isomorphic-jj 1.9.2 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,131 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.10.0 — 2026-09-26 — operations.restore() reliability (#43)
4
+
5
+ A substantial follow-up to 1.9.1/1.9.2's undo/restore work (#37/#38/#39/#41):
6
+ five distinct gaps that all fed the same root symptom — `operations.restore()`
7
+ (and `undo()`/`redo()`) not reliably reproducing the repository state at an
8
+ arbitrary past operation. Grouped into one minor release because all five
9
+ touch the same reversal machinery (`computeGraphReversal()`/
10
+ `applyGraphReversal()`, the `all()` revset's orphan filter, and the conflict
11
+ model), and because two of the fixes are real behavioral changes (`log()`'s
12
+ default visibility, and what `rebase()` actually writes into a conflicted
13
+ file) rather than pure bugfixes.
14
+
15
+ ### Fixed
16
+
17
+ - **`write()` and `snapshot()` now record their own operation (#43 part 1).**
18
+ Both mutated the working copy (disk content, and for `snapshot()` the
19
+ working-copy change's `fileSnapshot` too) without ever calling
20
+ `oplog.recordOperation()`. An edit typed into a change's working copy via
21
+ either was therefore completely invisible to `undo()`/`operations.restore()`
22
+ — the change kept its post-edit content forever regardless of which
23
+ operation you restored to, because there was no oplog entry to target and
24
+ no "before" snapshot for `computeGraphReversal()` to find. Both now follow
25
+ the same "snapshot the before-state, then record" pattern every other
26
+ mutator in this file already uses (see `describe()`), including a
27
+ `changeSnapshot` for `snapshot()` (which mutates a `ChangeGraph` node in
28
+ place) and a `view.fileSnapshot` for both.
29
+ - **`squash()`, `rebase()`/`moveChange()`, and `abandon()` now record a
30
+ "files before" snapshot as part of their own operation (#43 part 1).**
31
+ `computeGraphReversal()` reconstructs "the state right after operation X"
32
+ by scanning forward from X for the first LATER operation that recorded a
33
+ `view.fileSnapshot` "before" value. These three operations never recorded
34
+ one, so restoring to a point right before one of them ran had nothing
35
+ accurate to fall back on: the scan would skip straight over the gap and
36
+ either find nothing (leaving disk untouched) or land on a much later,
37
+ unrelated operation's "before" value — the wrong point in time entirely.
38
+ Reproduced via a stack-walk (build a chain of describe/new/write/squash/
39
+ describe operations, `operations.restore()` to every operation in it, and
40
+ compare on-disk files against an independently-tracked expectation) — this
41
+ is what surfaces as "a forward restore to an earlier op loses a file that
42
+ was added between a squash and the target". All three now capture disk
43
+ state before mutating anything and include it in their own `view`.
44
+ - **`undo()`/`redo()`/`operations.restore()` now record disk state right
45
+ before THEMSELVES ran, not the landing/target operation's own "before"
46
+ value (#43 part 1).** All three used to record `view: landingView` (or
47
+ `redoneView`/`targetOp.view`) verbatim — reusing that OTHER operation's
48
+ own recorded `fileSnapshot`, which represents disk state before THAT
49
+ operation ran, not before this undo/redo/restore call mutated anything.
50
+ A later `restore()`/`undo()` scanning forward past one of these would then
51
+ pick up that stale, wrong snapshot. Each now captures its own disk state
52
+ immediately before applying its reversal and merges it into the pointer
53
+ fields (`workingCopy`/`heads`/`bookmarks`) it still legitimately inherits
54
+ from the landing operation.
55
+ - **`view.heads` no longer silently drops live heads (#43 part 1).** Every
56
+ operation that recorded `view.heads` hand-wrote it as either `[]` or a
57
+ single changeId — whatever that specific call happened to touch —
58
+ instead of the repository's actual current head set. `squash()` in
59
+ particular recorded the destination changeId even when squashing the
60
+ working copy creates a fresh new tip on top of it (so `heads` pointed at
61
+ the parent, not the tip), and any operation that didn't itself touch an
62
+ untouched sibling branch silently reported only one head where two
63
+ existed. Added `computeCurrentHeads()` (every non-abandoned change with no
64
+ non-abandoned child) and switched `squash()`/`rebase()`/`moveChange()`/
65
+ `abandon()`/`conflicts.resolve()` to use it.
66
+ - **`log()` now hides orphans that are purely internal rewrite/undo
67
+ artifacts, even once they differ from their reverted parent (#43 part 2).**
68
+ The #37(b) fix hid an orphan only while it stayed exactly empty relative
69
+ to its CURRENT parent. Two shapes slip past that once undo() is involved:
70
+ (a) `squash()`'s synthetic post-squash `@`, if that squash is later undone
71
+ and the same undo reverts the destination's content out from under it —
72
+ the orphan's frozen (post-squash) `fileSnapshot` no longer matches the
73
+ destination's (reverted, pre-squash) one, even though nothing about the
74
+ orphan itself changed; (b) `new()`'s freshly created change, if something
75
+ writes into it before an `undo()` unwinds back past the `new()`. Both
76
+ `squash()`'s synthetic working-copy change and `new()`'s created change
77
+ are now tagged `autoCreated: true` at creation, and the revset engine's
78
+ `_computeHiddenOrphans()` hides an unreachable, undescribed change if it's
79
+ EITHER still empty relative to its parent OR `autoCreated` — the latter
80
+ doesn't need to re-diff against a parent that may no longer resemble what
81
+ the change was actually created from.
82
+ - **`rebase()`/`moveChange()` now writes real conflict-marker text into the
83
+ working copy instead of leaving the pre-rebase ("ours") content verbatim
84
+ (#43 part 3) — behavioral change.** Rebase already recorded a detected
85
+ conflict correctly as data (via `conflicts.addConflict()`), but never
86
+ touched the file's actual content: `change.fileSnapshot` (and, when the
87
+ rebased change was checked out, the on-disk file) kept showing whatever
88
+ content it had before the rebase, with no indication a conflict existed
89
+ short of calling `conflicts.list()` separately. It now calls
90
+ `jj.conflicts.markers()` for each detected conflict (reusing that
91
+ existing marker-formatting logic rather than re-deriving it a second way)
92
+ and writes the result into both `change.fileSnapshot` and, when
93
+ applicable, the working copy on disk.
94
+ - **`conflicts.resolve()` now records an operation, so a resolution can be
95
+ undone (#43 part 4).** It wrote the resolved content straight to disk and
96
+ mutated the `ConflictModel`'s persisted state directly, with no
97
+ `oplog.recordOperation()` call at all — resolving a conflict was
98
+ completely invisible to `undo()`/`operations.restore()`. It now captures
99
+ a `changeSnapshot` of the working-copy change, a `conflictsSnapshot` of
100
+ the pre-resolve conflict state (deep-cloned — `resolveConflict()` mutates
101
+ the same conflict object in place, so a naive `Object.fromEntries()` over
102
+ the live `Map` would otherwise leave the "before" snapshot aliased to the
103
+ post-mutation object), and a `view.fileSnapshot`, then records them.
104
+
105
+ ### Not fixed (punted)
106
+
107
+ - **#43 part 5 (minor): `edit()` of a non-leaf change doesn't rebase its
108
+ descendants' file content.** An initial fix (propagate the edited
109
+ ancestor's content onto descendants when moving away from it) was
110
+ implemented and then reverted: it directly conflicts with `absorb()`'s own
111
+ job of folding a working-copy edit back into the ancestor it actually
112
+ belongs to — with the auto-rebase in place, `edit()` + `write()` +
113
+ `edit()`-away already changes the descendant before `absorb()` ever runs,
114
+ so `absorb()` finds nothing left to do (caught by the existing
115
+ `tests/integration/absorb.test.js` "should work after edit()" test).
116
+ Fixing this properly needs to account for that interaction; left for a
117
+ follow-up.
118
+
119
+ ### Testing
120
+
121
+ 1859 tests passing (12 new regression tests, matched by a new negative
122
+ control confirming that 12 of them fail against the pre-fix code — the
123
+ other 3 are supplementary correctness checks that already held, e.g.
124
+ "a descendant's own edit is never clobbered"); lint (0 errors),
125
+ format:check, typecheck, and build all still green; all 12 `examples/*.mjs`
126
+ run clean; branch coverage 90.07% (gate: 90%). New test file:
127
+ `tests/integration/issue-43-restore-reliability.test.js`.
128
+
3
129
  ## 1.9.2 — 2026-09-26 — auto-snapshot mtime comparison (#41)
4
130
 
5
131
  ### Fixed
package/dist/index.cjs CHANGED
@@ -3283,11 +3283,39 @@ class RevsetEngine {
3283
3283
  return parentKeys.every((k) => parentSnapshot[k] === ownSnapshot[k]);
3284
3284
  };
3285
3285
 
3286
+ // issue #43(2): the original #37(b) rule above (unreachable + isEmpty +
3287
+ // undescribed) only hides an orphan while it still happens to have
3288
+ // zero file diff versus its CURRENT parent. Two real shapes slip past
3289
+ // that once undo()/operations.restore() are involved, because both are
3290
+ // purely internal rewrite/undo artifacts that were never "empty" in
3291
+ // any way a user would recognize — they simply never held real user
3292
+ // content of their own, and a later revert of something else can make
3293
+ // isEmpty() disagree even though nothing about THIS change changed:
3294
+ //
3295
+ // 1. squash() creates a fresh empty `@` on top of `dest` when
3296
+ // squashing the working copy. If that squash is later undone,
3297
+ // the fresh `@` becomes unreferenced — but if `dest` itself gets
3298
+ // reverted back to its pre-squash content by the SAME undo,
3299
+ // isEmpty() now compares the orphan's (frozen, post-squash)
3300
+ // fileSnapshot against dest's (reverted, pre-squash) one and
3301
+ // finds a mismatch, even though the orphan is exactly as
3302
+ // "discardable" as it was the instant it was created.
3303
+ // 2. new() creates a fresh empty change on top of the previous `@`.
3304
+ // If something writes into it (write()/snapshot()) before an
3305
+ // undo() unwinds back past the new(), the orphan is left with
3306
+ // real file content that no longer matches its parent, even
3307
+ // though the user never gave it a real description.
3308
+ //
3309
+ // Changes created by squash()/new() are tagged `autoCreated: true` at
3310
+ // creation (see squash()/new() in repository.js) specifically so this
3311
+ // filter can hide them on that basis alone — still gated on being
3312
+ // unreachable and undescribed, so a change the user actually described
3313
+ // (or that's still live) is never hidden by this rule.
3286
3314
  const hidden = new Set();
3287
3315
  for (const change of allChanges) {
3288
3316
  if (
3289
3317
  !reachable.has(change.changeId) &&
3290
- isEmpty(change) &&
3318
+ (isEmpty(change) || change.autoCreated) &&
3291
3319
  UNDESCRIBED.has(change.description)
3292
3320
  ) {
3293
3321
  hidden.add(change.changeId);
@@ -8087,6 +8115,38 @@ async function createJJ(options) {
8087
8115
  // an earlier, unrelated merge(). Fixed as part of issue #29.)
8088
8116
  };
8089
8117
 
8118
+ /**
8119
+ * Compute the full current set of "head" changeIds — every non-abandoned
8120
+ * change with no non-abandoned child. Used to record an accurate
8121
+ * `view.heads` when an operation runs (issue #43).
8122
+ *
8123
+ * Before this helper existed, several oplog-recording call sites
8124
+ * (squash(), rebase()/moveChange(), abandon()) hand-wrote a single-
8125
+ * element or empty `heads` array covering only the changeId(s) that
8126
+ * specific call happened to touch. That silently dropped any OTHER
8127
+ * live head the operation didn't itself touch (e.g. a sibling branch
8128
+ * left untouched by an abandon()/squash() of a different branch — "two
8129
+ * sibling changes report only one head between them"), and squash() in
8130
+ * particular recorded the OLD destination changeId instead of the fresh
8131
+ * working-copy change it creates on top of it when squashing @ ("after
8132
+ * a squash [heads] points at the parent instead of the new tip").
8133
+ *
8134
+ * @returns {Promise<string[]>}
8135
+ */
8136
+ const computeCurrentHeads = async () => {
8137
+ await graph.load();
8138
+ const allChanges = await graph.getAllChanges();
8139
+ const nonAbandoned = allChanges.filter((/** @type {any} */ c) => !c.abandoned);
8140
+ const nonAbandonedIds = new Set(nonAbandoned.map((/** @type {any} */ c) => c.changeId));
8141
+ const heads = [];
8142
+ for (const change of nonAbandoned) {
8143
+ const children = graph.getChildren(change.changeId);
8144
+ const hasLiveChild = children.some((/** @type {string} */ id) => nonAbandonedIds.has(id));
8145
+ if (!hasLiveChild) heads.push(change.changeId);
8146
+ }
8147
+ return heads;
8148
+ };
8149
+
8090
8150
  /**
8091
8151
  * Re-parent every child of `changeId` onto `newParents` instead — used
8092
8152
  * when a change is being elided from the graph (abandon(), issue #30;
@@ -8118,6 +8178,18 @@ async function createJJ(options) {
8118
8178
  }
8119
8179
  };
8120
8180
 
8181
+ // NOTE (issue #43(5), punted — see PR description): an earlier version of
8182
+ // this file added a `rebaseDescendantsOntoUpdatedParent()` helper, called
8183
+ // from edit(), to propagate an edited ancestor's content onto descendants
8184
+ // automatically. It was reverted: it directly conflicts with absorb()'s
8185
+ // job (folding a working-copy edit back into an ancestor it actually
8186
+ // belongs to) — with the auto-rebase in place, edit()+write()+edit()-away
8187
+ // already changes the descendant before absorb() ever runs, so absorb()
8188
+ // finds nothing left to do (see tests/integration/absorb.test.js
8189
+ // "should work after edit()", which regressed against this). Fixing
8190
+ // #43(5) properly needs to account for that interaction rather than
8191
+ // duplicate it; left for a follow-up.
8192
+
8121
8193
  /**
8122
8194
  * Determine which operation a fresh undo() call should revert, matching
8123
8195
  * real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
@@ -8553,6 +8625,17 @@ async function createJJ(options) {
8553
8625
  });
8554
8626
  }
8555
8627
 
8628
+ await workingCopy.load();
8629
+
8630
+ // Capture disk state right before this write mutates it, and record
8631
+ // an operation for it (issue #43(1)): write() previously recorded NO
8632
+ // operation at all, so an edit typed into a change's working copy
8633
+ // could never be rewound by undo()/operations.restore() regardless
8634
+ // of which op you targeted — the change kept its post-edit content
8635
+ // forever. Mirrors the "snapshot before, record after" pattern every
8636
+ // other mutator in this file uses (see describe(), moveChange()).
8637
+ const fileSnapshotBefore = await snapshotFilesystem();
8638
+
8556
8639
  const fullPath = `${dir}/${args.path}`;
8557
8640
 
8558
8641
  // Ensure directory exists
@@ -8568,7 +8651,6 @@ async function createJJ(options) {
8568
8651
  await fs.promises.writeFile(fullPath, data, 'utf8');
8569
8652
 
8570
8653
  // Track the file in working copy
8571
- await workingCopy.load();
8572
8654
  const stats = await fs.promises.stat(fullPath);
8573
8655
  await workingCopy.trackFile(args.path, {
8574
8656
  mtime: stats.mtime,
@@ -8576,6 +8658,20 @@ async function createJJ(options) {
8576
8658
  mode: stats.mode,
8577
8659
  });
8578
8660
 
8661
+ await oplog.recordOperation({
8662
+ timestamp: new Date().toISOString(),
8663
+ user: await getUserOplogInfo(),
8664
+ description: `write ${args.path}`,
8665
+ parents: [],
8666
+ view: {
8667
+ bookmarks: {},
8668
+ remoteBookmarks: {},
8669
+ heads: await computeCurrentHeads(),
8670
+ workingCopy: workingCopy.getCurrentChangeId(),
8671
+ fileSnapshot: fileSnapshotBefore, // Store filesystem state from before this write
8672
+ },
8673
+ });
8674
+
8579
8675
  // Return useful information about the written file
8580
8676
  return {
8581
8677
  path: args.path,
@@ -9004,6 +9100,15 @@ async function createJJ(options) {
9004
9100
  async moveChange(args) {
9005
9101
  await graph.load();
9006
9102
 
9103
+ // Capture disk state right before this rebase mutates anything, so
9104
+ // undo()/redo()/operations.restore() can rewind past it — see issue
9105
+ // #43(1): rebase()/moveChange() previously recorded no "files
9106
+ // before" snapshot at all, so restoring to a point right before a
9107
+ // rebase ran had no on-disk state to fall back to and would instead
9108
+ // pick up whatever a LATER operation happened to record (the wrong
9109
+ // point in time).
9110
+ const fileSnapshotBefore = await snapshotFilesystem();
9111
+
9007
9112
  // Support multiple parameter names for flexibility
9008
9113
  const changeId = args.changeId || args.from || args.source;
9009
9114
  const newParent = args.newParent || args.to || args.destination;
@@ -9116,6 +9221,32 @@ async function createJJ(options) {
9116
9221
  await conflicts.addConflict(conflict);
9117
9222
  }
9118
9223
 
9224
+ // Materialize real conflict-marker text into the moved change's file
9225
+ // content — issue #43(3): rebase()/moveChange() used to record the
9226
+ // conflict correctly as DATA (via conflicts.addConflict() above) but
9227
+ // left the file's content exactly as it was pre-rebase ("ours"),
9228
+ // never writing marker text into change.fileSnapshot or, when this
9229
+ // change is the checked-out working copy, onto disk. Reuses
9230
+ // conflicts.markers()'s own marker-formatting logic (the same format
9231
+ // jj.conflicts.markers() already returns) instead of re-deriving
9232
+ // marker text a second way.
9233
+ if (detectedConflicts.length > 0) {
9234
+ change.fileSnapshot = { ...(change.fileSnapshot || {}) };
9235
+ const isCheckedOut = changeId === workingCopy.getCurrentChangeId();
9236
+ for (const conflict of detectedConflicts) {
9237
+ const markerText = await jj.conflicts.markers({ conflictId: conflict.conflictId });
9238
+ change.fileSnapshot[conflict.path] = markerText;
9239
+ if (isCheckedOut) {
9240
+ const fullPath = join(dir, conflict.path);
9241
+ const pathParts = conflict.path.split('/');
9242
+ if (pathParts.length > 1) {
9243
+ await mkdirp(fs, join(dir, pathParts.slice(0, -1).join('/')));
9244
+ }
9245
+ await fs.promises.writeFile(fullPath, markerText, 'utf8');
9246
+ }
9247
+ }
9248
+ }
9249
+
9119
9250
  // Update parent
9120
9251
  change.parents = [newParent];
9121
9252
  await graph.updateChange(change);
@@ -9131,8 +9262,9 @@ async function createJJ(options) {
9131
9262
  view: {
9132
9263
  bookmarks: {},
9133
9264
  remoteBookmarks: {},
9134
- heads: [],
9265
+ heads: await computeCurrentHeads(),
9135
9266
  workingCopy: workingCopy.getCurrentChangeId(),
9267
+ fileSnapshot: fileSnapshotBefore,
9136
9268
  },
9137
9269
  });
9138
9270
 
@@ -9587,6 +9719,16 @@ async function createJJ(options) {
9587
9719
  description: args.message || '(no description)',
9588
9720
  timestamp: new Date().toISOString(),
9589
9721
  fileSnapshot: initialSnapshot, // Initialize with parent's snapshot
9722
+ // Issue #43(2): marks this as an internal, synthetic artifact
9723
+ // rather than something the user actually created, so the revset
9724
+ // engine's orphan-visibility filter (_computeHiddenOrphans) can
9725
+ // still hide it after it's un-referenced by a later undo()/
9726
+ // operations.restore(), even if some out-of-band mutation (e.g.
9727
+ // write()+snapshot() before the undo unwinds this far) has since
9728
+ // made it look "non-empty" relative to its parent. A real
9729
+ // description (args.message, or a later describe()) still makes
9730
+ // it visible regardless, via the UNDESCRIBED check.
9731
+ autoCreated: true,
9590
9732
  };
9591
9733
 
9592
9734
  // Dispatch change:creating event (preventable)
@@ -9707,17 +9849,51 @@ async function createJJ(options) {
9707
9849
  await graph.load();
9708
9850
  await workingCopy.load();
9709
9851
  const result = await workingCopy.snapshot();
9852
+
9853
+ // Disk content itself is not mutated by this call (it's a read,
9854
+ // not a write) — the same value is therefore valid both as the
9855
+ // refreshed change.fileSnapshot AND as the recorded operation's
9856
+ // `view.fileSnapshot` "before" value (nothing on disk changes
9857
+ // between the two).
9858
+ const wcId = workingCopy.getCurrentChangeId();
9859
+ const currentDiskSnapshot = await snapshotFilesystem();
9860
+
9710
9861
  // Refresh the working-copy change's content snapshot from disk.
9862
+ /** @type {Record<string, any>} */
9863
+ const changeSnapshot = {};
9711
9864
  try {
9712
- const wcId = workingCopy.getCurrentChangeId();
9713
9865
  const change = await graph.getChange(wcId);
9714
9866
  if (change) {
9715
- change.fileSnapshot = await snapshotFilesystem();
9867
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore()
9868
+ // can reverse this content refresh — see issue #43(1):
9869
+ // snapshot() previously mutated `change.fileSnapshot` in place
9870
+ // with no "before" capture and recorded no operation at all, so
9871
+ // computeGraphReversal() had nothing to find for this changeId
9872
+ // if a restore()/undo() landed between an out-of-band edit and
9873
+ // the next real command.
9874
+ changeSnapshot[wcId] = structuredClone(change);
9875
+ change.fileSnapshot = currentDiskSnapshot;
9716
9876
  await graph.updateChange(change);
9717
9877
  }
9718
9878
  } catch {
9719
9879
  // Best-effort content refresh.
9720
9880
  }
9881
+
9882
+ await oplog.recordOperation({
9883
+ timestamp: new Date().toISOString(),
9884
+ user: await getUserOplogInfo(),
9885
+ description: 'snapshot working copy',
9886
+ parents: [],
9887
+ changeSnapshot,
9888
+ view: {
9889
+ bookmarks: {},
9890
+ remoteBookmarks: {},
9891
+ heads: await computeCurrentHeads(),
9892
+ workingCopy: wcId,
9893
+ fileSnapshot: currentDiskSnapshot,
9894
+ },
9895
+ });
9896
+
9721
9897
  return result;
9722
9898
  },
9723
9899
 
@@ -10181,8 +10357,20 @@ async function createJJ(options) {
10181
10357
  const current = await graph.getChange(changeId);
10182
10358
  if (current) redoChangeSnapshot[changeId] = current;
10183
10359
  }
10360
+ // Capture disk state right before this undo() runs. Used both as
10361
+ // `redoFileSnapshot` (for a later redo() — only meaningful when this
10362
+ // reversal actually touches files) AND, unconditionally, as THIS
10363
+ // undo operation's own `view.fileSnapshot` "before" value below —
10364
+ // see issue #43(1): undo()/redo()/operations.restore() used to
10365
+ // record `view: landingView` verbatim, i.e. reusing the LANDING
10366
+ // operation's own recorded fileSnapshot (disk state before THAT op
10367
+ // ran) instead of disk state before THIS undo ran. A later
10368
+ // restore()/undo() scanning forward past this one would then pick
10369
+ // up that stale, wrong snapshot instead of what was actually on disk
10370
+ // right before this undo executed.
10371
+ const diskSnapshotBeforeThisOp = await snapshotFilesystem();
10184
10372
  const redoFileSnapshot =
10185
- reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
10373
+ reversal.fileSnapshot !== undefined ? diskSnapshotBeforeThisOp : undefined;
10186
10374
  let redoConflictsSnapshot;
10187
10375
  if (reversal.conflictsSnapshot !== undefined) {
10188
10376
  await conflicts.load();
@@ -10223,7 +10411,7 @@ async function createJJ(options) {
10223
10411
  redoFileSnapshot,
10224
10412
  redoConflictsSnapshot,
10225
10413
  parents: [],
10226
- view: landingView,
10414
+ view: { ...landingView, fileSnapshot: diskSnapshotBeforeThisOp },
10227
10415
  });
10228
10416
 
10229
10417
  const restoredFileCount =
@@ -10317,6 +10505,15 @@ async function createJJ(options) {
10317
10505
  await baseGraph.updateChange(snapshot);
10318
10506
  }
10319
10507
  }
10508
+
10509
+ // Capture disk state right before THIS redo() mutates it — see issue
10510
+ // #43(1)/the matching fix in undo(): `view: redoneView` below reuses
10511
+ // the ORIGINAL (re-applied) operation's own recorded pointers, but
10512
+ // its `fileSnapshot` sub-field must reflect disk state before THIS
10513
+ // redo ran, not before that original operation ran, or a later
10514
+ // restore()/undo() scanning forward past this redo would pick up a
10515
+ // stale, wrong snapshot.
10516
+ const diskSnapshotBeforeThisOp = await snapshotFilesystem();
10320
10517
  if (undoOp.redoFileSnapshot !== undefined) {
10321
10518
  await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
10322
10519
  }
@@ -10337,7 +10534,7 @@ async function createJJ(options) {
10337
10534
  redoOf: undoOp.id,
10338
10535
  changeSnapshot: undoChangeSnapshot,
10339
10536
  parents: [],
10340
- view: redoneView,
10537
+ view: { ...redoneView, fileSnapshot: diskSnapshotBeforeThisOp },
10341
10538
  });
10342
10539
 
10343
10540
  const restoredFileCount =
@@ -10643,6 +10840,17 @@ async function createJJ(options) {
10643
10840
  if (current) changeSnapshot[changeId] = current;
10644
10841
  }
10645
10842
 
10843
+ // Capture disk state right before THIS restore() mutates it — see
10844
+ // issue #43(1)/the matching fix in undo()/redo(): recording
10845
+ // `view: targetOp.view` verbatim reused targetOp's OWN recorded
10846
+ // `fileSnapshot` (disk state before targetOp ran), not disk state
10847
+ // before THIS restore ran. A later restore()/undo() scanning
10848
+ // forward past this one would then pick up that stale, wrong
10849
+ // snapshot instead of what was actually on disk right before this
10850
+ // restore executed — this is exactly the "forward restore to
10851
+ // 'edit tip' lost style.css" repro.
10852
+ const diskSnapshotBeforeThisOp = await snapshotFilesystem();
10853
+
10646
10854
  await applyGraphReversal(reversal);
10647
10855
 
10648
10856
  // Record this restoration as a new operation
@@ -10652,7 +10860,7 @@ async function createJJ(options) {
10652
10860
  description: `restore to operation ${args.operation}`,
10653
10861
  parents: [],
10654
10862
  changeSnapshot,
10655
- view: targetOp.view,
10863
+ view: { ...targetOp.view, fileSnapshot: diskSnapshotBeforeThisOp },
10656
10864
  });
10657
10865
 
10658
10866
  return {
@@ -10888,6 +11096,15 @@ async function createJJ(options) {
10888
11096
  await workingCopy.load();
10889
11097
  await userConfig.load();
10890
11098
 
11099
+ // Capture disk state right before this squash mutates anything, so
11100
+ // undo()/redo()/operations.restore() can rewind past it — see issue
11101
+ // #43(1): squash() previously recorded no "files before" snapshot at
11102
+ // all, so restoring to a point right before a squash ran had no
11103
+ // recorded on-disk state and would fall through to whatever a LATER
11104
+ // operation happened to record instead (the repro: a forward
11105
+ // restore to "edit tip" lost `style.css`).
11106
+ const fileSnapshotBefore = await snapshotFilesystem();
11107
+
10891
11108
  // Support 'into' as alias for 'dest' (matches JJ CLI)
10892
11109
  const dest = args.into || args.dest;
10893
11110
 
@@ -11019,6 +11236,18 @@ async function createJJ(options) {
11019
11236
  // every on-disk file as untracked/new until something re-wrote
11020
11237
  // it and triggered a fresh auto-snapshot.
11021
11238
  fileSnapshot: { ...destChange.fileSnapshot },
11239
+ // Marks this as an internal, synthetic artifact rather than
11240
+ // something the user actually created — issue #43(2): if this
11241
+ // squash is later undone, this change becomes unreferenced, and
11242
+ // by then `destChange` may have been reverted to pre-squash
11243
+ // content, making this change look "non-empty" relative to its
11244
+ // (now-reverted) parent even though it never held any real user
11245
+ // content of its own. The revset engine's orphan-visibility
11246
+ // filter (_computeHiddenOrphans) treats `autoCreated` as
11247
+ // sufficient on its own (given it's also unreferenced and
11248
+ // undescribed) rather than re-diffing against a parent that may
11249
+ // no longer resemble what this change was actually created from.
11250
+ autoCreated: true,
11022
11251
  };
11023
11252
 
11024
11253
  await graph.addChange(newChange); // Middleware will sync to Git
@@ -11041,8 +11270,18 @@ async function createJJ(options) {
11041
11270
  view: {
11042
11271
  bookmarks: {},
11043
11272
  remoteBookmarks: {},
11044
- heads: [destChangeId],
11273
+ // issue #43(1): this used to hard-code `[destChangeId]` — wrong
11274
+ // whenever squash() created a fresh working-copy change on top
11275
+ // of dest (the real new tip was `newWorkingCopyId`, not
11276
+ // `destChangeId` itself), and incomplete whenever a sibling head
11277
+ // elsewhere in the graph existed and wasn't touched by this
11278
+ // squash at all ("two sibling changes report only one head
11279
+ // between them"). computeCurrentHeads() reflects the WHOLE
11280
+ // repo's actual head set after this squash, not just the
11281
+ // changeId(s) this call happened to touch.
11282
+ heads: await computeCurrentHeads(),
11045
11283
  workingCopy: newWorkingCopyId,
11284
+ fileSnapshot: fileSnapshotBefore,
11046
11285
  },
11047
11286
  });
11048
11287
 
@@ -11114,6 +11353,12 @@ async function createJJ(options) {
11114
11353
  // abandoning any OTHER change must leave @ exactly where it was.
11115
11354
  const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
11116
11355
 
11356
+ // Capture disk state right before this abandon mutates anything, so
11357
+ // undo()/redo()/operations.restore() can rewind past it — see issue
11358
+ // #43(1): abandon() previously recorded no "files before" snapshot
11359
+ // at all.
11360
+ const fileSnapshotBefore = await snapshotFilesystem();
11361
+
11117
11362
  // Dispatch change:abandoning event (preventable)
11118
11363
  await dispatchEventAsync(jj, 'change:abandoning', {
11119
11364
  operation: 'abandon',
@@ -11148,8 +11393,16 @@ async function createJJ(options) {
11148
11393
  view: {
11149
11394
  bookmarks: {},
11150
11395
  remoteBookmarks: {},
11151
- heads: [],
11396
+ // issue #43(1): this used to hard-code `[]`, dropping every live
11397
+ // head from the recorded operation (including any untouched
11398
+ // sibling branch — "two sibling changes report only one head
11399
+ // between them"). computeCurrentHeads() reflects the actual
11400
+ // repo-wide head set right after this abandon (and its
11401
+ // reparenting) ran, before the compensating jj.new() below (if
11402
+ // any) records its own separate operation.
11403
+ heads: await computeCurrentHeads(),
11152
11404
  workingCopy: workingCopy.getCurrentChangeId(),
11405
+ fileSnapshot: fileSnapshotBefore,
11153
11406
  },
11154
11407
  });
11155
11408
 
@@ -13411,6 +13664,32 @@ async function createJJ(options) {
13411
13664
  throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
13412
13665
  }
13413
13666
 
13667
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore()
13668
+ // can reverse this resolution — issue #43(4): conflicts.resolve()
13669
+ // previously recorded no operation at all, so resolving a conflict
13670
+ // could never be undone: no changeSnapshot of the working-copy
13671
+ // change's content, no conflictsSnapshot of the pre-resolve
13672
+ // conflict state, and no fileSnapshot of the on-disk content
13673
+ // before the resolved content was written.
13674
+ await graph.load();
13675
+ await workingCopy.load();
13676
+ // structuredClone matters here (unlike the read-only conflictsSnapshot
13677
+ // capture in moveChange()/merge()): resolveConflict() below mutates
13678
+ // the SAME conflict object in place (`conflict.resolved = true`),
13679
+ // and Object.fromEntries() on the live Map would otherwise leave
13680
+ // this "before" snapshot holding a reference to that very object —
13681
+ // corrupting it into already showing `resolved: true` by the time
13682
+ // it's recorded.
13683
+ const conflictsSnapshotBefore = structuredClone({
13684
+ conflicts: Object.fromEntries(conflicts.conflicts),
13685
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
13686
+ });
13687
+ const fileSnapshotBefore = await snapshotFilesystem();
13688
+ const wcId = workingCopy.getCurrentChangeId();
13689
+ const wcChange = await graph.getChange(wcId);
13690
+ /** @type {Record<string, any>} */
13691
+ const changeSnapshot = wcChange ? { [wcId]: structuredClone(wcChange) } : {};
13692
+
13414
13693
  let resolvedContent;
13415
13694
 
13416
13695
  // v0.5: Support different resolution methods
@@ -13454,6 +13733,36 @@ async function createJJ(options) {
13454
13733
  await conflicts.resolveConflict(args.conflictId, 'manual');
13455
13734
  await conflicts.save();
13456
13735
 
13736
+ // Refresh the working-copy change's own fileSnapshot to match the
13737
+ // resolved on-disk content (conflicted files always live under
13738
+ // the checked-out working copy — see the writeFile above), so
13739
+ // undo()/restore() has an accurate "after" state to compare
13740
+ // against, and log()/read() see the resolution immediately rather
13741
+ // than waiting for the next auto-snapshot.
13742
+ if (wcChange) {
13743
+ wcChange.fileSnapshot = {
13744
+ ...(wcChange.fileSnapshot || {}),
13745
+ [conflict.path]: resolvedContent,
13746
+ };
13747
+ await graph.updateChange(wcChange);
13748
+ }
13749
+
13750
+ await oplog.recordOperation({
13751
+ timestamp: new Date().toISOString(),
13752
+ user: await getUserOplogInfo(),
13753
+ description: `resolve conflict ${args.conflictId} (${conflict.path})`,
13754
+ parents: [],
13755
+ changeSnapshot,
13756
+ conflictsSnapshot: conflictsSnapshotBefore,
13757
+ view: {
13758
+ bookmarks: {},
13759
+ remoteBookmarks: {},
13760
+ heads: await computeCurrentHeads(),
13761
+ workingCopy: wcId,
13762
+ fileSnapshot: fileSnapshotBefore,
13763
+ },
13764
+ });
13765
+
13457
13766
  return { resolved: true };
13458
13767
  },
13459
13768