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 +113 -0
- package/dist/index.cjs +128 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +128 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/api/repository.js +128 -11
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
|
-
* @
|
|
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
|
|
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
|
|
12482
|
-
parentSnapshot
|
|
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
|
}
|