isomorphic-jj 1.9.2 → 1.11.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 +239 -0
- package/dist/index.cjs +448 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +448 -22
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/api/repository.js +419 -21
- 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
|
|
@@ -424,8 +468,26 @@ export async function createJJ(options) {
|
|
|
424
468
|
const wcId = workingCopy.getCurrentChangeId();
|
|
425
469
|
const change = await graph.getChange(wcId);
|
|
426
470
|
if (change) {
|
|
471
|
+
const oldFileSnapshot = change.fileSnapshot ? structuredClone(change.fileSnapshot) : {};
|
|
427
472
|
change.fileSnapshot = await snapshotFilesystem();
|
|
428
473
|
await graph.updateChange(change);
|
|
474
|
+
|
|
475
|
+
// Issue #45: if the working-copy change isn't a leaf (e.g. we
|
|
476
|
+
// edit()'d into an older change earlier in the session), its
|
|
477
|
+
// descendants' fileSnapshot still reflects the OLD content and
|
|
478
|
+
// needs to be rebased onto the content just committed above —
|
|
479
|
+
// matching real jj's automatic rebase-on-edit. edit() itself is a
|
|
480
|
+
// pure checkout and never mutates content, so this is the actual
|
|
481
|
+
// moment content changes and the right place to propagate. `jj`
|
|
482
|
+
// (not `this`) is used here because this function is a plain
|
|
483
|
+
// closure, not a method of the `jj` object — but `jj` is a const
|
|
484
|
+
// in the same enclosing scope and this function only ever runs
|
|
485
|
+
// after `jj` is fully constructed, so the self-reference is safe
|
|
486
|
+
// (same pattern used elsewhere in this file, e.g. `jj.new(...)`).
|
|
487
|
+
const descendants = await jj._findDescendants(wcId, null);
|
|
488
|
+
if (descendants.length > 0) {
|
|
489
|
+
await jj._rebuildDescendants(wcId, null, oldFileSnapshot);
|
|
490
|
+
}
|
|
429
491
|
}
|
|
430
492
|
} catch {
|
|
431
493
|
// Best-effort content refresh; tracking state is already updated.
|
|
@@ -781,6 +843,17 @@ export async function createJJ(options) {
|
|
|
781
843
|
});
|
|
782
844
|
}
|
|
783
845
|
|
|
846
|
+
await workingCopy.load();
|
|
847
|
+
|
|
848
|
+
// Capture disk state right before this write mutates it, and record
|
|
849
|
+
// an operation for it (issue #43(1)): write() previously recorded NO
|
|
850
|
+
// operation at all, so an edit typed into a change's working copy
|
|
851
|
+
// could never be rewound by undo()/operations.restore() regardless
|
|
852
|
+
// of which op you targeted — the change kept its post-edit content
|
|
853
|
+
// forever. Mirrors the "snapshot before, record after" pattern every
|
|
854
|
+
// other mutator in this file uses (see describe(), moveChange()).
|
|
855
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
856
|
+
|
|
784
857
|
const fullPath = `${dir}/${args.path}`;
|
|
785
858
|
|
|
786
859
|
// Ensure directory exists
|
|
@@ -796,7 +869,6 @@ export async function createJJ(options) {
|
|
|
796
869
|
await fs.promises.writeFile(fullPath, data, 'utf8');
|
|
797
870
|
|
|
798
871
|
// Track the file in working copy
|
|
799
|
-
await workingCopy.load();
|
|
800
872
|
const stats = await fs.promises.stat(fullPath);
|
|
801
873
|
await workingCopy.trackFile(args.path, {
|
|
802
874
|
mtime: stats.mtime,
|
|
@@ -804,6 +876,20 @@ export async function createJJ(options) {
|
|
|
804
876
|
mode: stats.mode,
|
|
805
877
|
});
|
|
806
878
|
|
|
879
|
+
await oplog.recordOperation({
|
|
880
|
+
timestamp: new Date().toISOString(),
|
|
881
|
+
user: await getUserOplogInfo(),
|
|
882
|
+
description: `write ${args.path}`,
|
|
883
|
+
parents: [],
|
|
884
|
+
view: {
|
|
885
|
+
bookmarks: {},
|
|
886
|
+
remoteBookmarks: {},
|
|
887
|
+
heads: await computeCurrentHeads(),
|
|
888
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
889
|
+
fileSnapshot: fileSnapshotBefore, // Store filesystem state from before this write
|
|
890
|
+
},
|
|
891
|
+
});
|
|
892
|
+
|
|
807
893
|
// Return useful information about the written file
|
|
808
894
|
return {
|
|
809
895
|
path: args.path,
|
|
@@ -1232,6 +1318,15 @@ export async function createJJ(options) {
|
|
|
1232
1318
|
async moveChange(args) {
|
|
1233
1319
|
await graph.load();
|
|
1234
1320
|
|
|
1321
|
+
// Capture disk state right before this rebase mutates anything, so
|
|
1322
|
+
// undo()/redo()/operations.restore() can rewind past it — see issue
|
|
1323
|
+
// #43(1): rebase()/moveChange() previously recorded no "files
|
|
1324
|
+
// before" snapshot at all, so restoring to a point right before a
|
|
1325
|
+
// rebase ran had no on-disk state to fall back to and would instead
|
|
1326
|
+
// pick up whatever a LATER operation happened to record (the wrong
|
|
1327
|
+
// point in time).
|
|
1328
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
1329
|
+
|
|
1235
1330
|
// Support multiple parameter names for flexibility
|
|
1236
1331
|
const changeId = args.changeId || args.from || args.source;
|
|
1237
1332
|
const newParent = args.newParent || args.to || args.destination;
|
|
@@ -1344,6 +1439,32 @@ export async function createJJ(options) {
|
|
|
1344
1439
|
await conflicts.addConflict(conflict);
|
|
1345
1440
|
}
|
|
1346
1441
|
|
|
1442
|
+
// Materialize real conflict-marker text into the moved change's file
|
|
1443
|
+
// content — issue #43(3): rebase()/moveChange() used to record the
|
|
1444
|
+
// conflict correctly as DATA (via conflicts.addConflict() above) but
|
|
1445
|
+
// left the file's content exactly as it was pre-rebase ("ours"),
|
|
1446
|
+
// never writing marker text into change.fileSnapshot or, when this
|
|
1447
|
+
// change is the checked-out working copy, onto disk. Reuses
|
|
1448
|
+
// conflicts.markers()'s own marker-formatting logic (the same format
|
|
1449
|
+
// jj.conflicts.markers() already returns) instead of re-deriving
|
|
1450
|
+
// marker text a second way.
|
|
1451
|
+
if (detectedConflicts.length > 0) {
|
|
1452
|
+
change.fileSnapshot = { ...(change.fileSnapshot || {}) };
|
|
1453
|
+
const isCheckedOut = changeId === workingCopy.getCurrentChangeId();
|
|
1454
|
+
for (const conflict of detectedConflicts) {
|
|
1455
|
+
const markerText = await jj.conflicts.markers({ conflictId: conflict.conflictId });
|
|
1456
|
+
change.fileSnapshot[conflict.path] = markerText;
|
|
1457
|
+
if (isCheckedOut) {
|
|
1458
|
+
const fullPath = path.join(dir, conflict.path);
|
|
1459
|
+
const pathParts = conflict.path.split('/');
|
|
1460
|
+
if (pathParts.length > 1) {
|
|
1461
|
+
await mkdirp(fs, path.join(dir, pathParts.slice(0, -1).join('/')));
|
|
1462
|
+
}
|
|
1463
|
+
await fs.promises.writeFile(fullPath, markerText, 'utf8');
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1347
1468
|
// Update parent
|
|
1348
1469
|
change.parents = [newParent];
|
|
1349
1470
|
await graph.updateChange(change);
|
|
@@ -1359,8 +1480,9 @@ export async function createJJ(options) {
|
|
|
1359
1480
|
view: {
|
|
1360
1481
|
bookmarks: {},
|
|
1361
1482
|
remoteBookmarks: {},
|
|
1362
|
-
heads:
|
|
1483
|
+
heads: await computeCurrentHeads(),
|
|
1363
1484
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
1485
|
+
fileSnapshot: fileSnapshotBefore,
|
|
1364
1486
|
},
|
|
1365
1487
|
});
|
|
1366
1488
|
|
|
@@ -1815,6 +1937,16 @@ export async function createJJ(options) {
|
|
|
1815
1937
|
description: args.message || '(no description)',
|
|
1816
1938
|
timestamp: new Date().toISOString(),
|
|
1817
1939
|
fileSnapshot: initialSnapshot, // Initialize with parent's snapshot
|
|
1940
|
+
// Issue #43(2): marks this as an internal, synthetic artifact
|
|
1941
|
+
// rather than something the user actually created, so the revset
|
|
1942
|
+
// engine's orphan-visibility filter (_computeHiddenOrphans) can
|
|
1943
|
+
// still hide it after it's un-referenced by a later undo()/
|
|
1944
|
+
// operations.restore(), even if some out-of-band mutation (e.g.
|
|
1945
|
+
// write()+snapshot() before the undo unwinds this far) has since
|
|
1946
|
+
// made it look "non-empty" relative to its parent. A real
|
|
1947
|
+
// description (args.message, or a later describe()) still makes
|
|
1948
|
+
// it visible regardless, via the UNDESCRIBED check.
|
|
1949
|
+
autoCreated: true,
|
|
1818
1950
|
};
|
|
1819
1951
|
|
|
1820
1952
|
// Dispatch change:creating event (preventable)
|
|
@@ -1935,17 +2067,63 @@ export async function createJJ(options) {
|
|
|
1935
2067
|
await graph.load();
|
|
1936
2068
|
await workingCopy.load();
|
|
1937
2069
|
const result = await workingCopy.snapshot();
|
|
2070
|
+
|
|
2071
|
+
// Disk content itself is not mutated by this call (it's a read,
|
|
2072
|
+
// not a write) — the same value is therefore valid both as the
|
|
2073
|
+
// refreshed change.fileSnapshot AND as the recorded operation's
|
|
2074
|
+
// `view.fileSnapshot` "before" value (nothing on disk changes
|
|
2075
|
+
// between the two).
|
|
2076
|
+
const wcId = workingCopy.getCurrentChangeId();
|
|
2077
|
+
const currentDiskSnapshot = await snapshotFilesystem();
|
|
2078
|
+
|
|
1938
2079
|
// Refresh the working-copy change's content snapshot from disk.
|
|
2080
|
+
/** @type {Record<string, any>} */
|
|
2081
|
+
const changeSnapshot = {};
|
|
1939
2082
|
try {
|
|
1940
|
-
const wcId = workingCopy.getCurrentChangeId();
|
|
1941
2083
|
const change = await graph.getChange(wcId);
|
|
1942
2084
|
if (change) {
|
|
1943
|
-
|
|
2085
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore()
|
|
2086
|
+
// can reverse this content refresh — see issue #43(1):
|
|
2087
|
+
// snapshot() previously mutated `change.fileSnapshot` in place
|
|
2088
|
+
// with no "before" capture and recorded no operation at all, so
|
|
2089
|
+
// computeGraphReversal() had nothing to find for this changeId
|
|
2090
|
+
// if a restore()/undo() landed between an out-of-band edit and
|
|
2091
|
+
// the next real command.
|
|
2092
|
+
changeSnapshot[wcId] = structuredClone(change);
|
|
2093
|
+
change.fileSnapshot = currentDiskSnapshot;
|
|
1944
2094
|
await graph.updateChange(change);
|
|
2095
|
+
|
|
2096
|
+
// Issue #45: if the working-copy change isn't a leaf (e.g. we
|
|
2097
|
+
// edit()'d into an older change earlier in the session), its
|
|
2098
|
+
// descendants' fileSnapshot still reflects the OLD content and
|
|
2099
|
+
// needs to be rebased onto the content just committed above —
|
|
2100
|
+
// matching real jj's automatic rebase-on-edit. edit() itself is a
|
|
2101
|
+
// pure checkout and never mutates content, so this is the actual
|
|
2102
|
+
// moment content changes and the right place to propagate.
|
|
2103
|
+
const descendants = await this._findDescendants(wcId, null);
|
|
2104
|
+
if (descendants.length > 0) {
|
|
2105
|
+
await this._rebuildDescendants(wcId, null, changeSnapshot[wcId].fileSnapshot);
|
|
2106
|
+
}
|
|
1945
2107
|
}
|
|
1946
2108
|
} catch {
|
|
1947
|
-
// Best-effort content refresh.
|
|
2109
|
+
// Best-effort content refresh (including descendant propagation).
|
|
1948
2110
|
}
|
|
2111
|
+
|
|
2112
|
+
await oplog.recordOperation({
|
|
2113
|
+
timestamp: new Date().toISOString(),
|
|
2114
|
+
user: await getUserOplogInfo(),
|
|
2115
|
+
description: 'snapshot working copy',
|
|
2116
|
+
parents: [],
|
|
2117
|
+
changeSnapshot,
|
|
2118
|
+
view: {
|
|
2119
|
+
bookmarks: {},
|
|
2120
|
+
remoteBookmarks: {},
|
|
2121
|
+
heads: await computeCurrentHeads(),
|
|
2122
|
+
workingCopy: wcId,
|
|
2123
|
+
fileSnapshot: currentDiskSnapshot,
|
|
2124
|
+
},
|
|
2125
|
+
});
|
|
2126
|
+
|
|
1949
2127
|
return result;
|
|
1950
2128
|
},
|
|
1951
2129
|
|
|
@@ -2279,6 +2457,22 @@ export async function createJJ(options) {
|
|
|
2279
2457
|
changeSnapshot[previousChangeId] = structuredClone(previousChange);
|
|
2280
2458
|
previousChange.fileSnapshot = currentSnapshot;
|
|
2281
2459
|
await graph.updateChange(previousChange);
|
|
2460
|
+
|
|
2461
|
+
// Issue #45: this is the OTHER place a checked-out change's
|
|
2462
|
+
// content gets committed to the graph — not through snapshot()/
|
|
2463
|
+
// autoSnapshotWorkingCopy() at all, but here, inline, when
|
|
2464
|
+
// edit() leaves a change it was checked out on. If that change
|
|
2465
|
+
// isn't a leaf (we'd edit()'d into an ancestor), its descendants
|
|
2466
|
+
// still have the OLD content and need the same rebase-on-edit
|
|
2467
|
+
// treatment applied at the other two commit sites.
|
|
2468
|
+
const descendants = await this._findDescendants(previousChangeId, null);
|
|
2469
|
+
if (descendants.length > 0) {
|
|
2470
|
+
await this._rebuildDescendants(
|
|
2471
|
+
previousChangeId,
|
|
2472
|
+
null,
|
|
2473
|
+
changeSnapshot[previousChangeId].fileSnapshot
|
|
2474
|
+
);
|
|
2475
|
+
}
|
|
2282
2476
|
}
|
|
2283
2477
|
}
|
|
2284
2478
|
}
|
|
@@ -2409,8 +2603,20 @@ export async function createJJ(options) {
|
|
|
2409
2603
|
const current = await graph.getChange(changeId);
|
|
2410
2604
|
if (current) redoChangeSnapshot[changeId] = current;
|
|
2411
2605
|
}
|
|
2606
|
+
// Capture disk state right before this undo() runs. Used both as
|
|
2607
|
+
// `redoFileSnapshot` (for a later redo() — only meaningful when this
|
|
2608
|
+
// reversal actually touches files) AND, unconditionally, as THIS
|
|
2609
|
+
// undo operation's own `view.fileSnapshot` "before" value below —
|
|
2610
|
+
// see issue #43(1): undo()/redo()/operations.restore() used to
|
|
2611
|
+
// record `view: landingView` verbatim, i.e. reusing the LANDING
|
|
2612
|
+
// operation's own recorded fileSnapshot (disk state before THAT op
|
|
2613
|
+
// ran) instead of disk state before THIS undo ran. A later
|
|
2614
|
+
// restore()/undo() scanning forward past this one would then pick
|
|
2615
|
+
// up that stale, wrong snapshot instead of what was actually on disk
|
|
2616
|
+
// right before this undo executed.
|
|
2617
|
+
const diskSnapshotBeforeThisOp = await snapshotFilesystem();
|
|
2412
2618
|
const redoFileSnapshot =
|
|
2413
|
-
reversal.fileSnapshot !== undefined ?
|
|
2619
|
+
reversal.fileSnapshot !== undefined ? diskSnapshotBeforeThisOp : undefined;
|
|
2414
2620
|
let redoConflictsSnapshot;
|
|
2415
2621
|
if (reversal.conflictsSnapshot !== undefined) {
|
|
2416
2622
|
await conflicts.load();
|
|
@@ -2451,7 +2657,7 @@ export async function createJJ(options) {
|
|
|
2451
2657
|
redoFileSnapshot,
|
|
2452
2658
|
redoConflictsSnapshot,
|
|
2453
2659
|
parents: [],
|
|
2454
|
-
view: landingView,
|
|
2660
|
+
view: { ...landingView, fileSnapshot: diskSnapshotBeforeThisOp },
|
|
2455
2661
|
});
|
|
2456
2662
|
|
|
2457
2663
|
const restoredFileCount =
|
|
@@ -2545,6 +2751,15 @@ export async function createJJ(options) {
|
|
|
2545
2751
|
await baseGraph.updateChange(snapshot);
|
|
2546
2752
|
}
|
|
2547
2753
|
}
|
|
2754
|
+
|
|
2755
|
+
// Capture disk state right before THIS redo() mutates it — see issue
|
|
2756
|
+
// #43(1)/the matching fix in undo(): `view: redoneView` below reuses
|
|
2757
|
+
// the ORIGINAL (re-applied) operation's own recorded pointers, but
|
|
2758
|
+
// its `fileSnapshot` sub-field must reflect disk state before THIS
|
|
2759
|
+
// redo ran, not before that original operation ran, or a later
|
|
2760
|
+
// restore()/undo() scanning forward past this redo would pick up a
|
|
2761
|
+
// stale, wrong snapshot.
|
|
2762
|
+
const diskSnapshotBeforeThisOp = await snapshotFilesystem();
|
|
2548
2763
|
if (undoOp.redoFileSnapshot !== undefined) {
|
|
2549
2764
|
await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
|
|
2550
2765
|
}
|
|
@@ -2565,7 +2780,7 @@ export async function createJJ(options) {
|
|
|
2565
2780
|
redoOf: undoOp.id,
|
|
2566
2781
|
changeSnapshot: undoChangeSnapshot,
|
|
2567
2782
|
parents: [],
|
|
2568
|
-
view: redoneView,
|
|
2783
|
+
view: { ...redoneView, fileSnapshot: diskSnapshotBeforeThisOp },
|
|
2569
2784
|
});
|
|
2570
2785
|
|
|
2571
2786
|
const restoredFileCount =
|
|
@@ -2871,6 +3086,17 @@ export async function createJJ(options) {
|
|
|
2871
3086
|
if (current) changeSnapshot[changeId] = current;
|
|
2872
3087
|
}
|
|
2873
3088
|
|
|
3089
|
+
// Capture disk state right before THIS restore() mutates it — see
|
|
3090
|
+
// issue #43(1)/the matching fix in undo()/redo(): recording
|
|
3091
|
+
// `view: targetOp.view` verbatim reused targetOp's OWN recorded
|
|
3092
|
+
// `fileSnapshot` (disk state before targetOp ran), not disk state
|
|
3093
|
+
// before THIS restore ran. A later restore()/undo() scanning
|
|
3094
|
+
// forward past this one would then pick up that stale, wrong
|
|
3095
|
+
// snapshot instead of what was actually on disk right before this
|
|
3096
|
+
// restore executed — this is exactly the "forward restore to
|
|
3097
|
+
// 'edit tip' lost style.css" repro.
|
|
3098
|
+
const diskSnapshotBeforeThisOp = await snapshotFilesystem();
|
|
3099
|
+
|
|
2874
3100
|
await applyGraphReversal(reversal);
|
|
2875
3101
|
|
|
2876
3102
|
// Record this restoration as a new operation
|
|
@@ -2880,7 +3106,7 @@ export async function createJJ(options) {
|
|
|
2880
3106
|
description: `restore to operation ${args.operation}`,
|
|
2881
3107
|
parents: [],
|
|
2882
3108
|
changeSnapshot,
|
|
2883
|
-
view: targetOp.view,
|
|
3109
|
+
view: { ...targetOp.view, fileSnapshot: diskSnapshotBeforeThisOp },
|
|
2884
3110
|
});
|
|
2885
3111
|
|
|
2886
3112
|
return {
|
|
@@ -3116,6 +3342,15 @@ export async function createJJ(options) {
|
|
|
3116
3342
|
await workingCopy.load();
|
|
3117
3343
|
await userConfig.load();
|
|
3118
3344
|
|
|
3345
|
+
// Capture disk state right before this squash mutates anything, so
|
|
3346
|
+
// undo()/redo()/operations.restore() can rewind past it — see issue
|
|
3347
|
+
// #43(1): squash() previously recorded no "files before" snapshot at
|
|
3348
|
+
// all, so restoring to a point right before a squash ran had no
|
|
3349
|
+
// recorded on-disk state and would fall through to whatever a LATER
|
|
3350
|
+
// operation happened to record instead (the repro: a forward
|
|
3351
|
+
// restore to "edit tip" lost `style.css`).
|
|
3352
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
3353
|
+
|
|
3119
3354
|
// Support 'into' as alias for 'dest' (matches JJ CLI)
|
|
3120
3355
|
const dest = args.into || args.dest;
|
|
3121
3356
|
|
|
@@ -3247,6 +3482,18 @@ export async function createJJ(options) {
|
|
|
3247
3482
|
// every on-disk file as untracked/new until something re-wrote
|
|
3248
3483
|
// it and triggered a fresh auto-snapshot.
|
|
3249
3484
|
fileSnapshot: { ...destChange.fileSnapshot },
|
|
3485
|
+
// Marks this as an internal, synthetic artifact rather than
|
|
3486
|
+
// something the user actually created — issue #43(2): if this
|
|
3487
|
+
// squash is later undone, this change becomes unreferenced, and
|
|
3488
|
+
// by then `destChange` may have been reverted to pre-squash
|
|
3489
|
+
// content, making this change look "non-empty" relative to its
|
|
3490
|
+
// (now-reverted) parent even though it never held any real user
|
|
3491
|
+
// content of its own. The revset engine's orphan-visibility
|
|
3492
|
+
// filter (_computeHiddenOrphans) treats `autoCreated` as
|
|
3493
|
+
// sufficient on its own (given it's also unreferenced and
|
|
3494
|
+
// undescribed) rather than re-diffing against a parent that may
|
|
3495
|
+
// no longer resemble what this change was actually created from.
|
|
3496
|
+
autoCreated: true,
|
|
3250
3497
|
};
|
|
3251
3498
|
|
|
3252
3499
|
await graph.addChange(newChange); // Middleware will sync to Git
|
|
@@ -3269,8 +3516,18 @@ export async function createJJ(options) {
|
|
|
3269
3516
|
view: {
|
|
3270
3517
|
bookmarks: {},
|
|
3271
3518
|
remoteBookmarks: {},
|
|
3272
|
-
|
|
3519
|
+
// issue #43(1): this used to hard-code `[destChangeId]` — wrong
|
|
3520
|
+
// whenever squash() created a fresh working-copy change on top
|
|
3521
|
+
// of dest (the real new tip was `newWorkingCopyId`, not
|
|
3522
|
+
// `destChangeId` itself), and incomplete whenever a sibling head
|
|
3523
|
+
// elsewhere in the graph existed and wasn't touched by this
|
|
3524
|
+
// squash at all ("two sibling changes report only one head
|
|
3525
|
+
// between them"). computeCurrentHeads() reflects the WHOLE
|
|
3526
|
+
// repo's actual head set after this squash, not just the
|
|
3527
|
+
// changeId(s) this call happened to touch.
|
|
3528
|
+
heads: await computeCurrentHeads(),
|
|
3273
3529
|
workingCopy: newWorkingCopyId,
|
|
3530
|
+
fileSnapshot: fileSnapshotBefore,
|
|
3274
3531
|
},
|
|
3275
3532
|
});
|
|
3276
3533
|
|
|
@@ -3342,6 +3599,12 @@ export async function createJJ(options) {
|
|
|
3342
3599
|
// abandoning any OTHER change must leave @ exactly where it was.
|
|
3343
3600
|
const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
|
|
3344
3601
|
|
|
3602
|
+
// Capture disk state right before this abandon mutates anything, so
|
|
3603
|
+
// undo()/redo()/operations.restore() can rewind past it — see issue
|
|
3604
|
+
// #43(1): abandon() previously recorded no "files before" snapshot
|
|
3605
|
+
// at all.
|
|
3606
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
3607
|
+
|
|
3345
3608
|
// Dispatch change:abandoning event (preventable)
|
|
3346
3609
|
await dispatchEventAsync(jj, 'change:abandoning', {
|
|
3347
3610
|
operation: 'abandon',
|
|
@@ -3376,8 +3639,16 @@ export async function createJJ(options) {
|
|
|
3376
3639
|
view: {
|
|
3377
3640
|
bookmarks: {},
|
|
3378
3641
|
remoteBookmarks: {},
|
|
3379
|
-
|
|
3642
|
+
// issue #43(1): this used to hard-code `[]`, dropping every live
|
|
3643
|
+
// head from the recorded operation (including any untouched
|
|
3644
|
+
// sibling branch — "two sibling changes report only one head
|
|
3645
|
+
// between them"). computeCurrentHeads() reflects the actual
|
|
3646
|
+
// repo-wide head set right after this abandon (and its
|
|
3647
|
+
// reparenting) ran, before the compensating jj.new() below (if
|
|
3648
|
+
// any) records its own separate operation.
|
|
3649
|
+
heads: await computeCurrentHeads(),
|
|
3380
3650
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
3651
|
+
fileSnapshot: fileSnapshotBefore,
|
|
3381
3652
|
},
|
|
3382
3653
|
});
|
|
3383
3654
|
|
|
@@ -4440,9 +4711,27 @@ export async function createJJ(options) {
|
|
|
4440
4711
|
* @private
|
|
4441
4712
|
* @param {string} ancestorId - The ancestor change that was updated
|
|
4442
4713
|
* @param {string} stopAtChangeId - Don't rebuild this change (usually working copy)
|
|
4443
|
-
* @
|
|
4714
|
+
* @param {Record<string, string>} [originalAncestorSnapshot] - The
|
|
4715
|
+
* ancestor's fileSnapshot as it was BEFORE the caller's own
|
|
4716
|
+
* `graph.updateChange()` for `ancestorId` ran. Callers must invoke this
|
|
4717
|
+
* function AFTER committing the ancestor's new content (that's the
|
|
4718
|
+
* natural order — the ancestor's content must exist before its
|
|
4719
|
+
* descendants can be rebased onto it), but that means a live
|
|
4720
|
+
* `graph.getChange(ancestorId).fileSnapshot` read inside this function
|
|
4721
|
+
* would already see the NEW content. For the ancestor's DIRECT
|
|
4722
|
+
* children, this function compares each descendant's stale content
|
|
4723
|
+
* against "the original parent state" to figure out which lines are
|
|
4724
|
+
* the descendant's own edits; if that "original" state were actually
|
|
4725
|
+
* the already-new ancestor content, every line of the stale
|
|
4726
|
+
* descendant would look like a deliberate edit (stale != new) and get
|
|
4727
|
+
* preserved verbatim — silently reproducing the staleness instead of
|
|
4728
|
+
* fixing it. Passing the pre-mutation snapshot here lets the ancestor's
|
|
4729
|
+
* direct children be diffed against the correct baseline. Deeper
|
|
4730
|
+
* descendants are unaffected (their own immediate parent hasn't been
|
|
4731
|
+
* mutated yet when this function reads it, so the live read is
|
|
4732
|
+
* correct for them). Optional for backward compatibility.
|
|
4444
4733
|
*/
|
|
4445
|
-
async _rebuildDescendants(ancestorId, stopAtChangeId) {
|
|
4734
|
+
async _rebuildDescendants(ancestorId, stopAtChangeId, originalAncestorSnapshot) {
|
|
4446
4735
|
// Find all descendants of ancestorId
|
|
4447
4736
|
const allChanges = graph.getAll();
|
|
4448
4737
|
|
|
@@ -4450,11 +4739,16 @@ export async function createJJ(options) {
|
|
|
4450
4739
|
const originalParentStates = new Map();
|
|
4451
4740
|
for (const change of allChanges) {
|
|
4452
4741
|
if (change.parents && change.parents.length > 0) {
|
|
4453
|
-
const
|
|
4742
|
+
const parentId = change.parents[0];
|
|
4743
|
+
const parent = await graph.getChange(parentId);
|
|
4454
4744
|
if (parent) {
|
|
4745
|
+
const parentSnapshot =
|
|
4746
|
+
parentId === ancestorId && originalAncestorSnapshot !== undefined
|
|
4747
|
+
? JSON.parse(JSON.stringify(originalAncestorSnapshot))
|
|
4748
|
+
: JSON.parse(JSON.stringify(parent.fileSnapshot || {}));
|
|
4455
4749
|
originalParentStates.set(change.changeId, {
|
|
4456
|
-
parentId
|
|
4457
|
-
parentSnapshot
|
|
4750
|
+
parentId,
|
|
4751
|
+
parentSnapshot,
|
|
4458
4752
|
});
|
|
4459
4753
|
}
|
|
4460
4754
|
}
|
|
@@ -4524,30 +4818,78 @@ export async function createJJ(options) {
|
|
|
4524
4818
|
const allFiles = new Set([
|
|
4525
4819
|
...Object.keys(descendant.fileSnapshot || {}),
|
|
4526
4820
|
...Object.keys(parent.fileSnapshot || {}),
|
|
4821
|
+
...Object.keys(originalParentSnapshot || {}),
|
|
4527
4822
|
]);
|
|
4528
4823
|
|
|
4529
4824
|
for (const filePath of allFiles) {
|
|
4825
|
+
const descendantExists = Object.prototype.hasOwnProperty.call(
|
|
4826
|
+
descendant.fileSnapshot || {},
|
|
4827
|
+
filePath
|
|
4828
|
+
);
|
|
4829
|
+
const originalParentExists = Object.prototype.hasOwnProperty.call(
|
|
4830
|
+
originalParentSnapshot || {},
|
|
4831
|
+
filePath
|
|
4832
|
+
);
|
|
4833
|
+
const updatedParentExists = Object.prototype.hasOwnProperty.call(
|
|
4834
|
+
parent.fileSnapshot || {},
|
|
4835
|
+
filePath
|
|
4836
|
+
);
|
|
4837
|
+
|
|
4838
|
+
// A file's EXISTENCE is a separate question from its content —
|
|
4839
|
+
// coercing "doesn't exist" to '' before diffing (the previous
|
|
4840
|
+
// version of this loop did) means a descendant that deliberately
|
|
4841
|
+
// deleted a file the old parent had gets that file resurrected as
|
|
4842
|
+
// an empty string once rebuilt, instead of staying deleted. This
|
|
4843
|
+
// was a real, latent bug: nothing called this function at all
|
|
4844
|
+
// until issue #45 gave it a real caller, so it was never
|
|
4845
|
+
// exercised against a deletion before.
|
|
4846
|
+
if (
|
|
4847
|
+
descendantExists === originalParentExists &&
|
|
4848
|
+
(!descendantExists ||
|
|
4849
|
+
descendant.fileSnapshot[filePath] === originalParentSnapshot[filePath])
|
|
4850
|
+
) {
|
|
4851
|
+
// Descendant never touched this file relative to its old
|
|
4852
|
+
// parent (same existence + content as before) -- just follow
|
|
4853
|
+
// whatever the updated parent did with it (added, removed, or
|
|
4854
|
+
// changed since the descendant branched off).
|
|
4855
|
+
if (updatedParentExists) newSnapshot[filePath] = parent.fileSnapshot[filePath];
|
|
4856
|
+
continue; // else: stays deleted -- don't add it to newSnapshot
|
|
4857
|
+
}
|
|
4858
|
+
|
|
4859
|
+
if (originalParentExists && !descendantExists) {
|
|
4860
|
+
// Descendant deliberately deleted a file the old parent had —
|
|
4861
|
+
// preserve that deletion rather than resurrecting it.
|
|
4862
|
+
continue;
|
|
4863
|
+
}
|
|
4864
|
+
|
|
4865
|
+
if (!originalParentExists && descendantExists && !updatedParentExists) {
|
|
4866
|
+
// Descendant added a file that didn't exist on either side of
|
|
4867
|
+
// the parent at all -- keep the descendant's own addition.
|
|
4868
|
+
newSnapshot[filePath] = descendant.fileSnapshot[filePath];
|
|
4869
|
+
continue;
|
|
4870
|
+
}
|
|
4871
|
+
|
|
4872
|
+
// Both the descendant and the parent have real content for this
|
|
4873
|
+
// file (whether or not it existed originally) -- do the actual
|
|
4874
|
+
// line-level replay: find which lines the descendant changed
|
|
4875
|
+
// from the OLD parent, then reapply just those lines onto the
|
|
4876
|
+
// NEW parent's content.
|
|
4530
4877
|
const descendantContent = descendant.fileSnapshot[filePath] || '';
|
|
4531
4878
|
const originalParentContent = originalParentSnapshot[filePath] || '';
|
|
4532
4879
|
const updatedParentContent = parent.fileSnapshot[filePath] || '';
|
|
4533
4880
|
|
|
4534
|
-
// Find which lines descendant modified from original parent
|
|
4535
4881
|
const descendantLines = descendantContent.split('\n');
|
|
4536
4882
|
const originalParentLines = originalParentContent.split('\n');
|
|
4537
4883
|
const updatedParentLines = updatedParentContent.split('\n');
|
|
4538
4884
|
|
|
4539
|
-
// Rebuild file: start with updated parent, apply descendant's modifications
|
|
4540
4885
|
const resultLines = [...updatedParentLines];
|
|
4541
4886
|
|
|
4542
|
-
// Find lines that descendant modified
|
|
4543
4887
|
for (let i = 0; i < Math.max(descendantLines.length, originalParentLines.length); i++) {
|
|
4544
4888
|
const descendantLine = i < descendantLines.length ? descendantLines[i] : undefined;
|
|
4545
4889
|
const originalParentLine =
|
|
4546
4890
|
i < originalParentLines.length ? originalParentLines[i] : undefined;
|
|
4547
4891
|
|
|
4548
|
-
// If descendant modified this line from original parent, apply that modification
|
|
4549
4892
|
if (descendantLine !== originalParentLine) {
|
|
4550
|
-
// Extend resultLines if needed
|
|
4551
4893
|
while (resultLines.length <= i) {
|
|
4552
4894
|
resultLines.push('');
|
|
4553
4895
|
}
|
|
@@ -5639,6 +5981,32 @@ export async function createJJ(options) {
|
|
|
5639
5981
|
throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
|
|
5640
5982
|
}
|
|
5641
5983
|
|
|
5984
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore()
|
|
5985
|
+
// can reverse this resolution — issue #43(4): conflicts.resolve()
|
|
5986
|
+
// previously recorded no operation at all, so resolving a conflict
|
|
5987
|
+
// could never be undone: no changeSnapshot of the working-copy
|
|
5988
|
+
// change's content, no conflictsSnapshot of the pre-resolve
|
|
5989
|
+
// conflict state, and no fileSnapshot of the on-disk content
|
|
5990
|
+
// before the resolved content was written.
|
|
5991
|
+
await graph.load();
|
|
5992
|
+
await workingCopy.load();
|
|
5993
|
+
// structuredClone matters here (unlike the read-only conflictsSnapshot
|
|
5994
|
+
// capture in moveChange()/merge()): resolveConflict() below mutates
|
|
5995
|
+
// the SAME conflict object in place (`conflict.resolved = true`),
|
|
5996
|
+
// and Object.fromEntries() on the live Map would otherwise leave
|
|
5997
|
+
// this "before" snapshot holding a reference to that very object —
|
|
5998
|
+
// corrupting it into already showing `resolved: true` by the time
|
|
5999
|
+
// it's recorded.
|
|
6000
|
+
const conflictsSnapshotBefore = structuredClone({
|
|
6001
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
6002
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
6003
|
+
});
|
|
6004
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
6005
|
+
const wcId = workingCopy.getCurrentChangeId();
|
|
6006
|
+
const wcChange = await graph.getChange(wcId);
|
|
6007
|
+
/** @type {Record<string, any>} */
|
|
6008
|
+
const changeSnapshot = wcChange ? { [wcId]: structuredClone(wcChange) } : {};
|
|
6009
|
+
|
|
5642
6010
|
let resolvedContent;
|
|
5643
6011
|
|
|
5644
6012
|
// v0.5: Support different resolution methods
|
|
@@ -5682,6 +6050,36 @@ export async function createJJ(options) {
|
|
|
5682
6050
|
await conflicts.resolveConflict(args.conflictId, 'manual');
|
|
5683
6051
|
await conflicts.save();
|
|
5684
6052
|
|
|
6053
|
+
// Refresh the working-copy change's own fileSnapshot to match the
|
|
6054
|
+
// resolved on-disk content (conflicted files always live under
|
|
6055
|
+
// the checked-out working copy — see the writeFile above), so
|
|
6056
|
+
// undo()/restore() has an accurate "after" state to compare
|
|
6057
|
+
// against, and log()/read() see the resolution immediately rather
|
|
6058
|
+
// than waiting for the next auto-snapshot.
|
|
6059
|
+
if (wcChange) {
|
|
6060
|
+
wcChange.fileSnapshot = {
|
|
6061
|
+
...(wcChange.fileSnapshot || {}),
|
|
6062
|
+
[conflict.path]: resolvedContent,
|
|
6063
|
+
};
|
|
6064
|
+
await graph.updateChange(wcChange);
|
|
6065
|
+
}
|
|
6066
|
+
|
|
6067
|
+
await oplog.recordOperation({
|
|
6068
|
+
timestamp: new Date().toISOString(),
|
|
6069
|
+
user: await getUserOplogInfo(),
|
|
6070
|
+
description: `resolve conflict ${args.conflictId} (${conflict.path})`,
|
|
6071
|
+
parents: [],
|
|
6072
|
+
changeSnapshot,
|
|
6073
|
+
conflictsSnapshot: conflictsSnapshotBefore,
|
|
6074
|
+
view: {
|
|
6075
|
+
bookmarks: {},
|
|
6076
|
+
remoteBookmarks: {},
|
|
6077
|
+
heads: await computeCurrentHeads(),
|
|
6078
|
+
workingCopy: wcId,
|
|
6079
|
+
fileSnapshot: fileSnapshotBefore,
|
|
6080
|
+
},
|
|
6081
|
+
});
|
|
6082
|
+
|
|
5685
6083
|
return { resolved: true };
|
|
5686
6084
|
},
|
|
5687
6085
|
|