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/dist/index.mjs CHANGED
@@ -3281,11 +3281,39 @@ class RevsetEngine {
3281
3281
  return parentKeys.every((k) => parentSnapshot[k] === ownSnapshot[k]);
3282
3282
  };
3283
3283
 
3284
+ // issue #43(2): the original #37(b) rule above (unreachable + isEmpty +
3285
+ // undescribed) only hides an orphan while it still happens to have
3286
+ // zero file diff versus its CURRENT parent. Two real shapes slip past
3287
+ // that once undo()/operations.restore() are involved, because both are
3288
+ // purely internal rewrite/undo artifacts that were never "empty" in
3289
+ // any way a user would recognize — they simply never held real user
3290
+ // content of their own, and a later revert of something else can make
3291
+ // isEmpty() disagree even though nothing about THIS change changed:
3292
+ //
3293
+ // 1. squash() creates a fresh empty `@` on top of `dest` when
3294
+ // squashing the working copy. If that squash is later undone,
3295
+ // the fresh `@` becomes unreferenced — but if `dest` itself gets
3296
+ // reverted back to its pre-squash content by the SAME undo,
3297
+ // isEmpty() now compares the orphan's (frozen, post-squash)
3298
+ // fileSnapshot against dest's (reverted, pre-squash) one and
3299
+ // finds a mismatch, even though the orphan is exactly as
3300
+ // "discardable" as it was the instant it was created.
3301
+ // 2. new() creates a fresh empty change on top of the previous `@`.
3302
+ // If something writes into it (write()/snapshot()) before an
3303
+ // undo() unwinds back past the new(), the orphan is left with
3304
+ // real file content that no longer matches its parent, even
3305
+ // though the user never gave it a real description.
3306
+ //
3307
+ // Changes created by squash()/new() are tagged `autoCreated: true` at
3308
+ // creation (see squash()/new() in repository.js) specifically so this
3309
+ // filter can hide them on that basis alone — still gated on being
3310
+ // unreachable and undescribed, so a change the user actually described
3311
+ // (or that's still live) is never hidden by this rule.
3284
3312
  const hidden = new Set();
