isomorphic-jj 1.11.0 → 1.12.1

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,144 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.12.1 — 2026-09-27 — Conflict type shape + README status refresh
4
+
5
+ A cross-library documentation/typing audit flagged four things to verify
6
+ against current code; two turned out already fixed (`converge()`'s JSDoc
7
+ already documents N-way behavior as of #32/#35's 0.4.0 follow-up), one is
8
+ tracked as a design question instead of guessed at (see the "public,
9
+ untyped internal components" issue on `jj.graph` and its siblings), and
10
+ this release covers the two that were genuinely stale.
11
+
12
+ ### Fixed
13
+
14
+ - **`Conflict`/`ConflictType` in `src/types.d.ts` didn't match the shape
15
+ `ConflictModel` actually constructs.** `sides` was typed as `TreeRef[]`
16
+ (an array of tree hashes); every real `Conflict` object has `sides` as
17
+ an *object* — `{ left, right }`, `{ base, left, right }`, or (for
18
+ `converge()`'s N-way case, `ConflictModel.createNWayConflict()`)
19
+ `{ base, versions }` where `versions` is
20
+ `Array<{ commitId, content }>`. The interface was also missing
21
+ `message`, `timestamp`, and the optional `driverFailed`/`driverError`
22
+ fields that `_createConflict()` actually sets, and `ConflictType` was
23
+ missing the `'path'` and `'driver-conflict'` values `ConflictModel`
24
+ uses. Added a `ConflictSides` type covering both the two-way and N-way
25
+ shapes and corrected `Conflict` to match reality. Type-only change (the
26
+ `.d.ts` file has no runtime code), so nothing about the library's actual
27
+ behavior changes — this only fixes what TypeScript consumers see.
28
+ - **README's "Project Status" section was frozen at the pre-`@johnhenry`
29
+ rename numbering.** It read "Current Version: 0.3.0" (actual:
30
+ 1.12.0), cited "1823 tests" (actual: 1874), and its roadmap stopped at
31
+ 0.3.0 with no mention of 0.4.0's N-way `converge()` (#32/#35), the
32
+ 1.9.0 scoped/unscoped version-lineage merge (#36), or the 1.9.1–1.12.0
33
+ operation-log reliability pass (#37–#39, #41, #43, #45, #48). Also
34
+ corrected the provenance note's "reached v1.7.0 before the rename" to
35
+ the CHANGELOG's own recorded **v1.8.0** ceiling (see the 1.9.0 entry
36
+ below), and clarified that "Not yet 1.0" refers to API stability, not
37
+ the version number (which is already past 1.0 purely because merging
38
+ the two lineages forced it above the unscoped side's prior ceiling).
39
+ The Node 26 / `engines.node` note was checked too and was already
40
+ accurate — no change needed there.
41
+
42
+ ### Testing
43
+
44
+ 1874 tests passing, 0 skipped; lint (0 errors), format:check, typecheck,
45
+ and all 12 `examples/*.mjs` green; coverage 97.39% statements / 90.11%
46
+ branches / 99.08% functions / 97.63% lines (branch gate is 90%).
47
+
48
+ ## 1.12.0 — 2026-09-27 — op-recording gaps left by #43/#45's fixes (#48)
49
+
50
+ Follow-up to #43/#45 (below). Walking every op backward then forward now
51
+ lands correctly in 49 of 60 restores (up from before); the 11 misses were
52
+ five distinct gaps, all in the operation-recording machinery those fixes
53
+ introduced or depend on, plus one pre-existing bug in `operations.list()`
54
+ discovered while writing regression tests for the others.
55
+
56
+ ### Fixed
57
+
58
+ - **The descendant rebase performed by `edit()`/`snapshot()`/
59
+ `autoSnapshotWorkingCopy()` was never itself recorded in the operation
60
+ (R1).** `_rebuildDescendants()` mutates every rebased descendant via
61
+ `graph.updateChange()`, but had no way to hand its callers each
62
+ descendant's pre-rebase state — so `undo()`/`operations.restore()` had
63
+ nothing to reverse the rebase with, even though the ANCESTOR's own
64
+ content change was (already) correctly captured. Added an optional 4th
65
+ parameter, `outChangeSnapshot`, populated with `{[descendantId]:
66
+ fullRecordBeforeRebuild}` for every descendant actually mutated; all
67
+ three call sites now pass their own `changeSnapshot` object through so
68
+ it lands in the same operation record.
69
+ - **Restoring to a `conflicts.resolve()` (or `merge()`/`converge()`/
70
+ `moveChange()`) op could restore the resolved file but leave the
71
+ change's conflict state unresolved (R2).** `computeGraphReversal()`
72
+ recovers "conflict state right after `landingOp`" by scanning for a
73
+ LATER op's own "before" snapshot — which only exists if some later op
74
+ also touches conflicts. Conflict resolution is usually a one-off event
75
+ with nothing later re-touching the `ConflictModel` at all, so landing
76
+ exactly on it found no candidate and silently left whatever conflict
77
+ state already happened to be live untouched (file content usually
78
+ recovered correctly anyway, by coincidence — some unrelated later op
79
+ touching the same file's `changeSnapshot`). `conflicts.resolve()`,
80
+ `merge()`, `converge()` (unresolved case), and `moveChange()` now also
81
+ record `conflictsSnapshotAfter` — the result of the mutation THEY
82
+ performed — and `computeGraphReversal()` falls back to it when nothing
83
+ later reveals the answer.
84
+ - **A file changed directly on disk and picked up by
85
+ `autoSnapshotWorkingCopy()` (not the explicit `snapshot()` API) had no
86
+ "before" copy recorded anywhere (R3) — described as "silent
87
+ re-snapshot" when it also rebases descendants.** `autoSnapshotWorkingCopy()`
88
+ — the implicit reconciliation `status()`/`read()`/`describe()`/etc.
89
+ trigger on every call — mutated the graph (and rebased descendants) with
90
+ no operation recorded at all. A later `undo()`/`operations.restore()`
91
+ had nothing to reverse it with, and since the same function runs again
92
+ on every subsequent call with no drift left to detect, a real,
93
+ one-time content change looked exactly like it had simply never
94
+ happened. Now records a real operation (mirroring the explicit
95
+ `snapshot()` API, `description: 'auto-snapshot working copy'`) whenever
96
+ it actually refreshes content — gated on the same `added`/`modified`/
97
+ `deleted` check it already uses, so a no-op call still records nothing.
98
+ - **After `edit()` moves `@` to an older change, `log()` could hide a real,
99
+ undescribed tip with real work in it, and `edit()` recorded only its own
100
+ target in `view.heads`.** `_computeHiddenOrphans`'s `autoCreated ||`
101
+ branch (needed for two undo-artifact cases — see its doc comment) has no
102
+ way to distinguish "a synthetic scaffolding change that's still
103
+ discardable" from "a real, live tip the user simply isn't checked out on
104
+ right now" once the FLAG is set once at creation and never cleared.
105
+ `edit()` recording only its own target in `view.heads` (correct back
106
+ when `edit()` was a pure checkout, wrong now that it also commits the
107
+ change being left) meant this package's own "explicit, maintained set of
108
+ live heads" proxy never saw the other, real tip at all. Fixed two ways:
109
+ `edit()`'s recorded `view.heads` now uses `computeCurrentHeads()` (every
110
+ non-abandoned change with no non-abandoned children) instead of
111
+ `[args.changeId]`; and `RevsetEngine` now takes the operation log as a
112
+ constructor param and `_computeHiddenOrphans` adds the latest op's
113
+ `view.heads` to its `trackedHeads` signal, alongside the working-copy
114
+ pointer and every bookmark/tag target — the "explicit, maintained set of
115
+ heads" this package's own doc comment already said real jj's View keeps,
116
+ now actually wired through.
117
+ - **`operations.list()` reversed its own internal array in place instead
118
+ of a copy, silently scrambling operation order on the SECOND call within
119
+ a session.** `oplog.list()` returns its live internal array by
120
+ reference (by design — `computeGraphReversal()` and others need the
121
+ real, oldest-first list); `operations.list()`'s own `.reverse()` (to
122
+ present newest-first) mutated that same array as a side effect. Calling
123
+ it twice — a completely reasonable thing to do (list history, do
124
+ something, list history again) — un-reversed it back to oldest-first,
125
+ and anything recorded via `oplog.recordOperation()` in between landed
126
+ appended to what was, at that moment, the WRONG end. Found while writing
127
+ a regression test for the fixes above (two `operations.list()` calls
128
+ around one `autoSnapshotWorkingCopy()`), not from the issue's own repro
129
+ — but real and, in principle, capable of corrupting `undo()`/`redo()`/
130
+ `operations.restore()` for any caller that also calls
131
+ `jj.operations.list()` earlier in the same session. Fixed by copying
132
+ before reversing.
133
+
134
+ See `tests/integration/issue-43-restore-reliability.test.js`'s new
135
+ "issue #48" suites (8 new tests across all five gaps above) and
136
+ `tests/unit/core/revset-branches-coverage.test.js`'s new oplog-signal
137
+ suite (2 tests) for the hidden-tips fix specifically. All verified with
138
+ a negative control: reverting just the source changes fails exactly
139
+ these new tests while every pre-existing test still passes, confirming
140
+ they exercise real behavior rather than being tautological.
141
+
3
142
  ## 1.11.0 — 2026-09-26 — edit() of an older change rebases its descendants (#45)
4
143
 
5
144
  Part 5 of #43 (parts 1-4 shipped in 1.10.0 / #44), previously punted because
package/README.md CHANGED
@@ -1020,10 +1020,10 @@ repo/
1020
1020
 
1021
1021
  ## Project Status
1022
1022
 
1023
- **Current Version**: 0.3.0 (`@johnhenry/isomorphic-jj`; same library/API lineage as the old `isomorphic-jj`, which reached v1.7.0 before the rename — see the provenance note at the top of this file)
1024
- **Test Coverage**: 1823 tests passing, ~97% statements / 91% branches
1023
+ **Current Version**: 1.12.0 (`@johnhenry/isomorphic-jj`; same library/API lineage as the old `isomorphic-jj`, which reached v1.8.0 before the rename — see the provenance note at the top of this file, and the CHANGELOG's `1.9.0` entry for how the scoped/unscoped version lineages were merged back together)
1024
+ **Test Coverage**: 1874 tests passing, ~97% statements / 90% branches
1025
1025
  **JJ CLI parity**: tracks Jujutsu through v0.45.1
1026
- **Status**: Actively developed; broad JJ CLI semantic parity, used in the examples/apps in this repo and in [JJHub](#family). Not yet 1.0 — API surface can still shift; pin a version and read the CHANGELOG before upgrading.
1026
+ **Status**: Actively developed; broad JJ CLI semantic parity, used in the examples/apps in this repo and in [JJHub](#family). Not yet 1.0 in the API-stability sense (the version number is past 1.0 only because merging the scoped/unscoped lineages forced it above the unscoped side's prior ceiling, not because the API is frozen) — API surface can still shift; pin a version and read the CHANGELOG before upgrading.
1027
1027
 
1028
1028
  **Completed:**
1029
1029
  - ✅ v0.1: Core JJ experience (stable IDs, undo, bookmarks, revsets)
@@ -1036,6 +1036,9 @@ repo/
1036
1036
  - ✅ 0.1.0 (post-rename): Parity refresh tracking jj through **v0.44** — `tag.track()`/`tag.untrack()`, the `builtin_log()` revset alias, and `file.search({ nameOnly: true })`; `git_refs()`/`git_head()` documented as deprecated (jj removed both in v0.43). See [CHANGELOG.md](./CHANGELOG.md) for what was deliberately left for a maintainer call (`jj run`, fetch-time remote tag/bookmark import).
1037
1037
  - ✅ 0.2.0: Storage-layer audit fixes — same-process storage locking, `undo()` reverting in-place graph/tag mutations, ambiguous change-id detection, categorized `push()` error codes, a conflict-marker newline fix, atomic protobuf writes, and open-ended revset ranges (`..b`, `a..`, `::`). See [CHANGELOG.md](./CHANGELOG.md).
1038
1038
  - ✅ 0.3.0: Parity refresh tracking jj through **v0.45.1** — a working `/browser` entry (async `createBrowserFS()`, bundle-safe protobuf schema loading, no stray Node-only imports), `undo()`/`operations.restore()` reverting graph rewrites with real progressive undo/redo, `squash()`/`abandon()`/`edit()`/`new()` fully syncing content instead of only metadata, `rebase()` performing a real three-way merge and recording conflicts, and the new `converge()` command (jj v0.45.0) to auto-resolve divergent commits. See [CHANGELOG.md](./CHANGELOG.md).
1039
+ - ✅ 0.4.0: `converge()` generalized from pairwise to N-way — any number of divergent copies of one change id resolve in a single call, matching how jj's own underlying engine already treats divergence internally (issue #32). See [CHANGELOG.md](./CHANGELOG.md).
1040
+ - ✅ 1.9.0: Scoped (`@johnhenry/isomorphic-jj`) and unscoped (`isomorphic-jj`) version lineages merged into one shared version going forward, with both names dual-published on every release.
1041
+ - ✅ 1.9.1 – 1.12.0: Operation-log reliability pass — `undo()`/`operations.restore()` correctness across write/snapshot/squash/rebase/abandon/edit, `log()` visibility fixes, and recording gaps in descendant rebases and conflict-resolution ops (issues #37–#39, #41, #43, #45, #48). See [CHANGELOG.md](./CHANGELOG.md) for the full per-fix breakdown.
1039
1042
 
1040
1043
  See [ROADMAP.md](./ROADMAP.md) for detailed plans, and [CHANGELOG.md](./CHANGELOG.md) for the release history.
1041
1044
 
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);