isomorphic-jj 1.9.2 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/dist/index.cjs +320 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +320 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/api/repository.js +291 -10
- package/src/core/revset-engine.js +29 -1
package/package.json
CHANGED
package/src/api/repository.js
CHANGED
|
@@ -315,6 +315,38 @@ export async function createJJ(options) {
|
|
|
315
315
|
// an earlier, unrelated merge(). Fixed as part of issue #29.)
|
|
316
316
|
};
|
|
317
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Compute the full current set of "head" changeIds — every non-abandoned
|
|
320
|
+
* change with no non-abandoned child. Used to record an accurate
|
|
321
|
+
* `view.heads` when an operation runs (issue #43).
|
|
322
|
+
*
|
|
323
|
+
* Before this helper existed, several oplog-recording call sites
|
|
324
|
+
* (squash(), rebase()/moveChange(), abandon()) hand-wrote a single-
|
|
325
|
+
* element or empty `heads` array covering only the changeId(s) that
|
|
326
|
+
* specific call happened to touch. That silently dropped any OTHER
|
|
327
|
+
* live head the operation didn't itself touch (e.g. a sibling branch
|
|
328
|
+
* left untouched by an abandon()/squash() of a different branch — "two
|
|
329
|
+
* sibling changes report only one head between them"), and squash() in
|
|
330
|
+
* particular recorded the OLD destination changeId instead of the fresh
|
|
331
|
+
* working-copy change it creates on top of it when squashing @ ("after
|
|
332
|
+
* a squash [heads] points at the parent instead of the new tip").
|
|
333
|
+
*
|
|
334
|
+
* @returns {Promise<string[]>}
|
|
335
|
+
*/
|
|
336
|
+
const computeCurrentHeads = async () => {
|
|
337
|
+
await graph.load();
|
|
338
|
+
const allChanges = await graph.getAllChanges();
|
|
339
|
+
const nonAbandoned = allChanges.filter((/** @type {any} */ c) => !c.abandoned);
|
|
340
|
+
const nonAbandonedIds = new Set(nonAbandoned.map((/** @type {any} */ c) => c.changeId));
|
|
341
|
+
const heads = [];
|
|
342
|
+
for (const change of nonAbandoned) {
|
|
343
|
+
const children = graph.getChildren(change.changeId);
|
|
344
|
+
const hasLiveChild = children.some((/** @type {string} */ id) => nonAbandonedIds.has(id));
|
|
345
|
+
if (!hasLiveChild) heads.push(change.changeId);
|
|
346
|
+
}
|
|
347
|
+
return heads;
|
|
348
|
+
};
|
|
349
|
+
|
|
318
350
|
/**
|
|
319
351
|
* Re-parent every child of `changeId` onto `newParents` instead — used
|
|
320
352
|
* when a change is being elided from the graph (abandon(), issue #30;
|
|
@@ -346,6 +378,18 @@ export async function createJJ(options) {
|
|
|
346
378
|
}
|
|
347
379
|
};
|
|
348
380
|
|
|
381
|
+
// NOTE (issue #43(5), punted — see PR description): an earlier version of
|
|
382
|
+
// this file added a `rebaseDescendantsOntoUpdatedParent()` helper, called
|
|
383
|
+
// from edit(), to propagate an edited ancestor's content onto descendants
|
|
384
|
+
// automatically. It was reverted: it directly conflicts with absorb()'s
|
|
385
|
+
// job (folding a working-copy edit back into an ancestor it actually
|
|
386
|
+
// belongs to) — with the auto-rebase in place, edit()+write()+edit()-away
|
|
387
|
+
// already changes the descendant before absorb() ever runs, so absorb()
|
|
388
|
+
// finds nothing left to do (see tests/integration/absorb.test.js
|
|
389
|
+
// "should work after edit()", which regressed against this). Fixing
|
|
390
|
+
// #43(5) properly needs to account for that interaction rather than
|
|
391
|
+
// duplicate it; left for a follow-up.
|
|
392
|
+
|
|
349
393
|
/**
|
|
350
394
|
* Determine which operation a fresh undo() call should revert, matching
|
|
351
395
|
* real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
|
|
@@ -781,6 +825,17 @@ export async function createJJ(options) {
|
|
|
781
825
|
});
|
|
782
826
|
}
|
|
783
827
|
|
|
828
|
+
await workingCopy.load();
|
|
829
|
+
|
|
830
|
+
// Capture disk state right before this write mutates it, and record
|
|
831
|
+
// an operation for it (issue #43(1)): write() previously recorded NO
|
|
832
|
+
// operation at all, so an edit typed into a change's working copy
|
|
833
|
+
// could never be rewound by undo()/operations.restore() regardless
|
|
834
|
+
// of which op you targeted — the change kept its post-edit content
|
|
835
|
+
// forever. Mirrors the "snapshot before, record after" pattern every
|
|
836
|
+
// other mutator in this file uses (see describe(), moveChange()).
|
|
837
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
838
|
+
|
|
784
839
|
const fullPath = `${dir}/${args.path}`;
|
|
785
840
|
|
|
786
841
|
// Ensure directory exists
|
|
@@ -796,7 +851,6 @@ export async function createJJ(options) {
|
|
|
796
851
|
await fs.promises.writeFile(fullPath, data, 'utf8');
|
|
797
852
|
|
|
798
853
|
// Track the file in working copy
|
|
799
|
-
await workingCopy.load();
|
|
800
854
|
const stats = await fs.promises.stat(fullPath);
|
|
801
855
|
await workingCopy.trackFile(args.path, {
|
|
802
856
|
mtime: stats.mtime,
|
|
@@ -804,6 +858,20 @@ export async function createJJ(options) {
|
|
|
804
858
|
mode: stats.mode,
|
|
805
859
|
});
|
|
806
860
|
|
|
861
|
+
await oplog.recordOperation({
|
|
862
|
+
timestamp: new Date().toISOString(),
|
|
863
|
+
user: await getUserOplogInfo(),
|
|
864
|
+
description: `write ${args.path}`,
|
|
865
|
+
parents: [],
|
|
866
|
+
view: {
|
|
867
|
+
bookmarks: {},
|
|
868
|
+
remoteBookmarks: {},
|
|
869
|
+
heads: await computeCurrentHeads(),
|
|
870
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
871
|
+
fileSnapshot: fileSnapshotBefore, // Store filesystem state from before this write
|
|
872
|
+
},
|
|
873
|
+
});
|
|
874
|
+
|
|
807
875
|
// Return useful information about the written file
|
|
808
876
|
return {
|
|
809
877
|
path: args.path,
|
|
@@ -1232,6 +1300,15 @@ export async function createJJ(options) {
|
|
|
1232
1300
|
async moveChange(args) {
|
|
1233
1301
|
await graph.load();
|
|
1234
1302
|
|
|
1303
|
+
// Capture disk state right before this rebase mutates anything, so
|
|
1304
|
+
// undo()/redo()/operations.restore() can rewind past it — see issue
|
|
1305
|
+
// #43(1): rebase()/moveChange() previously recorded no "files
|
|
1306
|
+
// before" snapshot at all, so restoring to a point right before a
|
|
1307
|
+
// rebase ran had no on-disk state to fall back to and would instead
|
|
1308
|
+
// pick up whatever a LATER operation happened to record (the wrong
|
|
1309
|
+
// point in time).
|
|
1310
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
1311
|
+
|
|
1235
1312
|
// Support multiple parameter names for flexibility
|
|
1236
1313
|
const changeId = args.changeId || args.from || args.source;
|
|
1237
1314
|
const newParent = args.newParent || args.to || args.destination;
|
|
@@ -1344,6 +1421,32 @@ export async function createJJ(options) {
|
|
|
1344
1421
|
await conflicts.addConflict(conflict);
|
|
1345
1422
|
}
|
|
1346
1423
|
|
|
1424
|
+
// Materialize real conflict-marker text into the moved change's file
|
|
1425
|
+
// content — issue #43(3): rebase()/moveChange() used to record the
|
|
1426
|
+
// conflict correctly as DATA (via conflicts.addConflict() above) but
|
|
1427
|
+
// left the file's content exactly as it was pre-rebase ("ours"),
|
|
1428
|
+
// never writing marker text into change.fileSnapshot or, when this
|
|
1429
|
+
// change is the checked-out working copy, onto disk. Reuses
|
|
1430
|
+
// conflicts.markers()'s own marker-formatting logic (the same format
|
|
1431
|
+
// jj.conflicts.markers() already returns) instead of re-deriving
|
|
1432
|
+
// marker text a second way.
|
|
1433
|
+
if (detectedConflicts.length > 0) {
|
|
1434
|
+
change.fileSnapshot = { ...(change.fileSnapshot || {}) };
|
|
1435
|
+
const isCheckedOut = changeId === workingCopy.getCurrentChangeId();
|
|
1436
|
+
for (const conflict of detectedConflicts) {
|
|
1437
|
+
const markerText = await jj.conflicts.markers({ conflictId: conflict.conflictId });
|
|
1438
|
+
change.fileSnapshot[conflict.path] = markerText;
|
|
1439
|
+
if (isCheckedOut) {
|
|
1440
|
+
const fullPath = path.join(dir, conflict.path);
|
|
1441
|
+
const pathParts = conflict.path.split('/');
|
|
1442
|
+
if (pathParts.length > 1) {
|
|
1443
|
+
await mkdirp(fs, path.join(dir, pathParts.slice(0, -1).join('/')));
|
|
1444
|
+
}
|
|
1445
|
+
await fs.promises.writeFile(fullPath, markerText, 'utf8');
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1347
1450
|
// Update parent
|
|
1348
1451
|
change.parents = [newParent];
|
|
1349
1452
|
await graph.updateChange(change);
|
|
@@ -1359,8 +1462,9 @@ export async function createJJ(options) {
|
|
|
1359
1462
|
view: {
|
|
1360
1463
|
bookmarks: {},
|
|
1361
1464
|
remoteBookmarks: {},
|
|
1362
|
-
heads:
|
|
1465
|
+
heads: await computeCurrentHeads(),
|
|
1363
1466
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
1467
|
+
fileSnapshot: fileSnapshotBefore,
|
|
1364
1468
|
},
|
|
1365
1469
|
});
|
|
1366
1470
|
|
|
@@ -1815,6 +1919,16 @@ export async function createJJ(options) {
|
|
|
1815
1919
|
description: args.message || '(no description)',
|
|
1816
1920
|
timestamp: new Date().toISOString(),
|
|
1817
1921
|
fileSnapshot: initialSnapshot, // Initialize with parent's snapshot
|
|
1922
|
+
// Issue #43(2): marks this as an internal, synthetic artifact
|
|
1923
|
+
// rather than something the user actually created, so the revset
|
|
1924
|
+
// engine's orphan-visibility filter (_computeHiddenOrphans) can
|
|
1925
|
+
// still hide it after it's un-referenced by a later undo()/
|
|
1926
|
+
// operations.restore(), even if some out-of-band mutation (e.g.
|
|
1927
|
+
// write()+snapshot() before the undo unwinds this far) has since
|
|
1928
|
+
// made it look "non-empty" relative to its parent. A real
|
|
1929
|
+
// description (args.message, or a later describe()) still makes
|
|
1930
|
+
// it visible regardless, via the UNDESCRIBED check.
|
|
1931
|
+
autoCreated: true,
|
|
1818
1932
|
};
|
|
1819
1933
|
|
|
1820
1934
|
// Dispatch change:creating event (preventable)
|
|
@@ -1935,17 +2049,51 @@ export async function createJJ(options) {
|
|
|
1935
2049
|
await graph.load();
|
|
1936
2050
|
await workingCopy.load();
|
|
1937
2051
|
const result = await workingCopy.snapshot();
|
|
2052
|
+
|
|
2053
|
+
// Disk content itself is not mutated by this call (it's a read,
|
|
2054
|
+
// not a write) — the same value is therefore valid both as the
|
|
2055
|
+
// refreshed change.fileSnapshot AND as the recorded operation's
|
|
2056
|
+
// `view.fileSnapshot` "before" value (nothing on disk changes
|
|
2057
|
+
// between the two).
|
|
2058
|
+
const wcId = workingCopy.getCurrentChangeId();
|
|
2059
|
+
const currentDiskSnapshot = await snapshotFilesystem();
|
|
2060
|
+
|
|
1938
2061
|
// Refresh the working-copy change's content snapshot from disk.
|
|
2062
|
+
/** @type {Record<string, any>} */
|
|
2063
|
+
const changeSnapshot = {};
|
|
1939
2064
|
try {
|
|
1940
|
-
const wcId = workingCopy.getCurrentChangeId();
|
|
1941
2065
|
const change = await graph.getChange(wcId);
|
|
1942
2066
|
if (change) {
|
|
1943
|
-
|
|
2067
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore()
|
|
2068
|
+
// can reverse this content refresh — see issue #43(1):
|
|
2069
|
+
// snapshot() previously mutated `change.fileSnapshot` in place
|
|
2070
|
+
// with no "before" capture and recorded no operation at all, so
|
|
2071
|
+
// computeGraphReversal() had nothing to find for this changeId
|
|
2072
|
+
// if a restore()/undo() landed between an out-of-band edit and
|
|
2073
|
+
// the next real command.
|
|
2074
|
+
changeSnapshot[wcId] = structuredClone(change);
|
|
2075
|
+
change.fileSnapshot = currentDiskSnapshot;
|
|
1944
2076
|
await graph.updateChange(change);
|
|
1945
2077
|
}
|
|
1946
2078
|
} catch {
|
|
1947
2079
|
// Best-effort content refresh.
|
|
1948
2080
|
}
|
|
2081
|
+
|
|
2082
|
+
await oplog.recordOperation({
|
|
2083
|
+
timestamp: new Date().toISOString(),
|
|
2084
|
+
user: await getUserOplogInfo(),
|
|
2085
|
+
description: 'snapshot working copy',
|
|
2086
|
+
parents: [],
|
|
2087
|
+
changeSnapshot,
|
|
2088
|
+
view: {
|
|
2089
|
+
bookmarks: {},
|
|
2090
|
+
remoteBookmarks: {},
|
|
2091
|
+
heads: await computeCurrentHeads(),
|
|
2092
|
+
workingCopy: wcId,
|
|
2093
|
+
fileSnapshot: currentDiskSnapshot,
|
|
2094
|
+
},
|
|
2095
|
+
});
|
|
2096
|
+
|
|
1949
2097
|
return result;
|
|
1950
2098
|
},
|
|
1951
2099
|
|
|
@@ -2409,8 +2557,20 @@ export async function createJJ(options) {
|
|
|
2409
2557
|
const current = await graph.getChange(changeId);
|
|
2410
2558
|
if (current) redoChangeSnapshot[changeId] = current;
|
|
2411
2559
|
}
|
|
2560
|
+
// Capture disk state right before this undo() runs. Used both as
|
|
2561
|
+
// `redoFileSnapshot` (for a later redo() — only meaningful when this
|
|
2562
|
+
// reversal actually touches files) AND, unconditionally, as THIS
|
|
2563
|
+
// undo operation's own `view.fileSnapshot` "before" value below —
|
|
2564
|
+
// see issue #43(1): undo()/redo()/operations.restore() used to
|
|
2565
|
+
// record `view: landingView` verbatim, i.e. reusing the LANDING
|
|
2566
|
+
// operation's own recorded fileSnapshot (disk state before THAT op
|
|
2567
|
+
// ran) instead of disk state before THIS undo ran. A later
|
|
2568
|
+
// restore()/undo() scanning forward past this one would then pick
|
|
2569
|
+
// up that stale, wrong snapshot instead of what was actually on disk
|
|
2570
|
+
// right before this undo executed.
|
|
2571
|
+
const diskSnapshotBeforeThisOp = await snapshotFilesystem();
|
|
2412
2572
|
const redoFileSnapshot =
|
|
2413
|
-
reversal.fileSnapshot !== undefined ?
|
|
2573
|
+
reversal.fileSnapshot !== undefined ? diskSnapshotBeforeThisOp : undefined;
|
|
2414
2574
|
let redoConflictsSnapshot;
|
|
2415
2575
|
if (reversal.conflictsSnapshot !== undefined) {
|
|
2416
2576
|
await conflicts.load();
|
|
@@ -2451,7 +2611,7 @@ export async function createJJ(options) {
|
|
|
2451
2611
|
redoFileSnapshot,
|
|
2452
2612
|
redoConflictsSnapshot,
|
|
2453
2613
|
parents: [],
|
|
2454
|
-
view: landingView,
|
|
2614
|
+
view: { ...landingView, fileSnapshot: diskSnapshotBeforeThisOp },
|
|
2455
2615
|
});
|
|
2456
2616
|
|
|
2457
2617
|
const restoredFileCount =
|
|
@@ -2545,6 +2705,15 @@ export async function createJJ(options) {
|
|
|
2545
2705
|
await baseGraph.updateChange(snapshot);
|
|
2546
2706
|
}
|
|
2547
2707
|
}
|
|
2708
|
+
|
|
2709
|
+
// Capture disk state right before THIS redo() mutates it — see issue
|
|
2710
|
+
// #43(1)/the matching fix in undo(): `view: redoneView` below reuses
|
|
2711
|
+
// the ORIGINAL (re-applied) operation's own recorded pointers, but
|
|
2712
|
+
// its `fileSnapshot` sub-field must reflect disk state before THIS
|
|
2713
|
+
// redo ran, not before that original operation ran, or a later
|
|
2714
|
+
// restore()/undo() scanning forward past this redo would pick up a
|
|
2715
|
+
// stale, wrong snapshot.
|
|
2716
|
+
const diskSnapshotBeforeThisOp = await snapshotFilesystem();
|
|
2548
2717
|
if (undoOp.redoFileSnapshot !== undefined) {
|
|
2549
2718
|
await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
|
|
2550
2719
|
}
|
|
@@ -2565,7 +2734,7 @@ export async function createJJ(options) {
|
|
|
2565
2734
|
redoOf: undoOp.id,
|
|
2566
2735
|
changeSnapshot: undoChangeSnapshot,
|
|
2567
2736
|
parents: [],
|
|
2568
|
-
view: redoneView,
|
|
2737
|
+
view: { ...redoneView, fileSnapshot: diskSnapshotBeforeThisOp },
|
|
2569
2738
|
});
|
|
2570
2739
|
|
|
2571
2740
|
const restoredFileCount =
|
|
@@ -2871,6 +3040,17 @@ export async function createJJ(options) {
|
|
|
2871
3040
|
if (current) changeSnapshot[changeId] = current;
|
|
2872
3041
|
}
|
|
2873
3042
|
|
|
3043
|
+
// Capture disk state right before THIS restore() mutates it — see
|
|
3044
|
+
// issue #43(1)/the matching fix in undo()/redo(): recording
|
|
3045
|
+
// `view: targetOp.view` verbatim reused targetOp's OWN recorded
|
|
3046
|
+
// `fileSnapshot` (disk state before targetOp ran), not disk state
|
|
3047
|
+
// before THIS restore ran. A later restore()/undo() scanning
|
|
3048
|
+
// forward past this one would then pick up that stale, wrong
|
|
3049
|
+
// snapshot instead of what was actually on disk right before this
|
|
3050
|
+
// restore executed — this is exactly the "forward restore to
|
|
3051
|
+
// 'edit tip' lost style.css" repro.
|
|
3052
|
+
const diskSnapshotBeforeThisOp = await snapshotFilesystem();
|
|
3053
|
+
|
|
2874
3054
|
await applyGraphReversal(reversal);
|
|
2875
3055
|
|
|
2876
3056
|
// Record this restoration as a new operation
|
|
@@ -2880,7 +3060,7 @@ export async function createJJ(options) {
|
|
|
2880
3060
|
description: `restore to operation ${args.operation}`,
|
|
2881
3061
|
parents: [],
|
|
2882
3062
|
changeSnapshot,
|
|
2883
|
-
view: targetOp.view,
|
|
3063
|
+
view: { ...targetOp.view, fileSnapshot: diskSnapshotBeforeThisOp },
|
|
2884
3064
|
});
|
|
2885
3065
|
|
|
2886
3066
|
return {
|
|
@@ -3116,6 +3296,15 @@ export async function createJJ(options) {
|
|
|
3116
3296
|
await workingCopy.load();
|
|
3117
3297
|
await userConfig.load();
|
|
3118
3298
|
|
|
3299
|
+
// Capture disk state right before this squash mutates anything, so
|
|
3300
|
+
// undo()/redo()/operations.restore() can rewind past it — see issue
|
|
3301
|
+
// #43(1): squash() previously recorded no "files before" snapshot at
|
|
3302
|
+
// all, so restoring to a point right before a squash ran had no
|
|
3303
|
+
// recorded on-disk state and would fall through to whatever a LATER
|
|
3304
|
+
// operation happened to record instead (the repro: a forward
|
|
3305
|
+
// restore to "edit tip" lost `style.css`).
|
|
3306
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
3307
|
+
|
|
3119
3308
|
// Support 'into' as alias for 'dest' (matches JJ CLI)
|
|
3120
3309
|
const dest = args.into || args.dest;
|
|
3121
3310
|
|
|
@@ -3247,6 +3436,18 @@ export async function createJJ(options) {
|
|
|
3247
3436
|
// every on-disk file as untracked/new until something re-wrote
|
|
3248
3437
|
// it and triggered a fresh auto-snapshot.
|
|
3249
3438
|
fileSnapshot: { ...destChange.fileSnapshot },
|
|
3439
|
+
// Marks this as an internal, synthetic artifact rather than
|
|
3440
|
+
// something the user actually created — issue #43(2): if this
|
|
3441
|
+
// squash is later undone, this change becomes unreferenced, and
|
|
3442
|
+
// by then `destChange` may have been reverted to pre-squash
|
|
3443
|
+
// content, making this change look "non-empty" relative to its
|
|
3444
|
+
// (now-reverted) parent even though it never held any real user
|
|
3445
|
+
// content of its own. The revset engine's orphan-visibility
|
|
3446
|
+
// filter (_computeHiddenOrphans) treats `autoCreated` as
|
|
3447
|
+
// sufficient on its own (given it's also unreferenced and
|
|
3448
|
+
// undescribed) rather than re-diffing against a parent that may
|
|
3449
|
+
// no longer resemble what this change was actually created from.
|
|
3450
|
+
autoCreated: true,
|
|
3250
3451
|
};
|
|
3251
3452
|
|
|
3252
3453
|
await graph.addChange(newChange); // Middleware will sync to Git
|
|
@@ -3269,8 +3470,18 @@ export async function createJJ(options) {
|
|
|
3269
3470
|
view: {
|
|
3270
3471
|
bookmarks: {},
|
|
3271
3472
|
remoteBookmarks: {},
|
|
3272
|
-
|
|
3473
|
+
// issue #43(1): this used to hard-code `[destChangeId]` — wrong
|
|
3474
|
+
// whenever squash() created a fresh working-copy change on top
|
|
3475
|
+
// of dest (the real new tip was `newWorkingCopyId`, not
|
|
3476
|
+
// `destChangeId` itself), and incomplete whenever a sibling head
|
|
3477
|
+
// elsewhere in the graph existed and wasn't touched by this
|
|
3478
|
+
// squash at all ("two sibling changes report only one head
|
|
3479
|
+
// between them"). computeCurrentHeads() reflects the WHOLE
|
|
3480
|
+
// repo's actual head set after this squash, not just the
|
|
3481
|
+
// changeId(s) this call happened to touch.
|
|
3482
|
+
heads: await computeCurrentHeads(),
|
|
3273
3483
|
workingCopy: newWorkingCopyId,
|
|
3484
|
+
fileSnapshot: fileSnapshotBefore,
|
|
3274
3485
|
},
|
|
3275
3486
|
});
|
|
3276
3487
|
|
|
@@ -3342,6 +3553,12 @@ export async function createJJ(options) {
|
|
|
3342
3553
|
// abandoning any OTHER change must leave @ exactly where it was.
|
|
3343
3554
|
const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
|
|
3344
3555
|
|
|
3556
|
+
// Capture disk state right before this abandon mutates anything, so
|
|
3557
|
+
// undo()/redo()/operations.restore() can rewind past it — see issue
|
|
3558
|
+
// #43(1): abandon() previously recorded no "files before" snapshot
|
|
3559
|
+
// at all.
|
|
3560
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
3561
|
+
|
|
3345
3562
|
// Dispatch change:abandoning event (preventable)
|
|
3346
3563
|
await dispatchEventAsync(jj, 'change:abandoning', {
|
|
3347
3564
|
operation: 'abandon',
|
|
@@ -3376,8 +3593,16 @@ export async function createJJ(options) {
|
|
|
3376
3593
|
view: {
|
|
3377
3594
|
bookmarks: {},
|
|
3378
3595
|
remoteBookmarks: {},
|
|
3379
|
-
|
|
3596
|
+
// issue #43(1): this used to hard-code `[]`, dropping every live
|
|
3597
|
+
// head from the recorded operation (including any untouched
|
|
3598
|
+
// sibling branch — "two sibling changes report only one head
|
|
3599
|
+
// between them"). computeCurrentHeads() reflects the actual
|
|
3600
|
+
// repo-wide head set right after this abandon (and its
|
|
3601
|
+
// reparenting) ran, before the compensating jj.new() below (if
|
|
3602
|
+
// any) records its own separate operation.
|
|
3603
|
+
heads: await computeCurrentHeads(),
|
|
3380
3604
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
3605
|
+
fileSnapshot: fileSnapshotBefore,
|
|
3381
3606
|
},
|
|
3382
3607
|
});
|
|
3383
3608
|
|
|
@@ -5639,6 +5864,32 @@ export async function createJJ(options) {
|
|
|
5639
5864
|
throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
|
|
5640
5865
|
}
|
|
5641
5866
|
|
|
5867
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore()
|
|
5868
|
+
// can reverse this resolution — issue #43(4): conflicts.resolve()
|
|
5869
|
+
// previously recorded no operation at all, so resolving a conflict
|
|
5870
|
+
// could never be undone: no changeSnapshot of the working-copy
|
|
5871
|
+
// change's content, no conflictsSnapshot of the pre-resolve
|
|
5872
|
+
// conflict state, and no fileSnapshot of the on-disk content
|
|
5873
|
+
// before the resolved content was written.
|
|
5874
|
+
await graph.load();
|
|
5875
|
+
await workingCopy.load();
|
|
5876
|
+
// structuredClone matters here (unlike the read-only conflictsSnapshot
|
|
5877
|
+
// capture in moveChange()/merge()): resolveConflict() below mutates
|
|
5878
|
+
// the SAME conflict object in place (`conflict.resolved = true`),
|
|
5879
|
+
// and Object.fromEntries() on the live Map would otherwise leave
|
|
5880
|
+
// this "before" snapshot holding a reference to that very object —
|
|
5881
|
+
// corrupting it into already showing `resolved: true` by the time
|
|
5882
|
+
// it's recorded.
|
|
5883
|
+
const conflictsSnapshotBefore = structuredClone({
|
|
5884
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
5885
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
5886
|
+
});
|
|
5887
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
5888
|
+
const wcId = workingCopy.getCurrentChangeId();
|
|
5889
|
+
const wcChange = await graph.getChange(wcId);
|
|
5890
|
+
/** @type {Record<string, any>} */
|
|
5891
|
+
const changeSnapshot = wcChange ? { [wcId]: structuredClone(wcChange) } : {};
|
|
5892
|
+
|
|
5642
5893
|
let resolvedContent;
|
|
5643
5894
|
|
|
5644
5895
|
// v0.5: Support different resolution methods
|
|
@@ -5682,6 +5933,36 @@ export async function createJJ(options) {
|
|
|
5682
5933
|
await conflicts.resolveConflict(args.conflictId, 'manual');
|
|
5683
5934
|
await conflicts.save();
|
|
5684
5935
|
|
|
5936
|
+
// Refresh the working-copy change's own fileSnapshot to match the
|
|
5937
|
+
// resolved on-disk content (conflicted files always live under
|
|
5938
|
+
// the checked-out working copy — see the writeFile above), so
|
|
5939
|
+
// undo()/restore() has an accurate "after" state to compare
|
|
5940
|
+
// against, and log()/read() see the resolution immediately rather
|
|
5941
|
+
// than waiting for the next auto-snapshot.
|
|
5942
|
+
if (wcChange) {
|
|
5943
|
+
wcChange.fileSnapshot = {
|
|
5944
|
+
...(wcChange.fileSnapshot || {}),
|
|
5945
|
+
[conflict.path]: resolvedContent,
|
|
5946
|
+
};
|
|
5947
|
+
await graph.updateChange(wcChange);
|
|
5948
|
+
}
|
|
5949
|
+
|
|
5950
|
+
await oplog.recordOperation({
|
|
5951
|
+
timestamp: new Date().toISOString(),
|
|
5952
|
+
user: await getUserOplogInfo(),
|
|
5953
|
+
description: `resolve conflict ${args.conflictId} (${conflict.path})`,
|
|
5954
|
+
parents: [],
|
|
5955
|
+
changeSnapshot,
|
|
5956
|
+
conflictsSnapshot: conflictsSnapshotBefore,
|
|
5957
|
+
view: {
|
|
5958
|
+
bookmarks: {},
|
|
5959
|
+
remoteBookmarks: {},
|
|
5960
|
+
heads: await computeCurrentHeads(),
|
|
5961
|
+
workingCopy: wcId,
|
|
5962
|
+
fileSnapshot: fileSnapshotBefore,
|
|
5963
|
+
},
|
|
5964
|
+
});
|
|
5965
|
+
|
|
5685
5966
|
return { resolved: true };
|
|
5686
5967
|
},
|
|
5687
5968
|
|
|
@@ -1107,11 +1107,39 @@ export class RevsetEngine {
|
|
|
1107
1107
|
return parentKeys.every((k) => parentSnapshot[k] === ownSnapshot[k]);
|
|
1108
1108
|
};
|
|
1109
1109
|
|
|
1110
|
+
// issue #43(2): the original #37(b) rule above (unreachable + isEmpty +
|
|
1111
|
+
// undescribed) only hides an orphan while it still happens to have
|
|
1112
|
+
// zero file diff versus its CURRENT parent. Two real shapes slip past
|
|
1113
|
+
// that once undo()/operations.restore() are involved, because both are
|
|
1114
|
+
// purely internal rewrite/undo artifacts that were never "empty" in
|
|
1115
|
+
// any way a user would recognize — they simply never held real user
|
|
1116
|
+
// content of their own, and a later revert of something else can make
|
|
1117
|
+
// isEmpty() disagree even though nothing about THIS change changed:
|
|
1118
|
+
//
|
|
1119
|
+
// 1. squash() creates a fresh empty `@` on top of `dest` when
|
|
1120
|
+
// squashing the working copy. If that squash is later undone,
|
|
1121
|
+
// the fresh `@` becomes unreferenced — but if `dest` itself gets
|
|
1122
|
+
// reverted back to its pre-squash content by the SAME undo,
|
|
1123
|
+
// isEmpty() now compares the orphan's (frozen, post-squash)
|
|
1124
|
+
// fileSnapshot against dest's (reverted, pre-squash) one and
|
|
1125
|
+
// finds a mismatch, even though the orphan is exactly as
|
|
1126
|
+
// "discardable" as it was the instant it was created.
|
|
1127
|
+
// 2. new() creates a fresh empty change on top of the previous `@`.
|
|
1128
|
+
// If something writes into it (write()/snapshot()) before an
|
|
1129
|
+
// undo() unwinds back past the new(), the orphan is left with
|
|
1130
|
+
// real file content that no longer matches its parent, even
|
|
1131
|
+
// though the user never gave it a real description.
|
|
1132
|
+
//
|
|
1133
|
+
// Changes created by squash()/new() are tagged `autoCreated: true` at
|
|
1134
|
+
// creation (see squash()/new() in repository.js) specifically so this
|
|
1135
|
+
// filter can hide them on that basis alone — still gated on being
|
|
1136
|
+
// unreachable and undescribed, so a change the user actually described
|
|
1137
|
+
// (or that's still live) is never hidden by this rule.
|
|
1110
1138
|
const hidden = new Set();
|
|
1111
1139
|
for (const change of allChanges) {
|
|
1112
1140
|
if (
|
|
1113
1141
|
!reachable.has(change.changeId) &&
|
|
1114
|
-
isEmpty(change) &&
|
|
1142
|
+
(isEmpty(change) || change.autoCreated) &&
|
|
1115
1143
|
UNDESCRIBED.has(change.description)
|
|
1116
1144
|
) {
|
|
1117
1145
|
hidden.add(change.changeId);
|