3285
3313
  for (const change of allChanges) {
3286
3314
  if (
3287
3315
  !reachable.has(change.changeId) &&
3288
- isEmpty(change) &&
3316
+ (isEmpty(change) || change.autoCreated) &&
3289
3317
  UNDESCRIBED.has(change.description)
3290
3318
  ) {
3291
3319
  hidden.add(change.changeId);
@@ -8085,6 +8113,38 @@ async function createJJ(options) {
8085
8113
  // an earlier, unrelated merge(). Fixed as part of issue #29.)
8086
8114
  };
8087
8115
 
8116
+ /**
8117
+ * Compute the full current set of "head" changeIds — every non-abandoned
8118
+ * change with no non-abandoned child. Used to record an accurate
8119
+ * `view.heads` when an operation runs (issue #43).
8120
+ *
8121
+ * Before this helper existed, several oplog-recording call sites
8122
+ * (squash(), rebase()/moveChange(), abandon()) hand-wrote a single-
8123
+ * element or empty `heads` array covering only the changeId(s) that
8124
+ * specific call happened to touch. That silently dropped any OTHER
8125
+ * live head the operation didn't itself touch (e.g. a sibling branch
8126
+ * left untouched by an abandon()/squash() of a different branch — "two
8127
+ * sibling changes report only one head between them"), and squash() in
8128
+ * particular recorded the OLD destination changeId instead of the fresh
8129
+ * working-copy change it creates on top of it when squashing @ ("after
8130
+ * a squash [heads] points at the parent instead of the new tip").
8131
+ *
8132
+ * @returns {Promise<string[]>}
8133
+ */
8134
+ const computeCurrentHeads = async () => {
8135
+ await graph.load();
8136
+ const allChanges = await graph.getAllChanges();
8137
+ const nonAbandoned = allChanges.filter((/** @type {any} */ c) => !c.abandoned);
8138
+ const nonAbandonedIds = new Set(nonAbandoned.map((/** @type {any} */ c) => c.changeId));
8139
+ const heads = [];
8140
+ for (const change of nonAbandoned) {
8141
+ const children = graph.getChildren(change.changeId);
8142
+ const hasLiveChild = children.some((/** @type {string} */ id) => nonAbandonedIds.has(id));
8143
+ if (!hasLiveChild) heads.push(change.changeId);
8144
+ }
8145
+ return heads;
8146
+ };
8147
+
8088
8148
  /**
8089
8149
  * Re-parent every child of `changeId` onto `newParents` instead — used
8090
8150
  * when a change is being elided from the graph (abandon(), issue #30;
@@ -8116,6 +8176,18 @@ async function createJJ(options) {
8116
8176
  }
8117
8177
  };
8118
8178
 
8179
+ // NOTE (issue #43(5), punted — see PR description): an earlier version of
8180
+ // this file added a `rebaseDescendantsOntoUpdatedParent()` helper, called
8181
+ // from edit(), to propagate an edited ancestor's content onto descendants
8182
+ // automatically. It was reverted: it directly conflicts with absorb()'s
8183
+ // job (folding a working-copy edit back into an ancestor it actually
8184
+ // belongs to) — with the auto-rebase in place, edit()+write()+edit()-away
8185
+ // already changes the descendant before absorb() ever runs, so absorb()
8186
+ // finds nothing left to do (see tests/integration/absorb.test.js
8187
+ // "should work after edit()", which regressed against this). Fixing
8188
+ // #43(5) properly needs to account for that interaction rather than
8189
+ // duplicate it; left for a follow-up.
8190
+
8119
8191
  /**
8120
8192
  * Determine which operation a fresh undo() call should revert, matching
8121
8193
  * real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
@@ -8551,6 +8623,17 @@ async function createJJ(options) {
8551
8623
  });
8552
8624
  }
8553
8625
 
8626
+ await workingCopy.load();
8627
+
8628
+ // Capture disk state right before this write mutates it, and record
8629
+ // an operation for it (issue #43(1)): write() previously recorded NO
8630
+ // operation at all, so an edit typed into a change's working copy
8631
+ // could never be rewound by undo()/operations.restore() regardless
8632
+ // of which op you targeted — the change kept its post-edit content
8633
+ // forever. Mirrors the "snapshot before, record after" pattern every
8634
+ // other mutator in this file uses (see describe(), moveChange()).
8635
+ const fileSnapshotBefore = await snapshotFilesystem();
8636
+
8554
8637
  const fullPath = `${dir}/${args.path}`;
8555
8638
 
8556
8639
  // Ensure directory exists
@@ -8566,7 +8649,6 @@ async function createJJ(options) {
8566
8649
  await fs.promises.writeFile(fullPath, data, 'utf8');
8567
8650
 
8568
8651
  // Track the file in working copy
8569
- await workingCopy.load();
8570
8652
  const stats = await fs.promises.stat(fullPath);
8571
8653
  await workingCopy.trackFile(args.path, {
8572
8654
  mtime: stats.mtime,
@@ -8574,6 +8656,20 @@ async function createJJ(options) {
8574
8656
  mode: stats.mode,
8575
8657
  });
8576
8658
 
8659
+ await oplog.recordOperation({
8660
+ timestamp: new Date().toISOString(),
8661
+ user: await getUserOplogInfo(),
8662
+ description: `write ${args.path}`,
8663
+ parents: [],
8664
+ view: {
8665
+ bookmarks: {},
8666
+ remoteBookmarks: {},
8667
+ heads: await computeCurrentHeads(),
8668
+ workingCopy: workingCopy.getCurrentChangeId(),
8669
+ fileSnapshot: fileSnapshotBefore, // Store filesystem state from before this write
8670
+ },
8671
+ });
8672
+
8577
8673
  // Return useful information about the written file
8578
8674
  return {
8579
8675
  path: args.path,
@@ -9002,6 +9098,15 @@ async function createJJ(options) {
9002
9098
  async moveChange(args) {
9003
9099
  await graph.load();
9004
9100
 
9101
+ // Capture disk state right before this rebase mutates anything, so
9102
+ // undo()/redo()/operations.restore() can rewind past it — see issue
9103
+ // #43(1): rebase()/moveChange() previously recorded no "files
9104
+ // before" snapshot at all, so restoring to a point right before a
9105
+ // rebase ran had no on-disk state to fall back to and would instead
9106
+ // pick up whatever a LATER operation happened to record (the wrong
9107
+ // point in time).
9108
+ const fileSnapshotBefore = await snapshotFilesystem();
9109
+
9005
9110
  // Support multiple parameter names for flexibility
9006
9111
  const changeId = args.changeId || args.from || args.source;
9007
9112
  const newParent = args.newParent || args.to || args.destination;
@@ -9114,6 +9219,32 @@ async function createJJ(options) {
9114
9219
  await conflicts.addConflict(conflict);
9115
9220
  }
9116
9221
 
9222
+ // Materialize real conflict-marker text into the moved change's file
9223
+ // content — issue #43(3): rebase()/moveChange() used to record the
9224
+ // conflict correctly as DATA (via conflicts.addConflict() above) but
9225
+ // left the file's content exactly as it was pre-rebase ("ours"),
9226
+ // never writing marker text into change.fileSnapshot or, when this
9227
+ // change is the checked-out working copy, onto disk. Reuses
9228
+ // conflicts.markers()'s own marker-formatting logic (the same format
9229
+ // jj.conflicts.markers() already returns) instead of re-deriving
9230
+ // marker text a second way.
9231
+ if (detectedConflicts.length > 0) {
9232
+ change.fileSnapshot = { ...(change.fileSnapshot || {}) };
9233
+ const isCheckedOut = changeId === workingCopy.getCurrentChangeId();
9234
+ for (const conflict of detectedConflicts) {
9235
+ const markerText = await jj.conflicts.markers({ conflictId: conflict.conflictId });
9236
+ change.fileSnapshot[conflict.path] = markerText;
9237
+ if (isCheckedOut) {
9238
+ const fullPath = join(dir, conflict.path);
9239
+ const pathParts = conflict.path.split('/');
9240
+ if (pathParts.length > 1) {
9241
+ await mkdirp(fs, join(dir, pathParts.slice(0, -1).join('/')));
9242
+ }
9243
+ await fs.promises.writeFile(fullPath, markerText, 'utf8');
9244
+ }
9245
+ }
9246
+ }
9247
+
9117
9248
  // Update parent
9118
9249
  change.parents = [newParent];
9119
9250
  await graph.updateChange(change);
@@ -9129,8 +9260,9 @@ async function createJJ(options) {
9129
9260
  view: {
9130
9261
  bookmarks: {},
9131
9262
  remoteBookmarks: {},
9132
- heads: [],
9263
+ heads: await computeCurrentHeads(),
9133
9264
  workingCopy: workingCopy.getCurrentChangeId(),
9265
+ fileSnapshot: fileSnapshotBefore,
9134
9266
  },
9135
9267
  });
9136
9268
 
@@ -9585,6 +9717,16 @@ async function createJJ(options) {
9585
9717
  description: args.message || '(no description)',
9586
9718
  timestamp: new Date().toISOString(),
9587
9719
  fileSnapshot: initialSnapshot, // Initialize with parent's snapshot
9720
+ // Issue #43(2): marks this as an internal, synthetic artifact
9721
+ // rather than something the user actually created, so the revset
9722
+ // engine's orphan-visibility filter (_computeHiddenOrphans) can
9723
+ // still hide it after it's un-referenced by a later undo()/
9724
+ // operations.restore(), even if some out-of-band mutation (e.g.
9725
+ // write()+snapshot() before the undo unwinds this far) has since
9726
+ // made it look "non-empty" relative to its parent. A real
9727
+ // description (args.message, or a later describe()) still makes
9728
+ // it visible regardless, via the UNDESCRIBED check.
9729
+ autoCreated: true,
9588
9730
  };
9589
9731
 
9590
9732
  // Dispatch change:creating event (preventable)
@@ -9705,17 +9847,51 @@ async function createJJ(options) {
9705
9847
  await graph.load();
9706
9848
  await workingCopy.load();
9707
9849
  const result = await workingCopy.snapshot();
9850
+
9851
+ // Disk content itself is not mutated by this call (it's a read,
9852
+ // not a write) — the same value is therefore valid both as the
9853
+ // refreshed change.fileSnapshot AND as the recorded operation's
9854
+ // `view.fileSnapshot` "before" value (nothing on disk changes
9855
+ // between the two).
9856
+ const wcId = workingCopy.getCurrentChangeId();
9857
+ const currentDiskSnapshot = await snapshotFilesystem();
9858
+
9708
9859
  // Refresh the working-copy change's content snapshot from disk.
9860
+ /** @type {Record<string, any>} */
9861
+ const changeSnapshot = {};
9709
9862
  try {
9710
- const wcId = workingCopy.getCurrentChangeId();
9711
9863
  const change = await graph.getChange(wcId);
9712
9864
  if (change) {
9713
- change.fileSnapshot = await snapshotFilesystem();
9865
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore()
9866
+ // can reverse this content refresh — see issue #43(1):
9867
+ // snapshot() previously mutated `change.fileSnapshot` in place
9868
+ // with no "before" capture and recorded no operation at all, so
9869
+ // computeGraphReversal() had nothing to find for this changeId
9870
+ // if a restore()/undo() landed between an out-of-band edit and
9871
+ // the next real command.
9872
+ changeSnapshot[wcId] = structuredClone(change);
9873
+ change.fileSnapshot = currentDiskSnapshot;
9714
9874
  await graph.updateChange(change);
9715
9875
  }
9716
9876
  } catch {
9717
9877
  // Best-effort content refresh.
9718
9878
  }
9879
+
9880
+ await oplog.recordOperation({
9881
+ timestamp: new Date().toISOString(),
9882
+ user: await getUserOplogInfo(),
9883
+ description: 'snapshot working copy',
9884
+ parents: [],
9885
+ changeSnapshot,
9886
+ view: {
9887
+ bookmarks: {},
9888
+ remoteBookmarks: {},
9889
+ heads: await computeCurrentHeads(),
9890
+ workingCopy: wcId,
9891
+ fileSnapshot: currentDiskSnapshot,
9892
+ },
9893
+ });
9894
+
9719
9895
  return result;
9720
9896
  },
9721
9897
 
@@ -10179,8 +10355,20 @@ async function createJJ(options) {
10179
10355
  const current = await graph.getChange(changeId);
10180
10356
  if (current) redoChangeSnapshot[changeId] = current;
10181
10357
  }
10358
+ // Capture disk state right before this undo() runs. Used both as
10359
+ // `redoFileSnapshot` (for a later redo() — only meaningful when this
10360
+ // reversal actually touches files) AND, unconditionally, as THIS
10361
+ // undo operation's own `view.fileSnapshot` "before" value below —
10362
+ // see issue #43(1): undo()/redo()/operations.restore() used to
10363
+ // record `view: landingView` verbatim, i.e. reusing the LANDING
10364
+ // operation's own recorded fileSnapshot (disk state before THAT op
10365
+ // ran) instead of disk state before THIS undo ran. A later
10366
+ // restore()/undo() scanning forward past this one would then pick
10367
+ // up that stale, wrong snapshot instead of what was actually on disk
10368
+ // right before this undo executed.
10369
+ const diskSnapshotBeforeThisOp = await snapshotFilesystem();
10182
10370
  const redoFileSnapshot =
10183
- reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
10371
+ reversal.fileSnapshot !== undefined ? diskSnapshotBeforeThisOp : undefined;
10184
10372
  let redoConflictsSnapshot;
10185
10373
  if (reversal.conflictsSnapshot !== undefined) {
10186
10374
  await conflicts.load();
@@ -10221,7 +10409,7 @@ async function createJJ(options) {
10221
10409
  redoFileSnapshot,
10222
10410
  redoConflictsSnapshot,
10223
10411
  parents: [],
10224
- view: landingView,
10412
+ view: { ...landingView, fileSnapshot: diskSnapshotBeforeThisOp },
10225
10413
  });
10226
10414
 
10227
10415
  const restoredFileCount =
@@ -10315,6 +10503,15 @@ async function createJJ(options) {
10315
10503
  await baseGraph.updateChange(snapshot);
10316
10504
  }
10317
10505
  }
