isomorphic-jj 1.9.0 → 1.9.2
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 +149 -0
- package/dist/index.cjs +250 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +250 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/api/repository.js +87 -3
- package/src/core/revset-engine.js +112 -4
- package/src/core/working-copy.js +51 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,154 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.9.2 — 2026-09-26 — auto-snapshot mtime comparison (#41)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **`getModifiedFiles()`/`snapshot()` no longer treat every previously-tracked
|
|
8
|
+
file as "modified" on every call (#41).** Both compared `fs.stat().mtime`
|
|
9
|
+
values with `!==`. `mtime` is a `Date`, and `fs.promises.stat()` returns a
|
|
10
|
+
fresh `Date` instance on every call — two stats of the same, unchanged file
|
|
11
|
+
are never `===`/`!==`-equal even when the underlying timestamp is
|
|
12
|
+
identical (object identity, not value equality). Fixed by normalizing both
|
|
13
|
+
sides through a new `mtimeMs()` helper before comparing, and by storing
|
|
14
|
+
tracked mtimes as plain epoch-ms numbers going forward (numbers round-trip
|
|
15
|
+
through the JSON-based `Storage` persistence exactly, unlike `Date`, which
|
|
16
|
+
serializes to an ISO string and never comes back as a `Date` on reload).
|
|
17
|
+
`mtimeMs()` deliberately duck-types the Date case (checks for a `.getTime`
|
|
18
|
+
method) rather than using `instanceof Date`: under Jest's
|
|
19
|
+
`--experimental-vm-modules`, each test file's `Date` constructor can be a
|
|
20
|
+
different realm than the one `fs`'s internal `Stats` object was built
|
|
21
|
+
with, so a real `Date` from `fs.promises.stat()` can fail `instanceof
|
|
22
|
+
Date` — silently reintroducing the exact bug this fix is for, only inside
|
|
23
|
+
the test suite that's supposed to catch it.
|
|
24
|
+
|
|
25
|
+
**Real-world impact, not just a wrong return value.** Every repository
|
|
26
|
+
created with a Git backend wraps `graph.updateChange()` in middleware that
|
|
27
|
+
syncs to Git and mints a new commit (`syncChangeToGit()`). Because
|
|
28
|
+
`autoSnapshotWorkingCopy()` — which runs before `status()`/`describe()`/
|
|
29
|
+
`diff()`/`read()`/`file.*` — treated the file list as "always modified", it
|
|
30
|
+
called `graph.updateChange()` on essentially every one of those calls even
|
|
31
|
+
when nothing on disk had changed, silently minting a spurious Git commit
|
|
32
|
+
and changing the working-copy change's `commitId` each time. This is
|
|
33
|
+
exactly what forced the `{ autoSnapshot: false }` workaround added in
|
|
34
|
+
`tests/integration/undo-restore-residuals.test.js` for issue #37(c) (see
|
|
35
|
+
PR #40) — that test's own before/after `commitId` comparisons were being
|
|
36
|
+
contaminated by these spurious commits. That workaround has been removed
|
|
37
|
+
now that the underlying bug is fixed; the test passes with auto-snapshot
|
|
38
|
+
enabled.
|
|
39
|
+
|
|
40
|
+
**Why existing tests never caught this.** The in-memory `MockFS` fixture
|
|
41
|
+
used across almost this whole test suite stores `mtime` as a plain
|
|
42
|
+
`Date.now()` number, not a `Date` instance — numbers compare by value with
|
|
43
|
+
`!==`, so `MockFS`-backed tests never exercised the object-identity bug a
|
|
44
|
+
real filesystem's `Date`-returning `stat()` hits on every call. Added
|
|
45
|
+
regression tests in `tests/unit/core/working-copy.test.js` using a
|
|
46
|
+
`RealDateStatFS` wrapper that returns a fresh `Date` instance per `stat()`
|
|
47
|
+
call (mirroring real `fs` semantics) and asserting that two consecutive
|
|
48
|
+
`getModifiedFiles()`/`snapshot()` calls with no real file changes in
|
|
49
|
+
between report zero modified/added/deleted files. Verified against a
|
|
50
|
+
negative control: reverting just the comparison fix reproduces exactly the
|
|
51
|
+
"every tracked file looks modified" symptom described in #41.
|
|
52
|
+
|
|
53
|
+
### Testing
|
|
54
|
+
|
|
55
|
+
1839 tests passing (2 new regression tests for #41), including the
|
|
56
|
+
previously-`autoSnapshot: false`-gated issue #37(c) test now passing with
|
|
57
|
+
auto-snapshot enabled; lint (0 errors), format:check, typecheck, and build
|
|
58
|
+
all green; all 12 `examples/*.mjs` run clean.
|
|
59
|
+
|
|
60
|
+
## 1.9.1 — 2026-09-26 — undo/restore/abandon/squash follow-ups
|
|
61
|
+
|
|
62
|
+
Three bug-fix follow-ups to 1.9.0's undo/abandon/squash work (#29/#30),
|
|
63
|
+
found by continued use of the same graph/working-copy machinery. Grouped
|
|
64
|
+
into one patch release because all three touch overlapping code
|
|
65
|
+
(`computeGraphReversal()`/`applyGraphReversal()`, the `all()` revset, and
|
|
66
|
+
the synthetic-empty-change pattern `new()`/`squash()` share).
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- **`restore()`/`undo()` now record their own `changeSnapshot`, so undoing
|
|
71
|
+
a restore (or undoing an undo) actually reverts something (#37a).**
|
|
72
|
+
1.9.0 taught `describe()`/`squash()`/`abandon()` to snapshot the
|
|
73
|
+
ChangeGraph state they were about to overwrite so a later `undo()` could
|
|
74
|
+
put it back — but `undo()` and `operations.restore()` themselves never
|
|
75
|
+
did the same for THEIR OWN oplog entries. Repro: `describe("old")` →
|
|
76
|
+
`describe("new")` → `restore(<op before the second describe>)` (now
|
|
77
|
+
"old") → `undo()`. Expected: back to "new". Actual: still "old", because
|
|
78
|
+
the restore's own oplog entry carried no `changeSnapshot` for
|
|
79
|
+
`computeGraphReversal()` to find — undoing it moved the working-copy
|
|
80
|
+
pointer/files but left the change record exactly as the restore had
|
|
81
|
+
left it. Both `undo()` and `restore()` now capture ground-truth "what
|
|
82
|
+
am I about to overwrite" (the same way `undo()` already did for
|
|
83
|
+
`redo()`'s benefit, under `redoChangeSnapshot`) and record it as their
|
|
84
|
+
own `changeSnapshot`. Extended `redo()` the same way for symmetry, since
|
|
85
|
+
it shares the identical gap.
|
|
86
|
+
- **`log()` no longer lists changes orphaned by an undone `new()`/`squash()`
|
|
87
|
+
(#37b).** `undo()`/`redo()`/`operations.restore()` deliberately never
|
|
88
|
+
delete a change record a reverted `new()`/`squash()` created — they only
|
|
89
|
+
un-reference it (matching real jj's "hidden, not gone" model for
|
|
90
|
+
unreachable commits, and this package's own history-editing tests rely
|
|
91
|
+
on rebasing back onto one still working) — but `log()`'s default
|
|
92
|
+
`all()` revset was a flat `graph.getAll()`, so the orphan kept showing
|
|
93
|
+
up as an ordinary visible change forever. `all()`/`builtin_log()` now
|
|
94
|
+
exclude changes that are BOTH unreachable from every tracked head
|
|
95
|
+
(working copy, bookmarks, tags — the closest available proxy for real
|
|
96
|
+
jj's explicitly-maintained View heads, since this package doesn't thread
|
|
97
|
+
the operation log's `view.heads` through to the revset engine) AND
|
|
98
|
+
"discardable" (no file diff versus its own parent, and undescribed —
|
|
99
|
+
including this package's own `'(no description)'`/`'(no description
|
|
100
|
+
set)'` sentinel strings, which `new()`/`squash()` write when no message
|
|
101
|
+
is given). The change record itself is still resolvable directly by its
|
|
102
|
+
change id, matching real jj's hidden-not-gone semantics for unreachable
|
|
103
|
+
commits. Merge commits are conservatively never treated as "empty" by
|
|
104
|
+
this rule.
|
|
105
|
+
- **`undo()`/`restore()` now restore the ORIGINAL commit id instead of
|
|
106
|
+
minting a new one (#37c).** Reverting a `changeSnapshot` went through
|
|
107
|
+
the same `graph.updateChange()` every normal mutation uses, which fires
|
|
108
|
+
the git-sync middleware and creates a brand-new commit from the
|
|
109
|
+
*current* tree — even though the snapshot being reapplied already
|
|
110
|
+
carried the correct, original commit id from before the reverted
|
|
111
|
+
operation ran. Since undo/restore are pure reverts, not edits, this
|
|
112
|
+
undermined the "change id survives, commit id only changes on content
|
|
113
|
+
edit" story for the revert itself. `applyGraphReversal()` (used by both
|
|
114
|
+
`undo()` and `restore()`) and `redo()`'s equivalent now write the
|
|
115
|
+
snapshot verbatim via the un-wrapped graph store, bypassing the git-sync
|
|
116
|
+
hook entirely and restoring the original commit id byte-for-byte.
|
|
117
|
+
- **`abandon()` of the working-copy change now moves `@` off it (#38).**
|
|
118
|
+
`abandon(<changeId of @>)` correctly marked the change abandoned but
|
|
119
|
+
left `@` pointing at it — `status()`/`log()` kept showing the working
|
|
120
|
+
copy sitting on a change flagged abandoned. Real jj instead moves `@` to
|
|
121
|
+
a new empty change on the abandoned change's own parent(s). `abandon()`
|
|
122
|
+
now detects that case and calls `new({ parents: change.parents })` —
|
|
123
|
+
reusing `new()`'s own "create an empty change with given parents" logic
|
|
124
|
+
rather than duplicating it — which is exactly the workaround callers
|
|
125
|
+
previously had to reach for themselves.
|
|
126
|
+
- **`squash()` of the working-copy change no longer gives the new `@` an
|
|
127
|
+
empty `fileSnapshot` (#39).** 1.9.0 fixed `squash()` to actually move
|
|
128
|
+
the source's file content into the destination (#30), but when the
|
|
129
|
+
squashed source WAS the working-copy change, the new `@` squash creates
|
|
130
|
+
afterward was still hard-coded to `fileSnapshot: {}` instead of starting
|
|
131
|
+
from the destination's (now-merged) tree — so `status()`/`snapshot()`
|
|
132
|
+
saw every on-disk file as untracked/new until something rewrote it and
|
|
133
|
+
triggered a fresh auto-snapshot. The new `@` now starts as a copy of the
|
|
134
|
+
destination's `fileSnapshot`, matching real `jj squash` (the new
|
|
135
|
+
working-copy change looks identical to its new parent until you touch
|
|
136
|
+
something), and the working directory is synced to match too (a no-op
|
|
137
|
+
in the common case where source's parent already was the destination,
|
|
138
|
+
but necessary whenever they aren't adjacent).
|
|
139
|
+
|
|
140
|
+
### Testing
|
|
141
|
+
|
|
142
|
+
1837 tests passing, including 12 new regression tests added by this pass;
|
|
143
|
+
lint (0 errors), format:check, typecheck, and build all still green; all
|
|
144
|
+
12 `examples/*.mjs` run clean. New test files:
|
|
145
|
+
`tests/integration/undo-restore-residuals.test.js` (#37a/b/c),
|
|
146
|
+
`tests/integration/abandon-working-copy.test.js` (#38),
|
|
147
|
+
`tests/integration/squash-working-copy-file-snapshot.test.js` (#39). Each
|
|
148
|
+
fix was verified against a negative control (reverting just that fix
|
|
149
|
+
reproduces the exact wrong behavior described in its issue) before moving
|
|
150
|
+
to the next.
|
|
151
|
+
|
|
3
152
|
## 1.9.0 — merge the scoped and unscoped version lineages
|
|
4
153
|
|
|
5
154
|
No code change — a deliberate, one-time exception to this family's usual
|
package/dist/index.cjs
CHANGED
|
@@ -991,6 +991,44 @@ class ChangeGraph {
|
|
|
991
991
|
*/
|
|
992
992
|
|
|
993
993
|
|
|
994
|
+
/**
|
|
995
|
+
* Normalize an mtime value to a millisecond epoch number so two stat()
|
|
996
|
+
* results (or a stat() result and a stored value) can be compared by VALUE
|
|
997
|
+
* instead of by object identity (see issue #41).
|
|
998
|
+
*
|
|
999
|
+
* `fs.promises.stat()` returns a fresh `Date` instance on every call, so two
|
|
1000
|
+
* separate stats of the same unchanged file are never `===`/`!==`-equal even
|
|
1001
|
+
* when the underlying timestamp is identical. Additionally, once a tracked
|
|
1002
|
+
* mtime has been through a `JSON.stringify`/`JSON.parse` round-trip (Storage
|
|
1003
|
+
* persists working-copy state as JSON), a `Date` becomes an ISO string, not a
|
|
1004
|
+
* `Date` — so the stored side of the comparison may be a `Date`, a plain
|
|
1005
|
+
* epoch-ms number (this module now stores numbers going forward), or an ISO
|
|
1006
|
+
* string (older/reloaded-from-disk state). This normalizes all three to a
|
|
1007
|
+
* comparable number.
|
|
1008
|
+
*
|
|
1009
|
+
* Deliberately duck-types the Date case (`typeof value.getTime ===
|
|
1010
|
+
* 'function'`) instead of `value instanceof Date`: Node's built-in `fs`
|
|
1011
|
+
* module constructs `Stats.mtime` using its own realm's `Date`, which is not
|
|
1012
|
+
* always the same `Date` constructor as this module's under test runners
|
|
1013
|
+
* that sandbox each test file in its own VM context (observed under Jest
|
|
1014
|
+
* with `--experimental-vm-modules`) — there, a real `Date` instance from
|
|
1015
|
+
* `fs.promises.stat()` fails `instanceof Date` here even though it's a
|
|
1016
|
+
* genuine Date. Duck-typing survives that realm mismatch.
|
|
1017
|
+
*
|
|
1018
|
+
* @param {Date|number|string|undefined} value
|
|
1019
|
+
* @returns {number}
|
|
1020
|
+
*/
|
|
1021
|
+
function mtimeMs(value) {
|
|
1022
|
+
if (value === null || value === undefined) return NaN;
|
|
1023
|
+
if (typeof value === 'number') return value;
|
|
1024
|
+
if (typeof value === 'string') return new Date(value).getTime();
|
|
1025
|
+
// Per the type contract above, anything left is Date-like -- duck-typed
|
|
1026
|
+
// (see the comment above) rather than `instanceof Date`, so no runtime
|
|
1027
|
+
// check is needed here either; a value outside this contract is a caller
|
|
1028
|
+
// bug, not something to silently coerce.
|
|
1029
|
+
return /** @type {any} */ (value).getTime();
|
|
1030
|
+
}
|
|
1031
|
+
|
|
994
1032
|
class WorkingCopy {
|
|
995
1033
|
/**
|
|
996
1034
|
* @param {Storage} storage - Storage manager instance
|
|
@@ -1162,8 +1200,10 @@ class WorkingCopy {
|
|
|
1162
1200
|
try {
|
|
1163
1201
|
const stats = await this.fs.promises.stat(fullPath);
|
|
1164
1202
|
|
|
1165
|
-
// Fast path: check mtime and size
|
|
1166
|
-
|
|
1203
|
+
// Fast path: check mtime and size. Compare by VALUE (see mtimeMs
|
|
1204
|
+
// above) — comparing the raw stat()/stored values with !== is
|
|
1205
|
+
// always "different" even when nothing changed (issue #41).
|
|
1206
|
+
if (mtimeMs(stats.mtime) !== mtimeMs(tracked.mtime) || stats.size !== tracked.size) {
|
|
1167
1207
|
modified.push(path);
|
|
1168
1208
|
}
|
|
1169
1209
|
} catch (error) {
|
|
@@ -1274,12 +1314,19 @@ class WorkingCopy {
|
|
|
1274
1314
|
}
|
|
1275
1315
|
|
|
1276
1316
|
const prev = this.state.fileStates[rel];
|
|
1277
|
-
|
|
1317
|
+
// Store mtime as a plain epoch-ms number (not the raw stat() value):
|
|
1318
|
+
// numbers round-trip through JSON exactly, so state reloaded from disk
|
|
1319
|
+
// later compares identically to state still cached in-memory. Goes
|
|
1320
|
+
// through mtimeMs() rather than assuming `.getTime()` exists directly —
|
|
1321
|
+
// some fs implementations (e.g. the in-memory mock used in tests)
|
|
1322
|
+
// return mtime as a plain epoch-ms number already, not a Date (see
|
|
1323
|
+
// mtimeMs above and issue #41).
|
|
1324
|
+
const next = { mtime: mtimeMs(stats.mtime), size: stats.size, mode: stats.mode };
|
|
1278
1325
|
|
|
1279
1326
|
if (!prev) {
|
|
1280
1327
|
this.state.fileStates[rel] = next;
|
|
1281
1328
|
added.push(rel);
|
|
1282
|
-
} else if (
|
|
1329
|
+
} else if (mtimeMs(stats.mtime) !== mtimeMs(prev.mtime) || prev.size !== stats.size) {
|
|
1283
1330
|
this.state.fileStates[rel] = next;
|
|
1284
1331
|
modified.push(rel);
|
|
1285
1332
|
}
|
|
@@ -2668,9 +2715,12 @@ class RevsetEngine {
|
|
|
2668
2715
|
const unquote = (/** @type {string} */ s) => s.trim().replace(/^['"]|['"]$/g, '');
|
|
2669
2716
|
|
|
2670
2717
|
switch (name) {
|
|
2671
|
-
case 'all':
|
|
2718
|
+
case 'all': {
|
|
2672
2719
|
await this.graph.load();
|
|
2673
|
-
|
|
2720
|
+
const allChanges = this.graph.getAll();
|
|
2721
|
+
const hidden = await this._computeHiddenOrphans(allChanges);
|
|
2722
|
+
return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
|
|
2723
|
+
}
|
|
2674
2724
|
|
|
2675
2725
|
// `builtin_log()` (jj v0.44) is the revset the built-in `jj log` uses
|
|
2676
2726
|
// when no user override is configured. isomorphic-jj's `log()` already
|
|
@@ -2678,9 +2728,12 @@ class RevsetEngine {
|
|
|
2678
2728
|
// `builtin_log()` is a pure alias for that same default — this lets
|
|
2679
2729
|
// callers compose it (e.g. `builtin_log() & mine()`) instead of
|
|
2680
2730
|
// duplicating `all()`.
|
|
2681
|
-
case 'builtin_log':
|
|
2731
|
+
case 'builtin_log': {
|
|
2682
2732
|
await this.graph.load();
|
|
2683
|
-
|
|
2733
|
+
const allChanges = this.graph.getAll();
|
|
2734
|
+
const hidden = await this._computeHiddenOrphans(allChanges);
|
|
2735
|
+
return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
|
|
2736
|
+
}
|
|
2684
2737
|
|
|
2685
2738
|
case 'none':
|
|
2686
2739
|
return [];
|
|
@@ -3141,6 +3194,108 @@ class RevsetEngine {
|
|
|
3141
3194
|
return ancestors;
|
|
3142
3195
|
}
|
|
3143
3196
|
|
|
3197
|
+
/**
|
|
3198
|
+
* Change IDs that `all()`/`builtin_log()` (and therefore the default
|
|
3199
|
+
* `jj.log()`) should exclude — issue #37(b).
|
|
3200
|
+
*
|
|
3201
|
+
* undo()/redo()/operations.restore() deliberately don't delete a change
|
|
3202
|
+
* record for a `new()`/`squash()` that gets reverted — they only un-ref
|
|
3203
|
+
* it (restore the working-copy pointer/heads that made it reachable),
|
|
3204
|
+
* matching real jj where an unreachable change stays resolvable by its
|
|
3205
|
+
* change id (hidden, not gone) until explicitly gc'd — see
|
|
3206
|
+
* computeGraphReversal's doc comment in repository.js. But that means
|
|
3207
|
+
* the orphaned change record lingers in the graph, and before this fix
|
|
3208
|
+
* `all()` (a flat `graph.getAll()`) surfaced it in `log()` right
|
|
3209
|
+
* alongside everything still actually reachable.
|
|
3210
|
+
*
|
|
3211
|
+
* Real jj hides a commit from the default log when it is BOTH:
|
|
3212
|
+
* - unreachable from any visible head (not itself a head, and not an
|
|
3213
|
+
* ancestor of one), AND
|
|
3214
|
+
* - "discardable": empty (no file diff versus its own parent) and
|
|
3215
|
+
* undescribed.
|
|
3216
|
+
* Merge commits (2+ parents) are conservatively never treated as
|
|
3217
|
+
* "empty" here — real jj's diff-emptiness rule for merges is more
|
|
3218
|
+
* involved and isn't needed by anything this package creates today.
|
|
3219
|
+
*
|
|
3220
|
+
* "Visible head" here deliberately does NOT mean "any change with no
|
|
3221
|
+
* children" (that's what the `visible_heads()` revset function computes,
|
|
3222
|
+
* structurally, from the raw graph) — an orphan `new()`/`squash()`
|
|
3223
|
+
* change that undo() un-referenced has no children EITHER, so it would
|
|
3224
|
+
* trivially count as its own head under that definition and never be
|
|
3225
|
+
* excludable. Real jj instead tracks an explicit, maintained set of
|
|
3226
|
+
* heads in its View (updated as operations run — abandoning/squashing a
|
|
3227
|
+
* change removes it, rebasing children onto a new parent removes the
|
|
3228
|
+
* old parent, etc.). This package doesn't thread the operation log's
|
|
3229
|
+
* `view.heads` through to the revset engine, but the working-copy
|
|
3230
|
+
* pointer plus every bookmark/tag target is the closest available
|
|
3231
|
+
* proxy for "what's actually still in play" — anything only reachable
|
|
3232
|
+
* from those, not from every dangling graph leaf.
|
|
3233
|
+
*
|
|
3234
|
+
* @param {any[]} allChanges - `graph.getAll()`
|
|
3235
|
+
* @returns {Promise<Set<string>>} change IDs to exclude
|
|
3236
|
+
*/
|
|
3237
|
+
async _computeHiddenOrphans(allChanges) {
|
|
3238
|
+
const trackedHeads = new Set();
|
|
3239
|
+
try {
|
|
3240
|
+
const wcId = this.workingCopy && this.workingCopy.getCurrentChangeId();
|
|
3241
|
+
if (wcId) trackedHeads.add(wcId);
|
|
3242
|
+
} catch {
|
|
3243
|
+
// working copy not loaded/available — fine, just no wc-based root.
|
|
3244
|
+
}
|
|
3245
|
+
if (this.bookmarkStore) {
|
|
3246
|
+
await this.bookmarkStore.load();
|
|
3247
|
+
for (const bookmark of await this.bookmarkStore.list()) {
|
|
3248
|
+
if (bookmark && bookmark.changeId) trackedHeads.add(bookmark.changeId);
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
if (this.tagStore) {
|
|
3252
|
+
for (const tag of await this.tagStore.list()) {
|
|
3253
|
+
if (tag && tag.changeId) trackedHeads.add(tag.changeId);
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
const reachable = new Set();
|
|
3258
|
+
for (const headId of trackedHeads) {
|
|
3259
|
+
for (const id of await this.getAncestors(headId)) {
|
|
3260
|
+
reachable.add(id);
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
// Sentinel "no message given" descriptions this package itself writes
|
|
3265
|
+
// (new()'s '(no description)', squash()'s post-squash working-copy
|
|
3266
|
+
// change '(no description set)') are, for visibility purposes, just
|
|
3267
|
+
// as undescribed as a genuinely empty/missing description field.
|
|
3268
|
+
const UNDESCRIBED = new Set([undefined, null, '', '(no description)', '(no description set)']);
|
|
3269
|
+
|
|
3270
|
+
const byId = new Map(allChanges.map((c) => [c.changeId, c]));
|
|
3271
|
+
const isEmpty = (/** @type {any} */ change) => {
|
|
3272
|
+
const parents = change.parents || [];
|
|
3273
|
+
if (parents.length === 0) {
|
|
3274
|
+
return !change.fileSnapshot || Object.keys(change.fileSnapshot).length === 0;
|
|
3275
|
+
}
|
|
3276
|
+
if (parents.length > 1) return false; // merges: not handled by this rule
|
|
3277
|
+
const parent = byId.get(parents[0]);
|
|
3278
|
+
const parentSnapshot = (parent && parent.fileSnapshot) || {};
|
|
3279
|
+
const ownSnapshot = change.fileSnapshot || {};
|
|
3280
|
+
const parentKeys = Object.keys(parentSnapshot);
|
|
3281
|
+
const ownKeys = Object.keys(ownSnapshot);
|
|
3282
|
+
if (parentKeys.length !== ownKeys.length) return false;
|
|
3283
|
+
return parentKeys.every((k) => parentSnapshot[k] === ownSnapshot[k]);
|
|
3284
|
+
};
|
|
3285
|
+
|
|
3286
|
+
const hidden = new Set();
|
|
3287
|
+
for (const change of allChanges) {
|
|
3288
|
+
if (
|
|
3289
|
+
!reachable.has(change.changeId) &&
|
|
3290
|
+
isEmpty(change) &&
|
|
3291
|
+
UNDESCRIBED.has(change.description)
|
|
3292
|
+
) {
|
|
3293
|
+
hidden.add(change.changeId);
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
return hidden;
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3144
3299
|
/**
|
|
3145
3300
|
* Resolve a revset argument to a set of change IDs (v1.5).
|
|
3146
3301
|
*
|
|
@@ -7899,7 +8054,16 @@ async function createJJ(options) {
|
|
|
7899
8054
|
await graph.load();
|
|
7900
8055
|
for (const [changeId, snapshot] of Object.entries(reversal.changeSnapshotToApply)) {
|
|
7901
8056
|
if (await graph.getChange(changeId)) {
|
|
7902
|
-
|
|
8057
|
+
// Bypass the git-sync middleware (baseGraph, not the wrapped
|
|
8058
|
+
// `graph`): undo/restore are pure reverts, and `snapshot` already
|
|
8059
|
+
// carries the change's ORIGINAL commitId from before the reverted
|
|
8060
|
+
// operation(s) ran. Going through graph.updateChange() would fire
|
|
8061
|
+
// onUpdateChange -> syncChangeToGit(), which mints a brand-new
|
|
8062
|
+
// commit (and thus a new commitId) from the CURRENT tree instead
|
|
8063
|
+
// of restoring the one that was actually there — see issue #37(c):
|
|
8064
|
+
// "undo/restore give reverted commits new commit IDs". Writing the
|
|
8065
|
+
// snapshot verbatim via baseGraph puts the original commitId back.
|
|
8066
|
+
await baseGraph.updateChange(snapshot);
|
|
7903
8067
|
}
|
|
7904
8068
|
}
|
|
7905
8069
|
|
|
@@ -10037,6 +10201,16 @@ async function createJJ(options) {
|
|
|
10037
10201
|
// which operation it targeted so a following undo() can walk one
|
|
10038
10202
|
// step further back (resolveUndoTarget) instead of toggling, and
|
|
10039
10203
|
// carrying exactly what redo() needs to re-apply (captured above).
|
|
10204
|
+
//
|
|
10205
|
+
// Also record `changeSnapshot: redoChangeSnapshot` — the SAME
|
|
10206
|
+
// ground-truth-before-this-op values, but under the field name
|
|
10207
|
+
// computeGraphReversal() actually scans for. Without this, undo()
|
|
10208
|
+
// itself was the one mutating operation in the whole API that never
|
|
10209
|
+
// recorded its own changeSnapshot, so a LATER undo() targeting THIS
|
|
10210
|
+
// undo (to step back past it) had nothing to restore: it would
|
|
10211
|
+
// repoint the working copy and files, but leave whatever this
|
|
10212
|
+
// undo() overwrote (e.g. a change's description) exactly as this
|
|
10213
|
+
// undo() left it. See issue #37(a).
|
|
10040
10214
|
await oplog.recordOperation({
|
|
10041
10215
|
timestamp: new Date().toISOString(),
|
|
10042
10216
|
user: await getUserOplogInfo(),
|
|
@@ -10044,6 +10218,7 @@ async function createJJ(options) {
|
|
|
10044
10218
|
eventType: 'undo',
|
|
10045
10219
|
targetOpId: targetOp.id,
|
|
10046
10220
|
undoneOpId: targetOp.id, // kept for API/type compatibility
|
|
10221
|
+
changeSnapshot: redoChangeSnapshot,
|
|
10047
10222
|
redoChangeSnapshot,
|
|
10048
10223
|
redoFileSnapshot,
|
|
10049
10224
|
redoConflictsSnapshot,
|
|
@@ -10121,9 +10296,25 @@ async function createJJ(options) {
|
|
|
10121
10296
|
// by scanning forward. undo() captured it directly for exactly this
|
|
10122
10297
|
// reason (see its own comment).
|
|
10123
10298
|
await graph.load();
|
|
10299
|
+
|
|
10300
|
+
// Capture ground truth right before overwriting, same as undo()
|
|
10301
|
+
// does for its own changeSnapshot (issue #37a) — lets a later
|
|
10302
|
+
// undo() targeting THIS redo op restore what redo() is about to
|
|
10303
|
+
// clobber, instead of finding no changeSnapshot at all.
|
|
10304
|
+
/** @type {Record<string, any>} */
|
|
10305
|
+
const undoChangeSnapshot = {};
|
|
10306
|
+
for (const changeId of Object.keys(undoOp.redoChangeSnapshot || {})) {
|
|
10307
|
+
const current = await graph.getChange(changeId);
|
|
10308
|
+
if (current) undoChangeSnapshot[changeId] = current;
|
|
10309
|
+
}
|
|
10310
|
+
|
|
10124
10311
|
for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
|
|
10125
10312
|
if (await graph.getChange(changeId)) {
|
|
10126
|
-
|
|
10313
|
+
// Bypass the git-sync middleware — see the matching comment in
|
|
10314
|
+
// applyGraphReversal(). `snapshot` already carries the change's
|
|
10315
|
+
// correct commitId from before the undo it's reversing ran
|
|
10316
|
+
// (issue #37c applies to redo() too).
|
|
10317
|
+
await baseGraph.updateChange(snapshot);
|
|
10127
10318
|
}
|
|
10128
10319
|
}
|
|
10129
10320
|
if (undoOp.redoFileSnapshot !== undefined) {
|
|
@@ -10144,6 +10335,7 @@ async function createJJ(options) {
|
|
|
10144
10335
|
description: 'redo operation',
|
|
10145
10336
|
eventType: 'redo',
|
|
10146
10337
|
redoOf: undoOp.id,
|
|
10338
|
+
changeSnapshot: undoChangeSnapshot,
|
|
10147
10339
|
parents: [],
|
|
10148
10340
|
view: redoneView,
|
|
10149
10341
|
});
|
|
@@ -10434,6 +10626,23 @@ async function createJJ(options) {
|
|
|
10434
10626
|
// Revert the change graph, working-copy files, and conflict state
|
|
10435
10627
|
// to exactly what they were right after targetOp ran.
|
|
10436
10628
|
const reversal = computeGraphReversal(ops, targetOp);
|
|
10629
|
+
|
|
10630
|
+
// Capture ground truth for every changeId this restore is about to
|
|
10631
|
+
// overwrite, BEFORE applying the reversal — exactly like undo()
|
|
10632
|
+
// does for its own changeSnapshot. Without this, restore()'s own
|
|
10633
|
+
// oplog entry carried no changeSnapshot at all, so undoing the
|
|
10634
|
+
// restore itself had nothing to put back: a later undo() would
|
|
10635
|
+
// repoint the working copy and files, but leave whatever the
|
|
10636
|
+
// restore had just overwritten (e.g. a change's description)
|
|
10637
|
+
// exactly as the restore left it. See issue #37(a).
|
|
10638
|
+
await graph.load();
|
|
10639
|
+
/** @type {Record<string, any>} */
|
|
10640
|
+
const changeSnapshot = {};
|
|
10641
|
+
for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
|
|
10642
|
+
const current = await graph.getChange(changeId);
|
|
10643
|
+
if (current) changeSnapshot[changeId] = current;
|
|
10644
|
+
}
|
|
10645
|
+
|
|
10437
10646
|
await applyGraphReversal(reversal);
|
|
10438
10647
|
|
|
10439
10648
|
// Record this restoration as a new operation
|
|
@@ -10442,6 +10651,7 @@ async function createJJ(options) {
|
|
|
10442
10651
|
user: await getUserOplogInfo(),
|
|
10443
10652
|
description: `restore to operation ${args.operation}`,
|
|
10444
10653
|
parents: [],
|
|
10654
|
+
changeSnapshot,
|
|
10445
10655
|
view: targetOp.view,
|
|
10446
10656
|
});
|
|
10447
10657
|
|
|
@@ -10801,10 +11011,22 @@ async function createJJ(options) {
|
|
|
10801
11011
|
},
|
|
10802
11012
|
description: '(no description set)',
|
|
10803
11013
|
timestamp: new Date().toISOString(),
|
|
10804
|
-
|
|
11014
|
+
// Start from the DESTINATION's tree (which now includes source's
|
|
11015
|
+
// folded-in changes, set above), not an empty object — matches
|
|
11016
|
+
// real `jj squash`, where the new working-copy change looks
|
|
11017
|
+
// identical to its new parent until you touch something. Issue
|
|
11018
|
+
// #39: this used to hard-code `{}`, so status()/snapshot() saw
|
|
11019
|
+
// every on-disk file as untracked/new until something re-wrote
|
|
11020
|
+
// it and triggered a fresh auto-snapshot.
|
|
11021
|
+
fileSnapshot: { ...destChange.fileSnapshot },
|
|
10805
11022
|
};
|
|
10806
11023
|
|
|
10807
11024
|
await graph.addChange(newChange); // Middleware will sync to Git
|
|
11025
|
+
// Sync the working directory to match (issue #30's fix for
|
|
11026
|
+
// new()/edit(); squash()'s own synthetic post-squash change never
|
|
11027
|
+
// did this) — a no-op when source's parent was already dest (the
|
|
11028
|
+
// common case), but necessary whenever source/dest aren't adjacent.
|
|
11029
|
+
await syncWorkingCopyFiles(newChange.fileSnapshot);
|
|
10808
11030
|
await workingCopy.setCurrentChange(newChangeId);
|
|
10809
11031
|
newWorkingCopyId = newChangeId;
|
|
10810
11032
|
}
|
|
@@ -10886,6 +11108,12 @@ async function createJJ(options) {
|
|
|
10886
11108
|
throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
|
|
10887
11109
|
}
|
|
10888
11110
|
|
|
11111
|
+
// Captured BEFORE anything mutates — see issue #38: abandoning the
|
|
11112
|
+
// change @ currently points at must move @ off it (real jj checks
|
|
11113
|
+
// out a fresh empty change on the abandoned change's own parent(s));
|
|
11114
|
+
// abandoning any OTHER change must leave @ exactly where it was.
|
|
11115
|
+
const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
|
|
11116
|
+
|
|
10889
11117
|
// Dispatch change:abandoning event (preventable)
|
|
10890
11118
|
await dispatchEventAsync(jj, 'change:abandoning', {
|
|
10891
11119
|
operation: 'abandon',
|
|
@@ -10938,6 +11166,17 @@ async function createJJ(options) {
|
|
|
10938
11166
|
{ cancelable: false }
|
|
10939
11167
|
);
|
|
10940
11168
|
|
|
11169
|
+
// If @ was pointing at the change we just abandoned, it can't stay
|
|
11170
|
+
// there — real jj moves @ to a new empty change on the abandoned
|
|
11171
|
+
// change's own parent(s) (issue #38). Reuse new()'s own "create an
|
|
11172
|
+
// empty change with given parents" logic rather than duplicating it
|
|
11173
|
+
// — this is exactly the workaround callers had to reach for
|
|
11174
|
+
// themselves (`new({ parents: [parentOfAt] })` before abandoning).
|
|
11175
|
+
if (wasWorkingCopy) {
|
|
11176
|
+
const newWorkingCopyChange = await jj.new({ parents: change.parents || [] });
|
|
11177
|
+
return { ...change, newWorkingCopyChangeId: newWorkingCopyChange.changeId };
|
|
11178
|
+
}
|
|
11179
|
+
|
|
10941
11180
|
return change;
|
|
10942
11181
|
},
|
|
10943
11182
|
|