isomorphic-jj 1.11.0 → 1.12.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,99 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.12.0 — 2026-09-27 — op-recording gaps left by #43/#45's fixes (#48)
4
+
5
+ Follow-up to #43/#45 (below). Walking every op backward then forward now
6
+ lands correctly in 49 of 60 restores (up from before); the 11 misses were
7
+ five distinct gaps, all in the operation-recording machinery those fixes
8
+ introduced or depend on, plus one pre-existing bug in `operations.list()`
9
+ discovered while writing regression tests for the others.
10
+
11
+ ### Fixed
12
+
13
+ - **The descendant rebase performed by `edit()`/`snapshot()`/
14
+ `autoSnapshotWorkingCopy()` was never itself recorded in the operation
15
+ (R1).** `_rebuildDescendants()` mutates every rebased descendant via
16
+ `graph.updateChange()`, but had no way to hand its callers each
17
+ descendant's pre-rebase state — so `undo()`/`operations.restore()` had
18
+ nothing to reverse the rebase with, even though the ANCESTOR's own
19
+ content change was (already) correctly captured. Added an optional 4th
20
+ parameter, `outChangeSnapshot`, populated with `{[descendantId]:
21
+ fullRecordBeforeRebuild}` for every descendant actually mutated; all
22
+ three call sites now pass their own `changeSnapshot` object through so
23
+ it lands in the same operation record.
24
+ - **Restoring to a `conflicts.resolve()` (or `merge()`/`converge()`/
25
+ `moveChange()`) op could restore the resolved file but leave the
26
+ change's conflict state unresolved (R2).** `computeGraphReversal()`
27
+ recovers "conflict state right after `landingOp`" by scanning for a
28
+ LATER op's own "before" snapshot — which only exists if some later op
29
+ also touches conflicts. Conflict resolution is usually a one-off event
30
+ with nothing later re-touching the `ConflictModel` at all, so landing
31
+ exactly on it found no candidate and silently left whatever conflict
32
+ state already happened to be live untouched (file content usually
33
+ recovered correctly anyway, by coincidence — some unrelated later op
34
+ touching the same file's `changeSnapshot`). `conflicts.resolve()`,
35
+ `merge()`, `converge()` (unresolved case), and `moveChange()` now also
36
+ record `conflictsSnapshotAfter` — the result of the mutation THEY
37
+ performed — and `computeGraphReversal()` falls back to it when nothing
38
+ later reveals the answer.
39
+ - **A file changed directly on disk and picked up by
40
+ `autoSnapshotWorkingCopy()` (not the explicit `snapshot()` API) had no
41
+ "before" copy recorded anywhere (R3) — described as "silent
42
+ re-snapshot" when it also rebases descendants.** `autoSnapshotWorkingCopy()`
43
+ — the implicit reconciliation `status()`/`read()`/`describe()`/etc.
44
+ trigger on every call — mutated the graph (and rebased descendants) with
45
+ no operation recorded at all. A later `undo()`/`operations.restore()`
46
+ had nothing to reverse it with, and since the same function runs again
47
+ on every subsequent call with no drift left to detect, a real,
48
+ one-time content change looked exactly like it had simply never
49
+ happened. Now records a real operation (mirroring the explicit
50
+ `snapshot()` API, `description: 'auto-snapshot working copy'`) whenever
51
+ it actually refreshes content — gated on the same `added`/`modified`/
52
+ `deleted` check it already uses, so a no-op call still records nothing.
53
+ - **After `edit()` moves `@` to an older change, `log()` could hide a real,
54
+ undescribed tip with real work in it, and `edit()` recorded only its own
55
+ target in `view.heads`.** `_computeHiddenOrphans`'s `autoCreated ||`
56
+ branch (needed for two undo-artifact cases — see its doc comment) has no
57
+ way to distinguish "a synthetic scaffolding change that's still
58
+ discardable" from "a real, live tip the user simply isn't checked out on
59
+ right now" once the FLAG is set once at creation and never cleared.
60
+ `edit()` recording only its own target in `view.heads` (correct back
61
+ when `edit()` was a pure checkout, wrong now that it also commits the
62
+ change being left) meant this package's own "explicit, maintained set of
63
+ live heads" proxy never saw the other, real tip at all. Fixed two ways:
64
+ `edit()`'s recorded `view.heads` now uses `computeCurrentHeads()` (every
65
+ non-abandoned change with no non-abandoned children) instead of
66
+ `[args.changeId]`; and `RevsetEngine` now takes the operation log as a
67
+ constructor param and `_computeHiddenOrphans` adds the latest op's
68
+ `view.heads` to its `trackedHeads` signal, alongside the working-copy
69
+ pointer and every bookmark/tag target — the "explicit, maintained set of
70
+ heads" this package's own doc comment already said real jj's View keeps,
71
+ now actually wired through.
72
+ - **`operations.list()` reversed its own internal array in place instead
73
+ of a copy, silently scrambling operation order on the SECOND call within
74
+ a session.** `oplog.list()` returns its live internal array by
75
+ reference (by design — `computeGraphReversal()` and others need the
76
+ real, oldest-first list); `operations.list()`'s own `.reverse()` (to
77
+ present newest-first) mutated that same array as a side effect. Calling
78
+ it twice — a completely reasonable thing to do (list history, do
79
+ something, list history again) — un-reversed it back to oldest-first,
80
+ and anything recorded via `oplog.recordOperation()` in between landed
81
+ appended to what was, at that moment, the WRONG end. Found while writing
82
+ a regression test for the fixes above (two `operations.list()` calls
83
+ around one `autoSnapshotWorkingCopy()`), not from the issue's own repro
84
+ — but real and, in principle, capable of corrupting `undo()`/`redo()`/
85
+ `operations.restore()` for any caller that also calls
86
+ `jj.operations.list()` earlier in the same session. Fixed by copying
87
+ before reversing.
88
+
89
+ See `tests/integration/issue-43-restore-reliability.test.js`'s new
90
+ "issue #48" suites (8 new tests across all five gaps above) and
91
+ `tests/unit/core/revset-branches-coverage.test.js`'s new oplog-signal
92
+ suite (2 tests) for the hidden-tips fix specifically. All verified with
93
+ a negative control: reverting just the source changes fails exactly
94
+ these new tests while every pre-existing test still passes, confirming
95
+ they exercise real behavior rather than being tautological.
96
+
3
97
  ## 1.11.0 — 2026-09-26 — edit() of an older change rebases its descendants (#45)