10506
+
10507
+ // Capture disk state right before THIS redo() mutates it — see issue
10508
+ // #43(1)/the matching fix in undo(): `view: redoneView` below reuses
10509
+ // the ORIGINAL (re-applied) operation's own recorded pointers, but
10510
+ // its `fileSnapshot` sub-field must reflect disk state before THIS
10511
+ // redo ran, not before that original operation ran, or a later
10512
+ // restore()/undo() scanning forward past this redo would pick up a
10513
+ // stale, wrong snapshot.
10514
+ const diskSnapshotBeforeThisOp = await snapshotFilesystem();
10318
10515
  if (undoOp.redoFileSnapshot !== undefined) {
10319
10516
  await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
10320
10517
  }
@@ -10335,7 +10532,7 @@ async function createJJ(options) {
10335
10532
  redoOf: undoOp.id,
10336
10533
  changeSnapshot: undoChangeSnapshot,
10337
10534
  parents: [],
10338
- view: redoneView,
10535
+ view: { ...redoneView, fileSnapshot: diskSnapshotBeforeThisOp },
10339
10536
  });
10340
10537
 
10341
10538
  const restoredFileCount =
@@ -10641,6 +10838,17 @@ async function createJJ(options) {
10641
10838
  if (current) changeSnapshot[changeId] = current;
10642
10839
  }
