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 +275 -0
- package/dist/index.cjs +571 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +571 -23
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/api/repository.js +380 -15
- package/src/core/revset-engine.js +140 -4
- package/src/core/working-copy.js +51 -4
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
|