4
98
 
5
99
  Part 5 of #43 (parts 1-4 shipped in 1.10.0 / #44), previously punted because
package/dist/index.cjs CHANGED
@@ -2532,13 +2532,23 @@ class RevsetEngine {
2532
2532
  * @param {any} [userConfig] - User configuration instance (optional)
2533
2533
  * @param {any} [bookmarkStore] - Bookmark store instance (optional, v0.4)
2534
2534
  * @param {any} [tagStore] - Tag store instance (optional, v1.5)
2535
+ * @param {any} [oplog] - Operation log instance (optional, issue #48) —
2536
+ * see `_computeHiddenOrphans`'s doc comment for why this is needed.
2535
2537
  */
2536
- constructor(graph, workingCopy, userConfig = null, bookmarkStore = null, tagStore = null) {
2538
+ constructor(
2539
+ graph,
2540
+ workingCopy,
2541
+ userConfig = null,
2542
+ bookmarkStore = null,
2543
+ tagStore = null,
2544
+ oplog = null
2545
+ ) {
2537
2546
  this.graph = graph;
2538
2547
  this.workingCopy = workingCopy;
2539
2548
  this.userConfig = userConfig;
2540
2549
  this.bookmarkStore = bookmarkStore;
2541
2550
  this.tagStore = tagStore;
2551
+ this.oplog = oplog;
2542
2552
  }
2543
2553
 
2544
2554
  /**
@@ -3225,11 +3235,24 @@ class RevsetEngine {
3225
3235
  * excludable. Real jj instead tracks an explicit, maintained set of
3226
3236
  * heads in its View (updated as operations run — abandoning/squashing a
3227
3237
  * change removes it, rebasing children onto a new parent removes the
3228
- * old parent, etc.). This package doesn't thread the operation log's
3229
- * `view.heads` through to the revset engine, but the working-copy
3230
- * pointer plus every bookmark/tag target is the closest available
3231
- * proxy for "what's actually still in play" — anything only reachable
3232
- * from those, not from every dangling graph leaf.
3238
+ * old parent, etc.).
3239
+ *
3240
+ * Issue #48: the working-copy pointer plus every bookmark/tag target,
3241
+ * alone, missed a real case this rule needs to cover: `edit()`-ing @ to
3242
+ * an OLDER change leaves the change you edited AWAY FROM still fully
3243
+ * live (nobody abandoned/squashed/rebased it away) — but it's a
3244
+ * DESCENDANT of the new @ (not an ancestor), so it's absent from
3245
+ * `reachable` no matter what. If that change also happens to be
3246
+ * `autoCreated` (e.g. it came from `new()`) and was never `describe()`d,
3247
+ * the `autoCreated ||` branch below — needed for the two undo-artifact
3248
+ * cases documented above it — incorrectly swept it up too, hiding a real
3249
+ * tip with real work in it. The operation log's `view.heads` (now
3250
+ * correctly threaded through via the `oplog` constructor param) is
3251
+ * exactly the "explicit, maintained set of heads" real jj's View keeps —
3252
+ * every mutating operation records the actual live heads as of right
3253
+ * after it ran (see `computeCurrentHeads()` in repository.js), so the
3254
+ * latest one is the accurate, low-cost substitute for a full View this
3255
+ * package doesn't otherwise maintain.
3233
3256
  *
3234
3257
  * @param {any[]} allChanges - `graph.getAll()`
3235
3258
  * @returns {Promise<Set<string>>} change IDs to exclude
@@ -3253,6 +3276,18 @@ class RevsetEngine {
3253
3276
  if (tag && tag.changeId) trackedHeads.add(tag.changeId);
3254
3277
  }
3255
3278
  }
3279
+ if (this.oplog) {
3280
+ try {
3281
+ const headOp = await this.oplog.getHeadOperation();
3282
+ if (headOp && headOp.view && Array.isArray(headOp.view.heads)) {
3283
+ for (const headId of headOp.view.heads) {
3284
+ if (headId) trackedHeads.add(headId);
3285
+ }
3286
+ }
3287
+ } catch {
3288
+ // oplog not loaded/available — fine, just no op-based roots.
3289
+ }
3290
+ }
3256
3291
 
3257
3292
  const reachable = new Set();
3258
3293
  for (const headId of trackedHeads) {
@@ -7994,6 +8029,13 @@ async function createJJ(options) {
7994
8029
  * - changeSnapshot: { [changeId]: fullRecordBeforeThisOpRan }
7995
8030
  * - view.fileSnapshot: on-disk file state right BEFORE this op ran
7996
8031
  * - conflictsSnapshot: ConflictModel state right BEFORE this op ran
8032
+ * - conflictsSnapshotAfter: ConflictModel state right AFTER this op ran
8033
+ * (issue #48, R2) — conflicts.resolve()/merge()/converge()/
8034
+ * moveChange() all record this alongside the "before" value, purely
8035
+ * as a fallback for landing exactly ON that op with nothing LATER
8036
+ * also touching conflicts (see below) — every other kind of state
8037
+ * here only ever needs a "before" value, because it's recovered from
8038
+ * a LATER op's own before-snapshot, not from itself.
7997
8039
  *
7998
8040
  * Deliberately NOT reverted: changes an operation newly *created* (via
7999
8041
  * graph.addChange() — new(), squash()'s synthetic empty working-copy
@@ -8065,6 +8107,20 @@ async function createJJ(options) {
8065
8107
  }
8066
8108
  }
8067
8109
 
8110
+ // Issue #48 (R2): the loop above recovers "conflicts state right after
8111
+ // landingOp" from a LATER op's own "before" snapshot — but that only
8112
+ // exists if some later op also touched conflicts. When landingOp is
8113
+ // itself the last op to ever touch conflicts (routine for
8114
+ // conflicts.resolve()/merge()/converge()/moveChange() — nothing
8115
+ // naturally re-touches the ConflictModel afterward), the loop finds
8116
+ // nothing and this used to silently leave conflict state exactly as it
8117
+ // already happened to be, which is only correct by coincidence. Fall
8118
+ // back to landingOp's OWN recorded `conflictsSnapshotAfter` (the actual
8119
+ // result of the mutation IT performed), when present.
8120
+ if (conflictsSnapshot === undefined && landingOp.conflictsSnapshotAfter != null) {
8121
+ conflictsSnapshot = landingOp.conflictsSnapshotAfter;
8122
+ }
8123
+
8068
8124
  return { changeSnapshotToApply, fileSnapshot, conflictsSnapshot };
8069
8125
  };
8070
8126
 
@@ -8269,7 +8325,24 @@ async function createJJ(options) {
8269
8325
  const change = await graph.getChange(wcId);
8270
8326
  if (change) {
8271
8327
  const oldFileSnapshot = change.fileSnapshot ? structuredClone(change.fileSnapshot) : {};
8272
- change.fileSnapshot = await snapshotFilesystem();
8328
+ // Issue #48 (R3 / "silent re-snapshot"): this used to mutate the
8329
+ // graph (and rebase descendants) with no operation recorded at
8330
+ // all — the comment above even called it out as reconciling
8331
+ // state "silently". That meant an out-of-band disk edit picked
8332
+ // up here was gone for good the moment a later undo()/redo()/
8333
+ // operations.restore() landed anywhere: there was no op in the
8334
+ // log capturing the transition, so nothing could reverse it, and
8335
+ // — since this is exactly what status()/describe()/read()/etc.
8336
+ // trigger on every call — it fired again on the very next such
8337
+ // call with no drift left to detect, making a real, one-time
8338
+ // content change look like it had simply never happened. Same
8339
+ // treatment as the explicit snapshot() API now gets: capture the
8340
+ // "before" state and record a real operation, so this is exactly
8341
+ // as reversible as calling jj.snapshot() by hand.
8342
+ /** @type {Record<string, any>} */
8343
+ const changeSnapshot = { [wcId]: structuredClone(change) };
8344
+ const newFileSnapshot = await snapshotFilesystem();
8345
+ change.fileSnapshot = newFileSnapshot;
8273
8346
  await graph.updateChange(change);
8274
8347
 
8275
8348
  // Issue #45: if the working-copy change isn't a leaf (e.g. we
@@ -8284,10 +8357,28 @@ async function createJJ(options) {
8284
8357
  // in the same enclosing scope and this function only ever runs
8285
8358
  // after `jj` is fully constructed, so the self-reference is safe
8286
8359
  // (same pattern used elsewhere in this file, e.g. `jj.new(...)`).
8360
+ // Issue #48 (R1): pass `changeSnapshot` through as the 4th arg so
8361
+ // a rebased descendant's pre-rebase state is captured too — see
8362
+ // `_rebuildDescendants`'s doc comment.
8287
8363
  const descendants = await jj._findDescendants(wcId, null);
8288
8364
  if (descendants.length > 0) {
8289
- await jj._rebuildDescendants(wcId, null, oldFileSnapshot);
8365
+ await jj._rebuildDescendants(wcId, null, oldFileSnapshot, changeSnapshot);
8290
8366
  }
8367
+
8368
+ await oplog.recordOperation({
8369
+ timestamp: new Date().toISOString(),
8370
+ user: await getUserOplogInfo(),
8371
+ description: 'auto-snapshot working copy',
8372
+ parents: [],
8373
+ changeSnapshot,
8374
+ view: {
8375
+ bookmarks: {},
8376
+ remoteBookmarks: {},
8377
+ heads: await computeCurrentHeads(),
8378
+ workingCopy: wcId,
8379
+ fileSnapshot: newFileSnapshot,
8380
+ },
8381
+ });
8291
8382
  }
8292
8383
  } catch {
8293
8384
  // Best-effort content refresh; tracking state is already updated.
@@ -8490,7 +8581,7 @@ async function createJJ(options) {
8490
8581
  });
8491
8582
 
8492
8583
  // Create revset engine with the middleware-wrapped graph (v0.4: added bookmarkStore)
8493
- const revset = new RevsetEngine(graph, workingCopy, userConfig, bookmarks, tags);
8584
+ const revset = new RevsetEngine(graph, workingCopy, userConfig, bookmarks, tags, oplog);
8494
8585
 
8495
8586
  /**
8496
8587
  * Helper to resolve conflicts with different strategies (v0.5)
@@ -9261,6 +9352,21 @@ async function createJJ(options) {
9261
9352
  await mkdirp(fs, join(dir, pathParts.slice(0, -1).join('/')));
9262
9353
  }
9263
9354
  await fs.promises.writeFile(fullPath, markerText, 'utf8');
9355
+ // Keep the working-copy tracker in sync with this out-of-band
9356
+ // write (issue #48, discovered via R2's fix) — otherwise the
9357
+ // NEXT autoSnapshotWorkingCopy() call (status()/read()/etc.)
9358
+ // sees a stale tracked mtime for this file, reports a false
9359
+ // "modified", and now (since autoSnapshotWorkingCopy() records
9360
+ // a real operation for that — see its own doc comment) records
9361
+ // a spurious extra op immediately after this one. Every other
9362
+ // direct-to-disk write in this file (write(), syncWorkingCopyFiles)
9363
+ // already does this; this was the one gap.
9364
+ const markerStats = await fs.promises.stat(fullPath);
9365
+ await workingCopy.trackFile(conflict.path, {
9366
+ mtime: markerStats.mtime,
9367
+ size: markerStats.size,
9368
+ mode: markerStats.mode,
9369
+ });
9264
9370
  }
9265
9371
  }
9266
9372
  }
@@ -9269,6 +9375,15 @@ async function createJJ(options) {
9269
9375
  change.parents = [newParent];
9270
9376
  await graph.updateChange(change);
9271
9377
 
9378
+ // Issue #48 (R2): the AFTER state, for landing exactly on THIS op
9379
+ // when nothing later also touches conflicts — see
9380
+ // computeGraphReversal's doc comment and conflicts.resolve()'s
9381
+ // matching `conflictsSnapshotAfter`, which this mirrors.
9382
+ const conflictsSnapshotAfter = {
9383
+ conflicts: Object.fromEntries(conflicts.conflicts),
9384
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
9385
+ };
9386
+
9272
9387
  // Record operation
9273
9388
  await oplog.recordOperation({
9274
9389
  timestamp: new Date().toISOString(),
@@ -9277,6 +9392,7 @@ async function createJJ(options) {
9277
9392
  parents: [],
9278
9393
  changeSnapshot: { [changeId]: changeSnapshotBefore },
9279
9394
  conflictsSnapshot,
9395
+ conflictsSnapshotAfter,
9280
9396
  view: {
9281
9397
  bookmarks: {},
9282
9398
  remoteBookmarks: {},
@@ -9900,9 +10016,18 @@ async function createJJ(options) {
9900
10016
  // matching real jj's automatic rebase-on-edit. edit() itself is a
9901
10017
  // pure checkout and never mutates content, so this is the actual
9902
10018
  // moment content changes and the right place to propagate.
10019
+ // Issue #48 (R1/R3): pass `changeSnapshot` through as the 4th arg
10020
+ // so a rebased descendant's pre-rebase state is captured into the
10021
+ // SAME op this method records below, not lost the moment
10022
+ // `_rebuildDescendants` mutates it.
9903
10023
  const descendants = await this._findDescendants(wcId, null);
9904
10024
  if (descendants.length > 0) {
9905
- await this._rebuildDescendants(wcId, null, changeSnapshot[wcId].fileSnapshot);
10025
+ await this._rebuildDescendants(
10026
+ wcId,
10027
+ null,
10028
+ changeSnapshot[wcId].fileSnapshot,
10029
+ changeSnapshot
10030
+ );
9906
10031
  }
9907
10032
  }
9908
10033
  } catch {
@@ -10265,12 +10390,20 @@ async function createJJ(options) {
10265
10390
  // isn't a leaf (we'd edit()'d into an ancestor), its descendants
10266
10391
  // still have the OLD content and need the same rebase-on-edit
10267
10392
  // treatment applied at the other two commit sites.
10393
+ //
10394
+ // Issue #48 (R1): pass `changeSnapshot` itself as the 4th arg
10395
+ // so every rebased descendant's pre-rebase state lands in the
10396
+ // SAME object this method already records into the op below —
10397
+ // previously only `previousChangeId`'s own before-state was
10398
+ // captured, so undo()/operations.restore() had no way to
10399
+ // reverse the descendant rebase itself.
10268
10400
  const descendants = await this._findDescendants(previousChangeId, null);
10269
10401
  if (descendants.length > 0) {
10270
10402
  await this._rebuildDescendants(
10271
10403
  previousChangeId,
10272
10404
  null,
10273
- changeSnapshot[previousChangeId].fileSnapshot
10405
+ changeSnapshot[previousChangeId].fileSnapshot,
10406
+ changeSnapshot
10274
10407
  );
10275
10408
  }
10276
10409
  }
@@ -10316,7 +10449,17 @@ async function createJJ(options) {
10316
10449
  view: {
10317
10450
  bookmarks: {},
10318
10451
  remoteBookmarks: {},
10319
- heads: [args.changeId],
10452
+ // Issue #48: previously hardcoded to `[args.changeId]` -- correct
10453
+ // back when edit() was a pure checkout (the target WAS the only
10454
+ // thing that could plausibly have changed), but edit() now also
10455
+ // commits the change being left (see above), which can leave a
10456
+ // second, real, still-live tip that isn't the edit target at all
10457
+ // (e.g. `previousChangeId` itself, if it has no descendants).
10458
+ // Recording only the target here silently dropped that other
10459
+ // live head from `view.heads` -- see `_computeHiddenOrphans` in
10460
+ // revset-engine.js, which reads the latest op's `view.heads` as
10461
+ // one of its signals for "still actually in play."
10462
+ heads: await computeCurrentHeads(),
10320
10463
  workingCopy: args.changeId,
10321
10464
  fileSnapshot: fileSnapshotBeforeSwitch,
10322
10465
  },
@@ -10652,7 +10795,20 @@ async function createJJ(options) {
10652
10795
  */
10653
10796
  async list(opts = {}) {
10654
10797
  await oplog.load();
10655
- let ops = await oplog.list();
10798
+ // `oplog.list()` returns its OWN internal array by reference, not a
10799
+ // copy — discovered while fixing issue #48: calling this method
10800
+ // TWICE in the same session (e.g. once to capture a "before" op,
10801
+ // then again later to check what got recorded) used to reverse
10802
+ // that internal array a second time, silently un-reversing it and
10803
+ // scrambling the order any operation recorded in between landed
10804
+ // at. `.slice()` (with no args, i.e. a full copy) unconditionally,
10805
+ // before ever limiting or reversing, so `.reverse()` below always
10806
+ // mutates a throwaway copy — this method itself was the only
10807
+ // caller that needed to be non-destructive; `oplog.list()`'s own
10808
+ // contract (return the live list, oldest-first, for internal
10809
+ // reversal-computation callers like computeGraphReversal) is
10810
+ // unchanged.
10811
+ let ops = (await oplog.list()).slice();
10656
10812
 
10657
10813
  if (opts.limit && opts.limit > 0) {
10658
10814
  ops = ops.slice(-opts.limit);
@@ -11623,12 +11779,20 @@ async function createJJ(options) {
11623
11779
  for (const conflict of detectedConflicts) {
11624
11780
  await conflicts.addConflict(conflict);
11625
11781
  }
11782
+ // Issue #48 (R2): the AFTER state — see conflicts.resolve()'s
11783
+ // matching `conflictsSnapshotAfter` and computeGraphReversal's doc
11784
+ // comment for why landing exactly on this op needs it.
11785
+ const conflictsSnapshotAfter = {
11786
+ conflicts: Object.fromEntries(conflicts.conflicts),
11787
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
11788
+ };
11626
11789
  await oplog.recordOperation({
11627
11790
  timestamp: new Date().toISOString(),
11628
11791
  user: await getUserOplogInfo(),
11629
11792
  description: `converge ${changeId.slice(0, 8)} (unresolved)`,
11630
11793
  parents: [],
11631
11794
  conflictsSnapshot,
11795
+ conflictsSnapshotAfter,
11632
11796
  view: {
11633
11797
  bookmarks: {},
11634
11798
  remoteBookmarks: {},
@@ -12530,8 +12694,24 @@ async function createJJ(options) {
12530
12694
  * descendants are unaffected (their own immediate parent hasn't been
12531
12695
  * mutated yet when this function reads it, so the live read is
12532
12696
  * correct for them). Optional for backward compatibility.
12697
+ * @param {Record<string, any>} [outChangeSnapshot] - If provided,
12698
+ * populated with `{[descendantId]: fullRecordBeforeRebuild}` for every
12699
+ * descendant this call actually mutates, BEFORE the mutation —
12700
+ * matching the `oplog.recordOperation({changeSnapshot})` convention
12701
+ * used everywhere else in this file (see computeGraphReversal's doc
12702
+ * comment). Every caller mutates the graph via `graph.updateChange()`
12703
+ * here without this, so `undo()`/`redo()`/`operations.restore()` had
12704
+ * nothing to reverse a rebased descendant's content with — the
12705
+ * descendant rebase itself was simply never recorded in the operation
12706
+ * (issue #48, R1). Callers should merge this into their own
12707
+ * `changeSnapshot` before calling `oplog.recordOperation()`.
12533
12708
  */
12534
- async _rebuildDescendants(ancestorId, stopAtChangeId, originalAncestorSnapshot) {
12709
+ async _rebuildDescendants(
12710
+ ancestorId,
12711
+ stopAtChangeId,
12712
+ originalAncestorSnapshot,
12713
+ outChangeSnapshot
12714
+ ) {
12535
12715
  // Find all descendants of ancestorId
12536
12716
  const allChanges = graph.getAll();
12537
12717
 
@@ -12602,7 +12782,14 @@ async function createJJ(options) {
12602
12782
  // Get parent's ORIGINAL (before absorb) state
12603
12783
  const originalState = originalParentStates.get(descendantId);
12604
12784
  if (!originalState || originalState.parentId !== descendant.parents[0]) {
12605
- // Parent relationship changed or no original state, just use current parent
12785
+ // Parent relationship changed or no original state, just use current
12786
+ // parent. Not exercised by any current caller (each always builds
12787
+ // `originalParentStates` from a fresh, internally-consistent scan
12788
+ // immediately before this loop runs, with no mutation in between --
12789
+ // see the loop above) -- left as defensive dead code, matching its
12790
+ // pre-existing, already-untested state before issue #48 rather than
12791
+ // adding an `outChangeSnapshot` capture branch here that could never
12792
+ // be exercised either.
12606
12793
  descendant.fileSnapshot = { ...parent.fileSnapshot };
12607
12794
  await graph.updateChange(descendant);
12608
12795
  continue;
@@ -12700,6 +12887,9 @@ async function createJJ(options) {
12700
12887
  newSnapshot[filePath] = resultLines.join('\n');
12701
12888
  }
12702
12889
 
12890
+ if (outChangeSnapshot && !(descendantId in outChangeSnapshot)) {
12891
+ outChangeSnapshot[descendantId] = structuredClone(descendant);
12892
+ }
12703
12893
  descendant.fileSnapshot = newSnapshot;
12704
12894
  await graph.updateChange(descendant);
12705
12895
 
@@ -13723,6 +13913,14 @@ async function createJJ(options) {
13723
13913
  await conflicts.addConflict(conflict);
13724
13914
  }
13725
13915
 
13916
+ // Issue #48 (R2): the AFTER state — see conflicts.resolve()'s
13917
+ // matching `conflictsSnapshotAfter` and computeGraphReversal's doc
13918
+ // comment for why landing exactly on this op needs it.
13919
+ const conflictsSnapshotAfter = {
13920
+ conflicts: Object.fromEntries(conflicts.conflicts),
13921
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
13922
+ };
13923
+
13726
13924
  // Record operation with conflicts snapshot
13727
13925
  await oplog.recordOperation({
13728
13926
  timestamp: new Date().toISOString(),
@@ -13736,6 +13934,7 @@ async function createJJ(options) {
13736
13934
  workingCopy: currentChangeId,
13737
13935
  },
13738
13936
  conflictsSnapshot, // Store conflicts state before merge for undo
13937
+ conflictsSnapshotAfter,
13739
13938
  });
13740
13939
 
13741
13940
  return {
@@ -13845,6 +14044,23 @@ async function createJJ(options) {
13845
14044
 
13846
14045
  // Write resolved content to file
13847
14046
  await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
14047
+ // Keep the working-copy tracker in sync with this out-of-band
14048
+ // write (issue #48, discovered via R2's fix) — otherwise the NEXT
14049
+ // autoSnapshotWorkingCopy() call (status()/read()/etc.) sees a
14050
+ // stale tracked mtime for this file, reports a false "modified",
14051
+ // and records a spurious extra "auto-snapshot working copy"
14052
+ // operation immediately after this one (autoSnapshotWorkingCopy()
14053
+ // now records a real op for that — see its own doc comment).
14054
+ // write()/syncWorkingCopyFiles already do this for every OTHER
14055
+ // direct-to-disk write in this file; this was a gap.
14056
+ {
14057
+ const resolvedStats = await fs.promises.stat(join(dir, conflict.path));
14058
+ await workingCopy.trackFile(conflict.path, {
14059
+ mtime: resolvedStats.mtime,
14060
+ size: resolvedStats.size,
14061
+ mode: resolvedStats.mode,
14062
+ });
14063
+ }
13848
14064
 
13849
14065
  // Mark conflict as resolved
13850
14066
  await conflicts.resolveConflict(args.conflictId, 'manual');
@@ -13864,6 +14080,24 @@ async function createJJ(options) {
13864
14080
  await graph.updateChange(wcChange);
13865
14081
  }
13866
14082
 
14083
+ // Issue #48 (R2): `computeGraphReversal` recovers "the state right
14084
+ // after landingOp" for conflicts by scanning for a LATER op's
14085
+ // "before" snapshot — that only exists if some later op also
14086
+ // touched conflicts. Conflict resolution is usually a one-off
14087
+ // event with nothing later re-touching the ConflictModel at all, so
14088
+ // landing operations.restore()/undo()/redo() exactly on THIS op
14089
+ // found no candidate and left whatever conflict state happened to
14090
+ // already be live completely untouched — restoring the resolved
14091
+ // FILE content correctly (that part goes through the ordinary
14092
+ // changeSnapshot path, which usually does have a later toucher)
14093
+ // while leaving the change's conflict marked unresolved. Recording
14094
+ // the AFTER state directly closes that gap without needing a later
14095
+ // op to exist at all.
14096
+ const conflictsSnapshotAfter = {
14097
+ conflicts: Object.fromEntries(conflicts.conflicts),
14098
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
14099
+ };
14100
+
13867
14101
  await oplog.recordOperation({
13868
14102
  timestamp: new Date().toISOString(),
13869
14103
  user: await getUserOplogInfo(),
@@ -13871,6 +14105,7 @@ async function createJJ(options) {
13871
14105
  parents: [],
13872
14106
  changeSnapshot,
13873
14107
  conflictsSnapshot: conflictsSnapshotBefore,
14108
+ conflictsSnapshotAfter,
13874
14109
  view: {
13875
14110
  bookmarks: {},
13876
14111
  remoteBookmarks: {},
@@ -13917,7 +14152,16 @@ async function createJJ(options) {
13917
14152
  const resolvedContent = _resolveWithStrategy(conflict, args.strategy);
13918
14153
 
13919
14154
  // Write resolved content
13920
- await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
14155
+ const resolvedPath = join(dir, conflict.path);
14156
+ await fs.promises.writeFile(resolvedPath, resolvedContent, 'utf8');
14157
+ // Keep the working-copy tracker in sync — see resolve()'s
14158
+ // matching comment (issue #48).
14159
+ const resolvedStats = await fs.promises.stat(resolvedPath);
14160
+ await workingCopy.trackFile(conflict.path, {
14161
+ mtime: resolvedStats.mtime,
14162
+ size: resolvedStats.size,
14163
+ mode: resolvedStats.mode,
14164
+ });
13921
14165
 
13922
14166
  // Mark as resolved
13923
14167
  await conflicts.resolveConflict(conflict.conflictId, args.strategy);