isomorphic-jj 1.10.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,212 @@
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
+
97
+ ## 1.11.0 — 2026-09-26 — edit() of an older change rebases its descendants (#45)
98
+
99
+ Part 5 of #43 (parts 1-4 shipped in 1.10.0 / #44), previously punted because
100
+ an initial attempt broke `tests/integration/absorb.test.js`'s "should work
101
+ after edit()" test.
102
+
103
+ ### Fixed
104
+
105
+ - **`snapshot()` and `autoSnapshotWorkingCopy()` now propagate an edited
106
+ non-leaf change's new content onto its descendants (#45).** `edit()`
107
+ itself never touches content — it's a pure checkout (moves `@`, syncs disk
108
+ to the target's existing `fileSnapshot`). The actual moment a change's
109
+ *committed* content changes is inside the public `snapshot()` and the
110
+ internal `autoSnapshotWorkingCopy()`, which reconcile on-disk state into
111
+ the CURRENT working-copy change's `fileSnapshot`. If you `edit()`'d into a
112
+ change that already had descendants and then modified a file, both
113
+ functions updated only that change's own `fileSnapshot` and never
114
+ propagated the update, so the descendants stayed permanently stale unless
115
+ you happened to `edit()` directly into one of them (which just re-synced
116
+ disk FROM its own still-stale snapshot — no help). Both now check for
117
+ descendants (`_findDescendants`) after committing the new content and, if
118
+ any exist, rebase them onto it (`_rebuildDescendants`) — matching real
119
+ jj's automatic rebase-on-edit. `edit()` was correctly left untouched: it
120
+ never mutates content on the way IN, so it was never the right hook point.
121
+ - **Un-deprecated `_rebuildDescendants()` and fixed a real-content-vs-
122
+ synthetic-edit ordering bug in it (#45).** This line-level, three-way-
123
+ merge-style rebase helper had been marked `@deprecated` with zero callers
124
+ (`absorb()` uses a different function, `_rebuildDescendantsWithStates`) —
125
+ reused here instead of writing a second rebase implementation. Its first
126
+ pass builds "the original parent state" for every change by reading the
127
+ parent's CURRENT `fileSnapshot` live from the graph. Called (as it must
128
+ be) *after* the ancestor's own `graph.updateChange()` has already written
129
+ its new content, that live read captures the NEW content as if it were
130
+ the pre-edit baseline for the ancestor's DIRECT children — every line of
131
+ a stale descendant then looks like the descendant's own deliberate edit
132
+ (stale != "original"), so the old content gets preserved verbatim: a
133
+ silent no-op that reproduces the exact bug rather than fixing it. Added an
134
+ optional third parameter, `originalAncestorSnapshot`, that callers supply
135
+ with the ancestor's pre-mutation snapshot; the first pass uses it in place
136
+ of the live read specifically for the ancestor's direct children. Deeper
137
+ descendants are unaffected (their own immediate parent hasn't been mutated
138
+ yet when the function reads it, so the live read is correct for them).
139
+ - **`edit()`'s OWN inline content-commit — a third site the first pass
140
+ missed — now propagates too (#45).** There are three places a checked-out
141
+ change's content actually gets committed, not two: alongside the public
142
+ `snapshot()` and the internal `autoSnapshotWorkingCopy()` above, `edit()`
143
+ has its own separate inline step that snapshots disk for the change being
144
+ switched AWAY FROM and commits it via `graph.updateChange()` directly —
145
+ entirely bypassing both other functions. The first pass explicitly left
146
+ `edit()` untouched, on the theory (recorded in this same entry, in an
147
+ earlier draft) that this site was pure checkout bookkeeping unrelated to
148
+ content commits, and that `tests/integration/absorb.test.js`'s "should
149
+ work after edit()" would therefore keep passing unmodified. Both halves of
150
+ that theory were wrong: this inline step *is* a real content commit (it's
151
+ literally how `write()`-then-`edit()`-away ever reaches the graph at all,
152
+ since `write()` itself only touches disk), and it needed the exact same
153
+ descendant-propagation treatment as the other two sites. Once added, the
154
+ `absorb.test.js` collision the very first attempt at #45 hit *did*
155
+ materialize here — confirming this was the actual site that original
156
+ attempt (and this issue's own motivating repro: "edit an ancestor, modify
157
+ it, edit back") was about all along.
158
+ - **Fixed the `absorb.test.js` fixture, not the feature.** Traced exactly
159
+ why: "should work after edit()" wrote `file.txt` on `change1`, edited away
160
+ to `change2`, then called `absorb()` expecting it to fold `change2`'s
161
+ (stale) content back into `change1`. That "diff" only existed because
162
+ `change2` hadn't picked up `change1`'s edit — precisely the bug being
163
+ fixed. Once fixed, `change2` correctly matches `change1` the moment you
164
+ edit away, the manufactured diff disappears, and `absorb()` correctly
165
+ finds nothing to do. Updated the fixture to make a genuine *new* edit at
166
+ `change2` after returning to it, so `absorb()` has a real, bug-independent
167
+ diff to fold — preserving the test's actual intent ("absorb still works
168
+ normally in a session where an ancestor was edited earlier") without
169
+ depending on the bug.
170
+ - **Fixed a second, pre-existing bug in `_rebuildDescendants()` that this
171
+ same follow-up surfaced: file deletions were resurrected as empty
172
+ strings.** The function had no way to represent "this file doesn't exist"
173
+ separately from "empty content" — every file was coerced with `|| ''`
174
+ before diffing. A descendant that deliberately deleted a file the parent
175
+ still has (e.g. `backout()`'s reversal change, which legitimately has no
176
+ `fileSnapshot` entry at all for a removed file) got that file rebuilt as
177
+ `''` instead of staying deleted, breaking
178
+ `tests/integration/backout.test.js`'s two file-addition-reversal tests.
179
+ This was always latent — `_rebuildDescendants()` had zero callers at all
180
+ before this issue gave it a real one, so the bug was never exercised.
181
+ Fixed by tracking each file's existence (`hasOwnProperty`, not `||`) on
182
+ all three sides (descendant, original parent, updated parent) and
183
+ handling "descendant deleted it," "descendant added it fresh," and "both
184
+ sides have real content" as distinct cases, rather than collapsing all of
185
+ them into one string-diff.
186
+
187
+ ### Testing
188
+
189
+ New file `tests/integration/issue-43-restore-reliability.test.js`, describe
190
+ block "issue #45": edit into a non-leaf change, modify a file, and confirm
191
+ the descendant picks up the edit (a) via an explicit `jj.snapshot()` call,
192
+ (b) via `autoSnapshotWorkingCopy()` triggered by `jj.status()` after an
193
+ out-of-band disk write (a same-size `jj.write()` wouldn't reach this path —
194
+ `write()` immediately updates the tracked mtime/size itself, so the
195
+ disk-walk would see nothing "modified"), and (c) via `edit()`-away alone
196
+ with no explicit `snapshot()`/`status()` call in between — all three checked
197
+ via `jj.show()`. A fourth test confirms a leaf change (no descendants) still
198
+ works, exercising the other side of the `descendants.length > 0` branch. A
199
+ fifth test constructs a descendant that deliberately deleted a file and
200
+ confirms rebuilding it preserves that deletion instead of resurrecting an
201
+ empty string. Negative controls: with the `src/api/repository.js` changes
202
+ reverted, the propagation tests fail with stale pre-edit content, the
203
+ deletion test fails with a resurrected `''`, and the leaf-change test still
204
+ passes; reapplying the fix makes all five pass, and
205
+ `tests/integration/backout.test.js`'s two previously-broken tests pass
206
+ again too. 1864 tests passing; lint (0 errors), format:check, typecheck,
207
+ and build all green; all 12 `examples/*.mjs` run clean; branch coverage
208
+ 90.06% (gate: 90%).
209
+
3
210
  ## 1.10.0 — 2026-09-26 — operations.restore() reliability (#43)
4
211
 
5
212
  A substantial follow-up to 1.9.1/1.9.2's undo/restore work (#37/#38/#39/#41):