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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "isomorphic-jj",
3
- "version": "1.9.2",
3
+ "version": "1.11.0",
4
4
  "description": "Jujutsu (jj) version control semantics in pure JavaScript — stable change IDs, fearless undo, no staging area — for Node.js and browsers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- change.fileSnapshot = await snapshotFilesystem();
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 ? await snapshotFilesystem() : 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
- heads: [destChangeId],
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
- heads: [],
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
- * @deprecated Use _rebuildDescendantsWithStates instead
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 parent = await graph.getChange(change.parents[0]);
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: change.parents[0],
4457
- parentSnapshot: JSON.parse(JSON.stringify(parent.fileSnapshot || {})),
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