isomorphic-jj 1.9.2 → 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,244 @@
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
+
116
+ ## 1.10.0 — 2026-09-26 — operations.restore() reliability (#43)
117
+
118
+ A substantial follow-up to 1.9.1/1.9.2's undo/restore work (#37/#38/#39/#41):
119
+ five distinct gaps that all fed the same root symptom — `operations.restore()`
120
+ (and `undo()`/`redo()`) not reliably reproducing the repository state at an
121
+ arbitrary past operation. Grouped into one minor release because all five
122
+ touch the same reversal machinery (`computeGraphReversal()`/
123
+ `applyGraphReversal()`, the `all()` revset's orphan filter, and the conflict
124
+ model), and because two of the fixes are real behavioral changes (`log()`'s
125
+ default visibility, and what `rebase()` actually writes into a conflicted
126
+ file) rather than pure bugfixes.
127
+
128
+ ### Fixed
129
+
130
+ - **`write()` and `snapshot()` now record their own operation (#43 part 1).**
131
+ Both mutated the working copy (disk content, and for `snapshot()` the
132
+ working-copy change's `fileSnapshot` too) without ever calling
133
+ `oplog.recordOperation()`. An edit typed into a change's working copy via
134
+ either was therefore completely invisible to `undo()`/`operations.restore()`
135
+ — the change kept its post-edit content forever regardless of which
136
+ operation you restored to, because there was no oplog entry to target and
137
+ no "before" snapshot for `computeGraphReversal()` to find. Both now follow
138
+ the same "snapshot the before-state, then record" pattern every other
139
+ mutator in this file already uses (see `describe()`), including a
140
+ `changeSnapshot` for `snapshot()` (which mutates a `ChangeGraph` node in
141
+ place) and a `view.fileSnapshot` for both.
142
+ - **`squash()`, `rebase()`/`moveChange()`, and `abandon()` now record a
143
+ "files before" snapshot as part of their own operation (#43 part 1).**
144
+ `computeGraphReversal()` reconstructs "the state right after operation X"
145
+ by scanning forward from X for the first LATER operation that recorded a
146
+ `view.fileSnapshot` "before" value. These three operations never recorded
147
+ one, so restoring to a point right before one of them ran had nothing
148
+ accurate to fall back on: the scan would skip straight over the gap and
149
+ either find nothing (leaving disk untouched) or land on a much later,
150
+ unrelated operation's "before" value — the wrong point in time entirely.
151
+ Reproduced via a stack-walk (build a chain of describe/new/write/squash/
152
+ describe operations, `operations.restore()` to every operation in it, and
153
+ compare on-disk files against an independently-tracked expectation) — this
154
+ is what surfaces as "a forward restore to an earlier op loses a file that
155
+ was added between a squash and the target". All three now capture disk
156
+ state before mutating anything and include it in their own `view`.
157
+ - **`undo()`/`redo()`/`operations.restore()` now record disk state right
158
+ before THEMSELVES ran, not the landing/target operation's own "before"
159
+ value (#43 part 1).** All three used to record `view: landingView` (or
160
+ `redoneView`/`targetOp.view`) verbatim — reusing that OTHER operation's
161
+ own recorded `fileSnapshot`, which represents disk state before THAT
162
+ operation ran, not before this undo/redo/restore call mutated anything.
163
+ A later `restore()`/`undo()` scanning forward past one of these would then
164
+ pick up that stale, wrong snapshot. Each now captures its own disk state
165
+ immediately before applying its reversal and merges it into the pointer
166
+ fields (`workingCopy`/`heads`/`bookmarks`) it still legitimately inherits
167
+ from the landing operation.
168
+ - **`view.heads` no longer silently drops live heads (#43 part 1).** Every
169
+ operation that recorded `view.heads` hand-wrote it as either `[]` or a
170
+ single changeId — whatever that specific call happened to touch —
171
+ instead of the repository's actual current head set. `squash()` in
172
+ particular recorded the destination changeId even when squashing the
173
+ working copy creates a fresh new tip on top of it (so `heads` pointed at
174
+ the parent, not the tip), and any operation that didn't itself touch an
175
+ untouched sibling branch silently reported only one head where two
176
+ existed. Added `computeCurrentHeads()` (every non-abandoned change with no
177
+ non-abandoned child) and switched `squash()`/`rebase()`/`moveChange()`/
178
+ `abandon()`/`conflicts.resolve()` to use it.
179
+ - **`log()` now hides orphans that are purely internal rewrite/undo
180
+ artifacts, even once they differ from their reverted parent (#43 part 2).**
181
+ The #37(b) fix hid an orphan only while it stayed exactly empty relative
182
+ to its CURRENT parent. Two shapes slip past that once undo() is involved:
183
+ (a) `squash()`'s synthetic post-squash `@`, if that squash is later undone
184
+ and the same undo reverts the destination's content out from under it —
185
+ the orphan's frozen (post-squash) `fileSnapshot` no longer matches the
186
+ destination's (reverted, pre-squash) one, even though nothing about the
187
+ orphan itself changed; (b) `new()`'s freshly created change, if something
188
+ writes into it before an `undo()` unwinds back past the `new()`. Both
189
+ `squash()`'s synthetic working-copy change and `new()`'s created change
190
+ are now tagged `autoCreated: true` at creation, and the revset engine's
191
+ `_computeHiddenOrphans()` hides an unreachable, undescribed change if it's
192
+ EITHER still empty relative to its parent OR `autoCreated` — the latter
193
+ doesn't need to re-diff against a parent that may no longer resemble what
194
+ the change was actually created from.
195
+ - **`rebase()`/`moveChange()` now writes real conflict-marker text into the
196
+ working copy instead of leaving the pre-rebase ("ours") content verbatim
197
+ (#43 part 3) — behavioral change.** Rebase already recorded a detected
198
+ conflict correctly as data (via `conflicts.addConflict()`), but never
199
+ touched the file's actual content: `change.fileSnapshot` (and, when the
200
+ rebased change was checked out, the on-disk file) kept showing whatever
201
+ content it had before the rebase, with no indication a conflict existed
202
+ short of calling `conflicts.list()` separately. It now calls
203
+ `jj.conflicts.markers()` for each detected conflict (reusing that
204
+ existing marker-formatting logic rather than re-deriving it a second way)
205
+ and writes the result into both `change.fileSnapshot` and, when
206
+ applicable, the working copy on disk.
207
+ - **`conflicts.resolve()` now records an operation, so a resolution can be
208
+ undone (#43 part 4).** It wrote the resolved content straight to disk and
209
+ mutated the `ConflictModel`'s persisted state directly, with no
210
+ `oplog.recordOperation()` call at all — resolving a conflict was
211
+ completely invisible to `undo()`/`operations.restore()`. It now captures
212
+ a `changeSnapshot` of the working-copy change, a `conflictsSnapshot` of
213
+ the pre-resolve conflict state (deep-cloned — `resolveConflict()` mutates
214
+ the same conflict object in place, so a naive `Object.fromEntries()` over
215
+ the live `Map` would otherwise leave the "before" snapshot aliased to the
216
+ post-mutation object), and a `view.fileSnapshot`, then records them.
217
+
218
+ ### Not fixed (punted)
219
+
220
+ - **#43 part 5 (minor): `edit()` of a non-leaf change doesn't rebase its
221
+ descendants' file content.** An initial fix (propagate the edited
222
+ ancestor's content onto descendants when moving away from it) was
223
+ implemented and then reverted: it directly conflicts with `absorb()`'s own
224
+ job of folding a working-copy edit back into the ancestor it actually
225
+ belongs to — with the auto-rebase in place, `edit()` + `write()` +
226
+ `edit()`-away already changes the descendant before `absorb()` ever runs,
227
+ so `absorb()` finds nothing left to do (caught by the existing
228
+ `tests/integration/absorb.test.js` "should work after edit()" test).
229
+ Fixing this properly needs to account for that interaction; left for a
230
+ follow-up.
231
+
232
+ ### Testing
233
+
234
+ 1859 tests passing (12 new regression tests, matched by a new negative
235
+ control confirming that 12 of them fail against the pre-fix code — the
236
+ other 3 are supplementary correctness checks that already held, e.g.
237
+ "a descendant's own edit is never clobbered"); lint (0 errors),
238
+ format:check, typecheck, and build all still green; all 12 `examples/*.mjs`
239
+ run clean; branch coverage 90.07% (gate: 90%). New test file:
240
+ `tests/integration/issue-43-restore-reliability.test.js`.
241
+
3
242
  ## 1.9.2 — 2026-09-26 — auto-snapshot mtime comparison (#41)
4
243
 
5
244
  ### Fixed