isomorphic-jj 1.9.0 → 1.10.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,280 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.10.0 — 2026-09-26 — operations.restore() reliability (#43)
4
+
5
+ A substantial follow-up to 1.9.1/1.9.2's undo/restore work (#37/#38/#39/#41):
6
+ five distinct gaps that all fed the same root symptom — `operations.restore()`
7
+ (and `undo()`/`redo()`) not reliably reproducing the repository state at an
8
+ arbitrary past operation. Grouped into one minor release because all five
9
+ touch the same reversal machinery (`computeGraphReversal()`/
10
+ `applyGraphReversal()`, the `all()` revset's orphan filter, and the conflict
11
+ model), and because two of the fixes are real behavioral changes (`log()`'s
12
+ default visibility, and what `rebase()` actually writes into a conflicted
13
+ file) rather than pure bugfixes.
14
+
15
+ ### Fixed
16
+
17
+ - **`write()` and `snapshot()` now record their own operation (#43 part 1).**
18
+ Both mutated the working copy (disk content, and for `snapshot()` the
19
+ working-copy change's `fileSnapshot` too) without ever calling
20
+ `oplog.recordOperation()`. An edit typed into a change's working copy via
21
+ either was therefore completely invisible to `undo()`/`operations.restore()`
22
+ — the change kept its post-edit content forever regardless of which
23
+ operation you restored to, because there was no oplog entry to target and
24
+ no "before" snapshot for `computeGraphReversal()` to find. Both now follow
25
+ the same "snapshot the before-state, then record" pattern every other
26
+ mutator in this file already uses (see `describe()`), including a
27
+ `changeSnapshot` for `snapshot()` (which mutates a `ChangeGraph` node in
28
+ place) and a `view.fileSnapshot` for both.
29
+ - **`squash()`, `rebase()`/`moveChange()`, and `abandon()` now record a
30
+ "files before" snapshot as part of their own operation (#43 part 1).**
31
+ `computeGraphReversal()` reconstructs "the state right after operation X"
32
+ by scanning forward from X for the first LATER operation that recorded a
33
+ `view.fileSnapshot` "before" value. These three operations never recorded
34
+ one, so restoring to a point right before one of them ran had nothing
35
+ accurate to fall back on: the scan would skip straight over the gap and
36
+ either find nothing (leaving disk untouched) or land on a much later,
37
+ unrelated operation's "before" value — the wrong point in time entirely.
38
+ Reproduced via a stack-walk (build a chain of describe/new/write/squash/
39
+ describe operations, `operations.restore()` to every operation in it, and
40
+ compare on-disk files against an independently-tracked expectation) — this
41
+ is what surfaces as "a forward restore to an earlier op loses a file that
42
+ was added between a squash and the target". All three now capture disk
43
+ state before mutating anything and include it in their own `view`.
44
+ - **`undo()`/`redo()`/`operations.restore()` now record disk state right
45
+ before THEMSELVES ran, not the landing/target operation's own "before"
46
+ value (#43 part 1).** All three used to record `view: landingView` (or
47
+ `redoneView`/`targetOp.view`) verbatim — reusing that OTHER operation's
48
+ own recorded `fileSnapshot`, which represents disk state before THAT
49
+ operation ran, not before this undo/redo/restore call mutated anything.
50
+ A later `restore()`/`undo()` scanning forward past one of these would then
51
+ pick up that stale, wrong snapshot. Each now captures its own disk state
52
+ immediately before applying its reversal and merges it into the pointer
53
+ fields (`workingCopy`/`heads`/`bookmarks`) it still legitimately inherits
54
+ from the landing operation.
55
+ - **`view.heads` no longer silently drops live heads (#43 part 1).** Every
56
+ operation that recorded `view.heads` hand-wrote it as either `[]` or a
57
+ single changeId — whatever that specific call happened to touch —
58
+ instead of the repository's actual current head set. `squash()` in
59
+ particular recorded the destination changeId even when squashing the
60
+ working copy creates a fresh new tip on top of it (so `heads` pointed at
61
+ the parent, not the tip), and any operation that didn't itself touch an
62
+ untouched sibling branch silently reported only one head where two
63
+ existed. Added `computeCurrentHeads()` (every non-abandoned change with no
64
+ non-abandoned child) and switched `squash()`/`rebase()`/`moveChange()`/
65
+ `abandon()`/`conflicts.resolve()` to use it.
66
+ - **`log()` now hides orphans that are purely internal rewrite/undo
67
+ artifacts, even once they differ from their reverted parent (#43 part 2).**
68
+ The #37(b) fix hid an orphan only while it stayed exactly empty relative
69
+ to its CURRENT parent. Two shapes slip past that once undo() is involved:
70
+ (a) `squash()`'s synthetic post-squash `@`, if that squash is later undone
71
+ and the same undo reverts the destination's content out from under it —
72
+ the orphan's frozen (post-squash) `fileSnapshot` no longer matches the
73
+ destination's (reverted, pre-squash) one, even though nothing about the
74
+ orphan itself changed; (b) `new()`'s freshly created change, if something
75
+ writes into it before an `undo()` unwinds back past the `new()`. Both
76
+ `squash()`'s synthetic working-copy change and `new()`'s created change
77
+ are now tagged `autoCreated: true` at creation, and the revset engine's
78
+ `_computeHiddenOrphans()` hides an unreachable, undescribed change if it's
79
+ EITHER still empty relative to its parent OR `autoCreated` — the latter
80
+ doesn't need to re-diff against a parent that may no longer resemble what
81
+ the change was actually created from.
82
+ - **`rebase()`/`moveChange()` now writes real conflict-marker text into the
83
+ working copy instead of leaving the pre-rebase ("ours") content verbatim
84
+ (#43 part 3) — behavioral change.** Rebase already recorded a detected
85
+ conflict correctly as data (via `conflicts.addConflict()`), but never
86
+ touched the file's actual content: `change.fileSnapshot` (and, when the
87
+ rebased change was checked out, the on-disk file) kept showing whatever
88
+ content it had before the rebase, with no indication a conflict existed
89
+ short of calling `conflicts.list()` separately. It now calls
90
+ `jj.conflicts.markers()` for each detected conflict (reusing that
91
+ existing marker-formatting logic rather than re-deriving it a second way)
92
+ and writes the result into both `change.fileSnapshot` and, when
93
+ applicable, the working copy on disk.
94
+ - **`conflicts.resolve()` now records an operation, so a resolution can be
95
+ undone (#43 part 4).** It wrote the resolved content straight to disk and
96
+ mutated the `ConflictModel`'s persisted state directly, with no
97
+ `oplog.recordOperation()` call at all — resolving a conflict was
98
+ completely invisible to `undo()`/`operations.restore()`. It now captures
99
+ a `changeSnapshot` of the working-copy change, a `conflictsSnapshot` of
100
+ the pre-resolve conflict state (deep-cloned — `resolveConflict()` mutates
101
+ the same conflict object in place, so a naive `Object.fromEntries()` over
102
+ the live `Map` would otherwise leave the "before" snapshot aliased to the
103
+ post-mutation object), and a `view.fileSnapshot`, then records them.
104
+
105
+ ### Not fixed (punted)
106
+
107
+ - **#43 part 5 (minor): `edit()` of a non-leaf change doesn't rebase its
108
+ descendants' file content.** An initial fix (propagate the edited
109
+ ancestor's content onto descendants when moving away from it) was
110
+ implemented and then reverted: it directly conflicts with `absorb()`'s own
111
+ job of folding a working-copy edit back into the ancestor it actually
112
+ belongs to — with the auto-rebase in place, `edit()` + `write()` +
113
+ `edit()`-away already changes the descendant before `absorb()` ever runs,
114
+ so `absorb()` finds nothing left to do (caught by the existing
115
+ `tests/integration/absorb.test.js` "should work after edit()" test).
116
+ Fixing this properly needs to account for that interaction; left for a
117
+ follow-up.
118
+
119
+ ### Testing
120
+
121
+ 1859 tests passing (12 new regression tests, matched by a new negative
122
+ control confirming that 12 of them fail against the pre-fix code — the
123
+ other 3 are supplementary correctness checks that already held, e.g.
124
+ "a descendant's own edit is never clobbered"); lint (0 errors),
125
+ format:check, typecheck, and build all still green; all 12 `examples/*.mjs`
126
+ run clean; branch coverage 90.07% (gate: 90%). New test file:
127
+ `tests/integration/issue-43-restore-reliability.test.js`.
128
+
129
+ ## 1.9.2 — 2026-09-26 — auto-snapshot mtime comparison (#41)
130
+
131
+ ### Fixed
132
+
133
+ - **`getModifiedFiles()`/`snapshot()` no longer treat every previously-tracked
134
+ file as "modified" on every call (#41).** Both compared `fs.stat().mtime`
135
+ values with `!==`. `mtime` is a `Date`, and `fs.promises.stat()` returns a
136
+ fresh `Date` instance on every call — two stats of the same, unchanged file
137
+ are never `===`/`!==`-equal even when the underlying timestamp is
138
+ identical (object identity, not value equality). Fixed by normalizing both
139
+ sides through a new `mtimeMs()` helper before comparing, and by storing
140
+ tracked mtimes as plain epoch-ms numbers going forward (numbers round-trip
141
+ through the JSON-based `Storage` persistence exactly, unlike `Date`, which
142
+ serializes to an ISO string and never comes back as a `Date` on reload).
143
+ `mtimeMs()` deliberately duck-types the Date case (checks for a `.getTime`
144
+ method) rather than using `instanceof Date`: under Jest's
145
+ `--experimental-vm-modules`, each test file's `Date` constructor can be a
146
+ different realm than the one `fs`'s internal `Stats` object was built
147
+ with, so a real `Date` from `fs.promises.stat()` can fail `instanceof
148
+ Date` — silently reintroducing the exact bug this fix is for, only inside
149
+ the test suite that's supposed to catch it.
150
+
151
+ **Real-world impact, not just a wrong return value.** Every repository
152
+ created with a Git backend wraps `graph.updateChange()` in middleware that
153
+ syncs to Git and mints a new commit (`syncChangeToGit()`). Because
154
+ `autoSnapshotWorkingCopy()` — which runs before `status()`/`describe()`/
155
+ `diff()`/`read()`/`file.*` — treated the file list as "always modified", it
156
+ called `graph.updateChange()` on essentially every one of those calls even
157
+ when nothing on disk had changed, silently minting a spurious Git commit
158
+ and changing the working-copy change's `commitId` each time. This is
159
+ exactly what forced the `{ autoSnapshot: false }` workaround added in
160
+ `tests/integration/undo-restore-residuals.test.js` for issue #37(c) (see
161
+ PR #40) — that test's own before/after `commitId` comparisons were being
162
+ contaminated by these spurious commits. That workaround has been removed
163
+ now that the underlying bug is fixed; the test passes with auto-snapshot
164
+ enabled.
165
+
166
+ **Why existing tests never caught this.** The in-memory `MockFS` fixture
167
+ used across almost this whole test suite stores `mtime` as a plain
168
+ `Date.now()` number, not a `Date` instance — numbers compare by value with
169
+ `!==`, so `MockFS`-backed tests never exercised the object-identity bug a
170
+ real filesystem's `Date`-returning `stat()` hits on every call. Added
171
+ regression tests in `tests/unit/core/working-copy.test.js` using a
172
+ `RealDateStatFS` wrapper that returns a fresh `Date` instance per `stat()`
173
+ call (mirroring real `fs` semantics) and asserting that two consecutive
174
+ `getModifiedFiles()`/`snapshot()` calls with no real file changes in
175
+ between report zero modified/added/deleted files. Verified against a
176
+ negative control: reverting just the comparison fix reproduces exactly the
177
+ "every tracked file looks modified" symptom described in #41.
178
+
179
+ ### Testing
180
+
181
+ 1839 tests passing (2 new regression tests for #41), including the
182
+ previously-`autoSnapshot: false`-gated issue #37(c) test now passing with
183
+ auto-snapshot enabled; lint (0 errors), format:check, typecheck, and build
184
+ all green; all 12 `examples/*.mjs` run clean.
185
+
186
+ ## 1.9.1 — 2026-09-26 — undo/restore/abandon/squash follow-ups
187
+
188
+ Three bug-fix follow-ups to 1.9.0's undo/abandon/squash work (#29/#30),
189
+ found by continued use of the same graph/working-copy machinery. Grouped
190
+ into one patch release because all three touch overlapping code
191
+ (`computeGraphReversal()`/`applyGraphReversal()`, the `all()` revset, and
192
+ the synthetic-empty-change pattern `new()`/`squash()` share).
193
+
194
+ ### Fixed
195
+
196
+ - **`restore()`/`undo()` now record their own `changeSnapshot`, so undoing
197
+ a restore (or undoing an undo) actually reverts something (#37a).**
198
+ 1.9.0 taught `describe()`/`squash()`/`abandon()` to snapshot the
199
+ ChangeGraph state they were about to overwrite so a later `undo()` could
200
+ put it back — but `undo()` and `operations.restore()` themselves never
201
+ did the same for THEIR OWN oplog entries. Repro: `describe("old")` →
202
+ `describe("new")` → `restore(<op before the second describe>)` (now
203
+ "old") → `undo()`. Expected: back to "new". Actual: still "old", because
204
+ the restore's own oplog entry carried no `changeSnapshot` for
205
+ `computeGraphReversal()` to find — undoing it moved the working-copy
206
+ pointer/files but left the change record exactly as the restore had
207
+ left it. Both `undo()` and `restore()` now capture ground-truth "what
208
+ am I about to overwrite" (the same way `undo()` already did for
209
+ `redo()`'s benefit, under `redoChangeSnapshot`) and record it as their
210
+ own `changeSnapshot`. Extended `redo()` the same way for symmetry, since
211
+ it shares the identical gap.
212
+ - **`log()` no longer lists changes orphaned by an undone `new()`/`squash()`
213
+ (#37b).** `undo()`/`redo()`/`operations.restore()` deliberately never
214
+ delete a change record a reverted `new()`/`squash()` created — they only
215
+ un-reference it (matching real jj's "hidden, not gone" model for
216
+ unreachable commits, and this package's own history-editing tests rely
217
+ on rebasing back onto one still working) — but `log()`'s default
218
+ `all()` revset was a flat `graph.getAll()`, so the orphan kept showing
219
+ up as an ordinary visible change forever. `all()`/`builtin_log()` now
220
+ exclude changes that are BOTH unreachable from every tracked head
221
+ (working copy, bookmarks, tags — the closest available proxy for real
222
+ jj's explicitly-maintained View heads, since this package doesn't thread
223
+ the operation log's `view.heads` through to the revset engine) AND
224
+ "discardable" (no file diff versus its own parent, and undescribed —
225
+ including this package's own `'(no description)'`/`'(no description
226
+ set)'` sentinel strings, which `new()`/`squash()` write when no message
227
+ is given). The change record itself is still resolvable directly by its
228
+ change id, matching real jj's hidden-not-gone semantics for unreachable
229
+ commits. Merge commits are conservatively never treated as "empty" by
230
+ this rule.
231
+ - **`undo()`/`restore()` now restore the ORIGINAL commit id instead of
232
+ minting a new one (#37c).** Reverting a `changeSnapshot` went through
233
+ the same `graph.updateChange()` every normal mutation uses, which fires
234
+ the git-sync middleware and creates a brand-new commit from the
235
+ *current* tree — even though the snapshot being reapplied already
236
+ carried the correct, original commit id from before the reverted
237
+ operation ran. Since undo/restore are pure reverts, not edits, this
238
+ undermined the "change id survives, commit id only changes on content
239
+ edit" story for the revert itself. `applyGraphReversal()` (used by both
240
+ `undo()` and `restore()`) and `redo()`'s equivalent now write the
241
+ snapshot verbatim via the un-wrapped graph store, bypassing the git-sync
242
+ hook entirely and restoring the original commit id byte-for-byte.
243
+ - **`abandon()` of the working-copy change now moves `@` off it (#38).**
244
+ `abandon(<changeId of @>)` correctly marked the change abandoned but
245
+ left `@` pointing at it — `status()`/`log()` kept showing the working
246
+ copy sitting on a change flagged abandoned. Real jj instead moves `@` to
247
+ a new empty change on the abandoned change's own parent(s). `abandon()`
248
+ now detects that case and calls `new({ parents: change.parents })` —
249
+ reusing `new()`'s own "create an empty change with given parents" logic
250
+ rather than duplicating it — which is exactly the workaround callers
251
+ previously had to reach for themselves.
252
+ - **`squash()` of the working-copy change no longer gives the new `@` an
253
+ empty `fileSnapshot` (#39).** 1.9.0 fixed `squash()` to actually move
254
+ the source's file content into the destination (#30), but when the
255
+ squashed source WAS the working-copy change, the new `@` squash creates
256
+ afterward was still hard-coded to `fileSnapshot: {}` instead of starting
257
+ from the destination's (now-merged) tree — so `status()`/`snapshot()`
258
+ saw every on-disk file as untracked/new until something rewrote it and
259
+ triggered a fresh auto-snapshot. The new `@` now starts as a copy of the
260
+ destination's `fileSnapshot`, matching real `jj squash` (the new
261
+ working-copy change looks identical to its new parent until you touch
262
+ something), and the working directory is synced to match too (a no-op
263
+ in the common case where source's parent already was the destination,
264
+ but necessary whenever they aren't adjacent).
265
+
266
+ ### Testing
267
+
268
+ 1837 tests passing, including 12 new regression tests added by this pass;
269
+ lint (0 errors), format:check, typecheck, and build all still green; all
270
+ 12 `examples/*.mjs` run clean. New test files:
271
+ `tests/integration/undo-restore-residuals.test.js` (#37a/b/c),
272
+ `tests/integration/abandon-working-copy.test.js` (#38),
273
+ `tests/integration/squash-working-copy-file-snapshot.test.js` (#39). Each
274
+ fix was verified against a negative control (reverting just that fix
275
+ reproduces the exact wrong behavior described in its issue) before moving
276
+ to the next.
277
+
3
278
  ## 1.9.0 — merge the scoped and unscoped version lineages
4
279
 
5
280
  No code change — a deliberate, one-time exception to this family's usual