10643
10840
 
10841
+ // Capture disk state right before THIS restore() mutates it — see
10842
+ // issue #43(1)/the matching fix in undo()/redo(): recording
10843
+ // `view: targetOp.view` verbatim reused targetOp's OWN recorded
10844
+ // `fileSnapshot` (disk state before targetOp ran), not disk state
10845
+ // before THIS restore ran. A later restore()/undo() scanning
10846
+ // forward past this one would then pick up that stale, wrong
10847
+ // snapshot instead of what was actually on disk right before this
10848
+ // restore executed — this is exactly the "forward restore to
10849
+ // 'edit tip' lost style.css" repro.
10850
+ const diskSnapshotBeforeThisOp = await snapshotFilesystem();
10851
+
10644
10852
  await applyGraphReversal(reversal);
10645
10853
 
10646
10854
  // Record this restoration as a new operation
@@ -10650,7 +10858,7 @@ async function createJJ(options) {
10650
10858
  description: `restore to operation ${args.operation}`,
10651
10859
  parents: [],
10652
10860
  changeSnapshot,
10653
- view: targetOp.view,
10861
+ view: { ...targetOp.view, fileSnapshot: diskSnapshotBeforeThisOp },
10654
10862
  });
10655
10863
 
10656
10864
  return {
@@ -10886,6 +11094,15 @@ async function createJJ(options) {
10886
11094
  await workingCopy.load();
10887
11095
  await userConfig.load();
10888
11096
 
11097
+ // Capture disk state right before this squash mutates anything, so
11098
+ // undo()/redo()/operations.restore() can rewind past it — see issue
11099
+ // #43(1): squash() previously recorded no "files before" snapshot at
11100
+ // all, so restoring to a point right before a squash ran had no
11101
+ // recorded on-disk state and would fall through to whatever a LATER
11102
+ // operation happened to record instead (the repro: a forward
11103
+ // restore to "edit tip" lost `style.css`).
11104
+ const fileSnapshotBefore = await snapshotFilesystem();
11105
+
10889
11106
  // Support 'into' as alias for 'dest' (matches JJ CLI)
10890
11107
  const dest = args.into || args.dest;
10891
11108
 
@@ -11017,6 +11234,18 @@ async function createJJ(options) {
11017
11234
  // every on-disk file as untracked/new until something re-wrote
11018
11235
  // it and triggered a fresh auto-snapshot.
11019
11236
  fileSnapshot: { ...destChange.fileSnapshot },
11237
+ // Marks this as an internal, synthetic artifact rather than
11238
+ // something the user actually created — issue #43(2): if this
11239
+ // squash is later undone, this change becomes unreferenced, and
11240
+ // by then `destChange` may have been reverted to pre-squash
11241
+ // content, making this change look "non-empty" relative to its
11242
+ // (now-reverted) parent even though it never held any real user
11243
+ // content of its own. The revset engine's orphan-visibility
11244
+ // filter (_computeHiddenOrphans) treats `autoCreated` as
11245
+ // sufficient on its own (given it's also unreferenced and
11246
+ // undescribed) rather than re-diffing against a parent that may
11247
+ // no longer resemble what this change was actually created from.
11248
+ autoCreated: true,
11020
11249
  };
11021
11250
 
11022
11251
  await graph.addChange(newChange); // Middleware will sync to Git
@@ -11039,8 +11268,18 @@ async function createJJ(options) {
11039
11268
  view: {
11040
11269
  bookmarks: {},
11041
11270
  remoteBookmarks: {},
11042
- heads: [destChangeId],
11271
+ // issue #43(1): this used to hard-code `[destChangeId]` — wrong
11272
+ // whenever squash() created a fresh working-copy change on top
11273
+ // of dest (the real new tip was `newWorkingCopyId`, not
11274
+ // `destChangeId` itself), and incomplete whenever a sibling head
11275
+ // elsewhere in the graph existed and wasn't touched by this
11276
+ // squash at all ("two sibling changes report only one head
11277
+ // between them"). computeCurrentHeads() reflects the WHOLE
11278
+ // repo's actual head set after this squash, not just the
11279
+ // changeId(s) this call happened to touch.
11280
+ heads: await computeCurrentHeads(),
11043
11281
  workingCopy: newWorkingCopyId,
11282
+ fileSnapshot: fileSnapshotBefore,
11044
11283
  },
11045
11284
  });
11046
11285
 
@@ -11112,6 +11351,12 @@ async function createJJ(options) {
11112
11351
  // abandoning any OTHER change must leave @ exactly where it was.
11113
11352
  const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
11114
11353
 
11354
+ // Capture disk state right before this abandon mutates anything, so
11355
+ // undo()/redo()/operations.restore() can rewind past it — see issue
11356
+ // #43(1): abandon() previously recorded no "files before" snapshot
11357
+ // at all.
11358
+ const fileSnapshotBefore = await snapshotFilesystem();
11359
+
11115
11360
  // Dispatch change:abandoning event (preventable)
11116
11361
  await dispatchEventAsync(jj, 'change:abandoning', {
11117
11362
  operation: 'abandon',
@@ -11146,8 +11391,16 @@ async function createJJ(options) {
11146
11391
  view: {
11147
11392
  bookmarks: {},
11148
11393
  remoteBookmarks: {},
11149
- heads: [],
11394
+ // issue #43(1): this used to hard-code `[]`, dropping every live
11395
+ // head from the recorded operation (including any untouched
11396
+ // sibling branch — "two sibling changes report only one head
11397
+ // between them"). computeCurrentHeads() reflects the actual
11398
+ // repo-wide head set right after this abandon (and its
11399
+ // reparenting) ran, before the compensating jj.new() below (if
11400
+ // any) records its own separate operation.
11401
+ heads: await computeCurrentHeads(),
11150
11402
  workingCopy: workingCopy.getCurrentChangeId(),
11403
+ fileSnapshot: fileSnapshotBefore,
11151
11404
  },
11152
11405
  });
11153
11406
 
@@ -13409,6 +13662,32 @@ async function createJJ(options) {
13409
13662
  throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
13410
13663
  }
13411
13664
 
13665
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore()
13666
+ // can reverse this resolution — issue #43(4): conflicts.resolve()
13667
+ // previously recorded no operation at all, so resolving a conflict
13668
+ // could never be undone: no changeSnapshot of the working-copy
13669
+ // change's content, no conflictsSnapshot of the pre-resolve
13670
+ // conflict state, and no fileSnapshot of the on-disk content
13671
+ // before the resolved content was written.
13672
+ await graph.load();
13673
+ await workingCopy.load();
13674
+ // structuredClone matters here (unlike the read-only conflictsSnapshot
13675
+ // capture in moveChange()/merge()): resolveConflict() below mutates
13676
+ // the SAME conflict object in place (`conflict.resolved = true`),
13677
+ // and Object.fromEntries() on the live Map would otherwise leave
13678
+ // this "before" snapshot holding a reference to that very object —
13679
+ // corrupting it into already showing `resolved: true` by the time
13680
+ // it's recorded.
13681
+ const conflictsSnapshotBefore = structuredClone({
13682
+ conflicts: Object.fromEntries(conflicts.conflicts),
13683
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
13684
+ });
13685
+ const fileSnapshotBefore = await snapshotFilesystem();
13686
+ const wcId = workingCopy.getCurrentChangeId();
13687
+ const wcChange = await graph.getChange(wcId);
13688
+ /** @type {Record<string, any>} */
13689
+ const changeSnapshot = wcChange ? { [wcId]: structuredClone(wcChange) } : {};
13690
+
13412
13691
  let resolvedContent;
13413
13692
 
13414
13693
  // v0.5: Support different resolution methods
@@ -13452,6 +13731,36 @@ async function createJJ(options) {
13452
13731
  await conflicts.resolveConflict(args.conflictId, 'manual');
13453
13732
  await conflicts.save();
13454
13733
 
13734
+ // Refresh the working-copy change's own fileSnapshot to match the
13735
+ // resolved on-disk content (conflicted files always live under
13736
+ // the checked-out working copy — see the writeFile above), so
13737
+ // undo()/restore() has an accurate "after" state to compare
13738
+ // against, and log()/read() see the resolution immediately rather
13739
+ // than waiting for the next auto-snapshot.
13740
+ if (wcChange) {
13741
+ wcChange.fileSnapshot = {
13742
+ ...(wcChange.fileSnapshot || {}),
13743
+ [conflict.path]: resolvedContent,
13744
+ };
13745
+ await graph.updateChange(wcChange);
13746
+ }
13747
+
13748
+ await oplog.recordOperation({
13749
+ timestamp: new Date().toISOString(),
13750
+ user: await getUserOplogInfo(),
13751
+ description: `resolve conflict ${args.conflictId} (${conflict.path})`,
13752
+ parents: [],
13753
+ changeSnapshot,
13754
+ conflictsSnapshot: conflictsSnapshotBefore,
13755
+ view: {
13756
+ bookmarks: {},
13757
+ remoteBookmarks: {},
13758
+ heads: await computeCurrentHeads(),
13759
+ workingCopy: wcId,
13760
+ fileSnapshot: fileSnapshotBefore,
13761
+ },
13762
+ });
13763
+
13455
13764
  return { resolved: true };
13456
13765
  },
13457
13766