isomorphic-jj 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,118 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.11.0 — 2026-09-26 — edit() of an older change rebases its descendants (#45)
4
+
5
+ Part 5 of #43 (parts 1-4 shipped in 1.10.0 / #44), previously punted because
6
+ an initial attempt broke `tests/integration/absorb.test.js`'s "should work
7
+ after edit()" test.
8
+
9
+ ### Fixed
10
+
11
+ - **`snapshot()` and `autoSnapshotWorkingCopy()` now propagate an edited
12
+ non-leaf change's new content onto its descendants (#45).** `edit()`
13
+ itself never touches content — it's a pure checkout (moves `@`, syncs disk
14
+ to the target's existing `fileSnapshot`). The actual moment a change's
15
+ *committed* content changes is inside the public `snapshot()` and the
16
+ internal `autoSnapshotWorkingCopy()`, which reconcile on-disk state into
17
+ the CURRENT working-copy change's `fileSnapshot`. If you `edit()`'d into a
18
+ change that already had descendants and then modified a file, both
19
+ functions updated only that change's own `fileSnapshot` and never
20
+ propagated the update, so the descendants stayed permanently stale unless
21
+ you happened to `edit()` directly into one of them (which just re-synced
22
+ disk FROM its own still-stale snapshot — no help). Both now check for
23
+ descendants (`_findDescendants`) after committing the new content and, if
24
+ any exist, rebase them onto it (`_rebuildDescendants`) — matching real
25
+ jj's automatic rebase-on-edit. `edit()` was correctly left untouched: it
26
+ never mutates content on the way IN, so it was never the right hook point.
27
+ - **Un-deprecated `_rebuildDescendants()` and fixed a real-content-vs-
28
+ synthetic-edit ordering bug in it (#45).** This line-level, three-way-
29
+ merge-style rebase helper had been marked `@deprecated` with zero callers
30
+ (`absorb()` uses a different function, `_rebuildDescendantsWithStates`) —
31
+ reused here instead of writing a second rebase implementation. Its first
32
+ pass builds "the original parent state" for every change by reading the
33
+ parent's CURRENT `fileSnapshot` live from the graph. Called (as it must
34
+ be) *after* the ancestor's own `graph.updateChange()` has already written
35
+ its new content, that live read captures the NEW content as if it were
36
+ the pre-edit baseline for the ancestor's DIRECT children — every line of
37
+ a stale descendant then looks like the descendant's own deliberate edit
38
+ (stale != "original"), so the old content gets preserved verbatim: a
39
+ silent no-op that reproduces the exact bug rather than fixing it. Added an
40
+ optional third parameter, `originalAncestorSnapshot`, that callers supply
41
+ with the ancestor's pre-mutation snapshot; the first pass uses it in place
42
+ of the live read specifically for the ancestor's direct children. Deeper
43
+ descendants are unaffected (their own immediate parent hasn't been mutated
44
+ yet when the function reads it, so the live read is correct for them).
45
+ - **`edit()`'s OWN inline content-commit — a third site the first pass
46
+ missed — now propagates too (#45).** There are three places a checked-out
47
+ change's content actually gets committed, not two: alongside the public
48
+ `snapshot()` and the internal `autoSnapshotWorkingCopy()` above, `edit()`
49
+ has its own separate inline step that snapshots disk for the change being
50
+ switched AWAY FROM and commits it via `graph.updateChange()` directly —
51
+ entirely bypassing both other functions. The first pass explicitly left
52
+ `edit()` untouched, on the theory (recorded in this same entry, in an
53
+ earlier draft) that this site was pure checkout bookkeeping unrelated to
54
+ content commits, and that `tests/integration/absorb.test.js`'s "should
55
+ work after edit()" would therefore keep passing unmodified. Both halves of
56
+ that theory were wrong: this inline step *is* a real content commit (it's
57
+ literally how `write()`-then-`edit()`-away ever reaches the graph at all,
58
+ since `write()` itself only touches disk), and it needed the exact same
59
+ descendant-propagation treatment as the other two sites. Once added, the
60
+ `absorb.test.js` collision the very first attempt at #45 hit *did*
61
+ materialize here — confirming this was the actual site that original
62
+ attempt (and this issue's own motivating repro: "edit an ancestor, modify
63
+ it, edit back") was about all along.
64
+ - **Fixed the `absorb.test.js` fixture, not the feature.** Traced exactly
65
+ why: "should work after edit()" wrote `file.txt` on `change1`, edited away
66
+ to `change2`, then called `absorb()` expecting it to fold `change2`'s
67
+ (stale) content back into `change1`. That "diff" only existed because
68
+ `change2` hadn't picked up `change1`'s edit — precisely the bug being
69
+ fixed. Once fixed, `change2` correctly matches `change1` the moment you
70
+ edit away, the manufactured diff disappears, and `absorb()` correctly
71
+ finds nothing to do. Updated the fixture to make a genuine *new* edit at
72
+ `change2` after returning to it, so `absorb()` has a real, bug-independent
73
+ diff to fold — preserving the test's actual intent ("absorb still works
74
+ normally in a session where an ancestor was edited earlier") without
75
+ depending on the bug.
76
+ - **Fixed a second, pre-existing bug in `_rebuildDescendants()` that this
77
+ same follow-up surfaced: file deletions were resurrected as empty
78
+ strings.** The function had no way to represent "this file doesn't exist"
79
+ separately from "empty content" — every file was coerced with `|| ''`
80
+ before diffing. A descendant that deliberately deleted a file the parent
81
+ still has (e.g. `backout()`'s reversal change, which legitimately has no
82
+ `fileSnapshot` entry at all for a removed file) got that file rebuilt as
83
+ `''` instead of staying deleted, breaking
84
+ `tests/integration/backout.test.js`'s two file-addition-reversal tests.
85
+ This was always latent — `_rebuildDescendants()` had zero callers at all
86
+ before this issue gave it a real one, so the bug was never exercised.
87
+ Fixed by tracking each file's existence (`hasOwnProperty`, not `||`) on
88
+ all three sides (descendant, original parent, updated parent) and
89
+ handling "descendant deleted it," "descendant added it fresh," and "both
90
+ sides have real content" as distinct cases, rather than collapsing all of
91
+ them into one string-diff.
92
+
93
+ ### Testing
94
+
95
+ New file `tests/integration/issue-43-restore-reliability.test.js`, describe
96
+ block "issue #45": edit into a non-leaf change, modify a file, and confirm
97
+ the descendant picks up the edit (a) via an explicit `jj.snapshot()` call,
98
+ (b) via `autoSnapshotWorkingCopy()` triggered by `jj.status()` after an
99
+ out-of-band disk write (a same-size `jj.write()` wouldn't reach this path —
100
+ `write()` immediately updates the tracked mtime/size itself, so the
101
+ disk-walk would see nothing "modified"), and (c) via `edit()`-away alone
102
+ with no explicit `snapshot()`/`status()` call in between — all three checked
103
+ via `jj.show()`. A fourth test confirms a leaf change (no descendants) still
104
+ works, exercising the other side of the `descendants.length > 0` branch. A
105
+ fifth test constructs a descendant that deliberately deleted a file and
106
+ confirms rebuilding it preserves that deletion instead of resurrecting an
107
+ empty string. Negative controls: with the `src/api/repository.js` changes
108
+ reverted, the propagation tests fail with stale pre-edit content, the
109
+ deletion test fails with a resurrected `''`, and the leaf-change test still
110
+ passes; reapplying the fix makes all five pass, and
111
+ `tests/integration/backout.test.js`'s two previously-broken tests pass
112
+ again too. 1864 tests passing; lint (0 errors), format:check, typecheck,
113
+ and build all green; all 12 `examples/*.mjs` run clean; branch coverage
114
+ 90.06% (gate: 90%).
115
+
3
116
  ## 1.10.0 — 2026-09-26 — operations.restore() reliability (#43)
4
117
 
5
118
  A substantial follow-up to 1.9.1/1.9.2's undo/restore work (#37/#38/#39/#41):
package/dist/index.cjs CHANGED
@@ -8268,8 +8268,26 @@ async function createJJ(options) {
8268
8268
  const wcId = workingCopy.getCurrentChangeId();
8269
8269
  const change = await graph.getChange(wcId);
8270
8270
  if (change) {
8271
+ const oldFileSnapshot = change.fileSnapshot ? structuredClone(change.fileSnapshot) : {};
8271
8272
  change.fileSnapshot = await snapshotFilesystem();
8272
8273
  await graph.updateChange(change);
8274
+
8275
+ // Issue #45: if the working-copy change isn't a leaf (e.g. we
8276
+ // edit()'d into an older change earlier in the session), its
8277
+ // descendants' fileSnapshot still reflects the OLD content and
8278
+ // needs to be rebased onto the content just committed above —
8279
+ // matching real jj's automatic rebase-on-edit. edit() itself is a
8280
+ // pure checkout and never mutates content, so this is the actual
8281
+ // moment content changes and the right place to propagate. `jj`
8282
+ // (not `this`) is used here because this function is a plain
8283
+ // closure, not a method of the `jj` object — but `jj` is a const
8284
+ // in the same enclosing scope and this function only ever runs
8285
+ // after `jj` is fully constructed, so the self-reference is safe
8286
+ // (same pattern used elsewhere in this file, e.g. `jj.new(...)`).
8287
+ const descendants = await jj._findDescendants(wcId, null);
8288
+ if (descendants.length > 0) {
8289
+ await jj._rebuildDescendants(wcId, null, oldFileSnapshot);
8290
+ }
8273
8291
  }
8274
8292
  } catch {
8275
8293
  // Best-effort content refresh; tracking state is already updated.
@@ -9874,9 +9892,21 @@ async function createJJ(options) {
9874
9892
  changeSnapshot[wcId] = structuredClone(change);
9875
9893
  change.fileSnapshot = currentDiskSnapshot;
9876
9894
  await graph.updateChange(change);
9895
+
9896
+ // Issue #45: if the working-copy change isn't a leaf (e.g. we
9897
+ // edit()'d into an older change earlier in the session), its
9898
+ // descendants' fileSnapshot still reflects the OLD content and
9899
+ // needs to be rebased onto the content just committed above —
9900
+ // matching real jj's automatic rebase-on-edit. edit() itself is a
9901
+ // pure checkout and never mutates content, so this is the actual
9902
+ // moment content changes and the right place to propagate.
9903
+ const descendants = await this._findDescendants(wcId, null);
9904
+ if (descendants.length > 0) {
9905
+ await this._rebuildDescendants(wcId, null, changeSnapshot[wcId].fileSnapshot);
9906
+ }
9877
9907
  }
9878
9908
  } catch {
9879
- // Best-effort content refresh.
9909
+ // Best-effort content refresh (including descendant propagation).
9880
9910
  }
9881
9911
 
9882
9912
  await oplog.recordOperation({
@@ -10227,6 +10257,22 @@ async function createJJ(options) {
10227
10257
  changeSnapshot[previousChangeId] = structuredClone(previousChange);
10228
10258
  previousChange.fileSnapshot = currentSnapshot;
10229
10259
  await graph.updateChange(previousChange);
10260
+
10261
+ // Issue #45: this is the OTHER place a checked-out change's
10262
+ // content gets committed to the graph — not through snapshot()/
10263
+ // autoSnapshotWorkingCopy() at all, but here, inline, when
10264
+ // edit() leaves a change it was checked out on. If that change
10265
+ // isn't a leaf (we'd edit()'d into an ancestor), its descendants
10266
+ // still have the OLD content and need the same rebase-on-edit
10267
+ // treatment applied at the other two commit sites.
10268
+ const descendants = await this._findDescendants(previousChangeId, null);
10269
+ if (descendants.length > 0) {
10270
+ await this._rebuildDescendants(
10271
+ previousChangeId,
10272
+ null,
10273
+ changeSnapshot[previousChangeId].fileSnapshot
10274
+ );
10275
+ }
10230
10276
  }
10231
10277
  }
10232
10278
  }
@@ -12465,9 +12511,27 @@ async function createJJ(options) {
12465
12511
  * @private
12466
12512
  * @param {string} ancestorId - The ancestor change that was updated
12467
12513
  * @param {string} stopAtChangeId - Don't rebuild this change (usually working copy)
12468
- * @deprecated Use _rebuildDescendantsWithStates instead
12514
+ * @param {Record<string, string>} [originalAncestorSnapshot] - The
12515
+ * ancestor's fileSnapshot as it was BEFORE the caller's own
12516
+ * `graph.updateChange()` for `ancestorId` ran. Callers must invoke this
12517
+ * function AFTER committing the ancestor's new content (that's the
12518
+ * natural order — the ancestor's content must exist before its
12519
+ * descendants can be rebased onto it), but that means a live
12520
+ * `graph.getChange(ancestorId).fileSnapshot` read inside this function
12521
+ * would already see the NEW content. For the ancestor's DIRECT
12522
+ * children, this function compares each descendant's stale content
12523
+ * against "the original parent state" to figure out which lines are
12524
+ * the descendant's own edits; if that "original" state were actually
12525
+ * the already-new ancestor content, every line of the stale
12526
+ * descendant would look like a deliberate edit (stale != new) and get
12527
+ * preserved verbatim — silently reproducing the staleness instead of
12528
+ * fixing it. Passing the pre-mutation snapshot here lets the ancestor's
12529
+ * direct children be diffed against the correct baseline. Deeper
12530
+ * descendants are unaffected (their own immediate parent hasn't been
12531
+ * mutated yet when this function reads it, so the live read is
12532
+ * correct for them). Optional for backward compatibility.
12469
12533
  */
12470
- async _rebuildDescendants(ancestorId, stopAtChangeId) {
12534
+ async _rebuildDescendants(ancestorId, stopAtChangeId, originalAncestorSnapshot) {
12471
12535
  // Find all descendants of ancestorId
12472
12536
  const allChanges = graph.getAll();
12473
12537
 
@@ -12475,11 +12539,16 @@ async function createJJ(options) {
12475
12539
  const originalParentStates = new Map();
12476
12540
  for (const change of allChanges) {
12477
12541
  if (change.parents && change.parents.length > 0) {
12478
- const parent = await graph.getChange(change.parents[0]);
12542
+ const parentId = change.parents[0];
12543
+ const parent = await graph.getChange(parentId);
12479
12544
  if (parent) {
12545
+ const parentSnapshot =
12546
+ parentId === ancestorId && originalAncestorSnapshot !== undefined
12547
+ ? JSON.parse(JSON.stringify(originalAncestorSnapshot))
12548
+ : JSON.parse(JSON.stringify(parent.fileSnapshot || {}));
12480
12549
  originalParentStates.set(change.changeId, {
12481
- parentId: change.parents[0],
12482
- parentSnapshot: JSON.parse(JSON.stringify(parent.fileSnapshot || {})),
12550
+ parentId,
12551
+ parentSnapshot,
12483
12552
  });
12484
12553
  }
12485
12554
  }
@@ -12549,30 +12618,78 @@ async function createJJ(options) {
12549
12618
  const allFiles = new Set([
12550
12619
  ...Object.keys(descendant.fileSnapshot || {}),
12551
12620
  ...Object.keys(parent.fileSnapshot || {}),
12621
+ ...Object.keys(originalParentSnapshot || {}),
12552
12622
  ]);
12553
12623
 
12554
12624
  for (const filePath of allFiles) {
12625
+ const descendantExists = Object.prototype.hasOwnProperty.call(
12626
+ descendant.fileSnapshot || {},
12627
+ filePath
12628
+ );
12629
+ const originalParentExists = Object.prototype.hasOwnProperty.call(
12630
+ originalParentSnapshot || {},
12631
+ filePath
12632
+ );
12633
+ const updatedParentExists = Object.prototype.hasOwnProperty.call(
12634
+ parent.fileSnapshot || {},
12635
+ filePath
12636
+ );
12637
+
12638
+ // A file's EXISTENCE is a separate question from its content —
12639
+ // coercing "doesn't exist" to '' before diffing (the previous
12640
+ // version of this loop did) means a descendant that deliberately
12641
+ // deleted a file the old parent had gets that file resurrected as
12642
+ // an empty string once rebuilt, instead of staying deleted. This
12643
+ // was a real, latent bug: nothing called this function at all
12644
+ // until issue #45 gave it a real caller, so it was never
12645
+ // exercised against a deletion before.
12646
+ if (
12647
+ descendantExists === originalParentExists &&
12648
+ (!descendantExists ||
12649
+ descendant.fileSnapshot[filePath] === originalParentSnapshot[filePath])
12650
+ ) {
12651
+ // Descendant never touched this file relative to its old
12652
+ // parent (same existence + content as before) -- just follow
12653
+ // whatever the updated parent did with it (added, removed, or
12654
+ // changed since the descendant branched off).
12655
+ if (updatedParentExists) newSnapshot[filePath] = parent.fileSnapshot[filePath];
12656
+ continue; // else: stays deleted -- don't add it to newSnapshot
12657
+ }
12658
+
12659
+ if (originalParentExists && !descendantExists) {
12660
+ // Descendant deliberately deleted a file the old parent had —
12661
+ // preserve that deletion rather than resurrecting it.
12662
+ continue;
12663
+ }
12664
+
12665
+ if (!originalParentExists && descendantExists && !updatedParentExists) {
12666
+ // Descendant added a file that didn't exist on either side of
12667
+ // the parent at all -- keep the descendant's own addition.
12668
+ newSnapshot[filePath] = descendant.fileSnapshot[filePath];
12669
+ continue;
12670
+ }
12671
+
12672
+ // Both the descendant and the parent have real content for this
12673
+ // file (whether or not it existed originally) -- do the actual
12674
+ // line-level replay: find which lines the descendant changed
12675
+ // from the OLD parent, then reapply just those lines onto the
12676
+ // NEW parent's content.
12555
12677
  const descendantContent = descendant.fileSnapshot[filePath] || '';
12556
12678
  const originalParentContent = originalParentSnapshot[filePath] || '';
12557
12679
  const updatedParentContent = parent.fileSnapshot[filePath] || '';
12558
12680
 
12559
- // Find which lines descendant modified from original parent
12560
12681
  const descendantLines = descendantContent.split('\n');
12561
12682
  const originalParentLines = originalParentContent.split('\n');
12562
12683
  const updatedParentLines = updatedParentContent.split('\n');
12563
12684
 
12564
- // Rebuild file: start with updated parent, apply descendant's modifications
12565
12685
  const resultLines = [...updatedParentLines];
12566
12686
 
12567
- // Find lines that descendant modified
12568
12687
  for (let i = 0; i < Math.max(descendantLines.length, originalParentLines.length); i++) {
12569
12688
  const descendantLine = i < descendantLines.length ? descendantLines[i] : undefined;
12570
12689
  const originalParentLine =
12571
12690
  i < originalParentLines.length ? originalParentLines[i] : undefined;
12572
12691
 
12573
- // If descendant modified this line from original parent, apply that modification
12574
12692
  if (descendantLine !== originalParentLine) {
12575
- // Extend resultLines if needed
12576
12693
  while (resultLines.length <= i) {
12577
12694
  resultLines.push('');
12578
12695
  }