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 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
- if (stats.mtime !== tracked.mtime || stats.size !== tracked.size) {
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
- const next = { mtime: stats.mtime, size: stats.size, mode: stats.mode };
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 (prev.mtime !== stats.mtime || prev.size !== stats.size) {
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
- return this.graph.getAll().map((c) => c.changeId);
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
- return this.graph.getAll().map((c) => c.changeId);
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
- await graph.updateChange(snapshot);
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
- await graph.updateChange(snapshot);
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
- fileSnapshot: {},
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