isomorphic-jj 1.12.1 → 1.14.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,100 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.14.0 — 2026-10-09 — describe()/metaedit() honor changeId; operations.restore() restores the view
4
+
5
+ Fixes [#55](https://github.com/johnhenry/isomorphic-jj/issues/55) and
6
+ [#56](https://github.com/johnhenry/isomorphic-jj/issues/56). Both change
7
+ observable behavior, hence a minor bump.
8
+
9
+ ### Fixed
10
+
11
+ - **`describe()` and `metaedit()` ignored `changeId`.** Both only read the
12
+ older `revision` option (`metaedit` also `change`), so
13
+ `describe({ changeId, message })` silently rewrote the working-copy change
14
+ instead of the named one -- the documented option (API.md) did nothing and
15
+ `DescribeArgs` in `types.d.ts` didn't list it. Both now accept `changeId`
16
+ (alongside `revision`), rewrite exactly that change, leave the working
17
+ copy's description alone, and keep change IDs stable. `DescribeArgs` gains
18
+ `changeId` and `revision`. Regression tests:
19
+ `tests/integration/describe-change-id.test.js`.
20
+ - **`operations.restore()` left changes created after the target operation
21
+ visible.** The restore operation recorded the right view (heads, working
22
+ copy), but the revset layer only hid orphaned changes that were empty and
23
+ undescribed, so a described change stayed in `log()`, `heads(all())` and
24
+ `visible_heads()`. As in jj, restoring now restores the *view*: every
25
+ change created after the target operation and unreachable from the
26
+ restored working copy, bookmarks and tags is hidden (not deleted -- it
27
+ stays in the graph and resolvable by change ID). The hidden set is stored
28
+ in the operation's `view.hiddenChanges`, so it persists across later
29
+ operations, `undo()`/`redo()` of the restore bring the changes back, and
30
+ they reappear if the working copy/bookmarks make them reachable again
31
+ (e.g. `edit({ changeId })`). **Behavior change:** `log()` after a restore
32
+ no longer lists such changes. Regression tests:
33
+ `tests/integration/op-restore-view.test.js`.
34
+
35
+ ## 1.13.0 — 2026-10-05 — Write-path fixes: converge on a fresh instance, new files lost on edit(), rebase content
36
+
37
+ Fixed in [PR #52](https://github.com/johnhenry/isomorphic-jj/pull/52).
38
+
39
+ Three bugs found while wiring a consumer (JJHub's isomorphic jj engine)
40
+ to write through this library, constructing one fresh `createJJ()`
41
+ instance per call so no read is ever served from a stale cache. Two of
42
+ them change observable behavior, hence a minor bump.
43
+
44
+ ### Fixed
45
+
46
+ - **`converge()` threw `WORKING_COPY_NOT_LOADED` on a fresh instance.**
47
+ Both outcomes record the working copy in their operation, but
48
+ `converge()` loaded only the graph and user config — so it only worked
49
+ after some earlier call on the same instance had loaded the working
50
+ copy. It now loads it itself. Regression tests: "on a fresh instance"
51
+ in `tests/integration/converge.test.js`.
52
+ - **A new file `write()` created was deleted by the next `edit()` away.**
53
+ `write()` tracks a file without recording it in the working-copy
54
+ change (the next command commits it, as in jj), but `edit()` first
55
+ deleted every tracked file missing from the change being left, as
56
+ cross-branch "pollution" from an earlier checkout. Since issue #30 every
57
+ switch fully syncs the working directory to its target, so nothing
58
+ leaks that way any more; what the cleanup actually removed was every new
59
+ file — top-level or nested — written into that change. Edits to files
60
+ already recorded survived, which made it look path-dependent. `edit()`
61
+ now commits every tracked file into the change it leaves, like
62
+ `new()`/`describe()`. **Behavior change:** a tracked file on disk is
63
+ now always the checked-out change's content when `edit()` leaves it;
64
+ code that removed a file from a change by editing the graph alone (not
65
+ `remove()`) must now remove it from disk too —
66
+ `tests/integration/issue-43-restore-reliability.test.js`'s
67
+ descendant-deletion test did exactly that and now uses `remove()`.
68
+ Regression tests: "write() then edit() away" in
69
+ `tests/integration/file-operations.test.js`.
70
+ - **`moveChange()`/`rebase()` kept the change's own tree verbatim.** The
71
+ three-way conflict check from issue #31 ran, but the rebased content
72
+ was never derived from it: the change kept every file only its OLD
73
+ parent had and never picked up what only the NEW parent changed. Its
74
+ content is now jj's rebase, path by path — the new parent's value
75
+ wherever the change didn't touch a path relative to its old parent,
76
+ the change's own value wherever it did (a path both sides changed
77
+ differently is the conflict #31 already reports, with markers). When
78
+ the moved change is checked out, its files on disk are synced to the
79
+ result. **Behavior change:** a rebased change's `fileSnapshot` now
80
+ differs from before for any path its old and new parents disagree on.
81
+ Regression tests: "content of the rebased change" in
82
+ `tests/integration/rebase-conflict-detection.test.js`.
83
+
84
+ ### Not changed
85
+
86
+ - `moveChange()` still re-derives only the moved change's own content,
87
+ not that of its descendants (they follow it structurally, by parent
88
+ change id, but keep their trees) — see the README's status section.
89
+ - Conflict detection still compares whole files, not lines: two sides
90
+ changing different lines of one file is a conflict.
91
+
92
+ ### Testing
93
+
94
+ 1882 tests passing, 0 skipped; lint, format:check, typecheck, and all 12
95
+ `examples/*.mjs` green; coverage 97.39% statements / 90.13% branches
96
+ (gate 90%). Each new regression test fails against 1.12.1's source.
97
+
3
98
  ## 1.12.1 — 2026-09-27 — Conflict type shape + README status refresh
4
99
 
5
100
  A cross-library documentation/typing audit flagged four things to verify
package/README.md CHANGED
@@ -1020,8 +1020,8 @@ repo/
1020
1020
 
1021
1021
  ## Project Status
1022
1022
 
1023
- **Current Version**: 1.12.0 (`@johnhenry/isomorphic-jj`; same library/API lineage as the old `isomorphic-jj`, which reached v1.8.0 before the rename — see the provenance note at the top of this file, and the CHANGELOG's `1.9.0` entry for how the scoped/unscoped version lineages were merged back together)
1024
- **Test Coverage**: 1874 tests passing, ~97% statements / 90% branches
1023
+ **Current Version**: 1.14.0 (`@johnhenry/isomorphic-jj`; same library/API lineage as the old `isomorphic-jj`, which reached v1.8.0 before the rename — see the provenance note at the top of this file, and the CHANGELOG's `1.9.0` entry for how the scoped/unscoped version lineages were merged back together)
1024
+ **Test Coverage**: 1882 tests passing, ~97% statements / 90% branches
1025
1025
  **JJ CLI parity**: tracks Jujutsu through v0.45.1
1026
1026
  **Status**: Actively developed; broad JJ CLI semantic parity, used in the examples/apps in this repo and in [JJHub](#family). Not yet 1.0 in the API-stability sense (the version number is past 1.0 only because merging the scoped/unscoped lineages forced it above the unscoped side's prior ceiling, not because the API is frozen) — API surface can still shift; pin a version and read the CHANGELOG before upgrading.
1027
1027
 
@@ -1038,6 +1038,8 @@ repo/
1038
1038
  - ✅ 0.3.0: Parity refresh tracking jj through **v0.45.1** — a working `/browser` entry (async `createBrowserFS()`, bundle-safe protobuf schema loading, no stray Node-only imports), `undo()`/`operations.restore()` reverting graph rewrites with real progressive undo/redo, `squash()`/`abandon()`/`edit()`/`new()` fully syncing content instead of only metadata, `rebase()` performing a real three-way merge and recording conflicts, and the new `converge()` command (jj v0.45.0) to auto-resolve divergent commits. See [CHANGELOG.md](./CHANGELOG.md).
1039
1039
  - ✅ 0.4.0: `converge()` generalized from pairwise to N-way — any number of divergent copies of one change id resolve in a single call, matching how jj's own underlying engine already treats divergence internally (issue #32). See [CHANGELOG.md](./CHANGELOG.md).
1040
1040
  - ✅ 1.9.0: Scoped (`@johnhenry/isomorphic-jj`) and unscoped (`isomorphic-jj`) version lineages merged into one shared version going forward, with both names dual-published on every release.
1041
+ - ✅ 1.14.0: `describe()`/`metaedit()` honor `changeId`; `operations.restore()` restores the view (changes created after the target op are hidden from `log()`). See [CHANGELOG.md](./CHANGELOG.md).
1042
+ - ✅ 1.13.0: Write-path fixes — `converge()` works on a fresh instance, `edit()` no longer deletes new files `write()` created in the change it leaves, and `moveChange()`/`rebase()` derives the rebased change's content (new parent + the change's own edits) instead of keeping its tree verbatim. Not done: a rebase re-derives only the moved change's content, not its descendants', and conflicts are detected per whole file, not per line. See [CHANGELOG.md](./CHANGELOG.md).
1041
1043
  - ✅ 1.9.1 – 1.12.0: Operation-log reliability pass — `undo()`/`operations.restore()` correctness across write/snapshot/squash/rebase/abandon/edit, `log()` visibility fixes, and recording gaps in descendant rebases and conflict-resolution ops (issues #37–#39, #41, #43, #45, #48). See [CHANGELOG.md](./CHANGELOG.md) for the full per-fix breakdown.
1042
1044
 
1043
1045
  See [ROADMAP.md](./ROADMAP.md) for detailed plans, and [CHANGELOG.md](./CHANGELOG.md) for the release history.
package/dist/index.cjs CHANGED
@@ -1517,6 +1517,24 @@ class OperationLog {
1517
1517
  operation.parents = operation.parents || [];
1518
1518
  }
1519
1519
 
1520
+ // `view.hiddenChanges` (written by operations.restore()) is part of the
1521
+ // view, so it persists across later operations. undo()/redo()/restore()
1522
+ // set it explicitly (their view is a snapshot of another operation);
1523
+ // every other operation carries the current head's value forward.
1524
+ if (
1525
+ this.headOperationId &&
1526
+ operation.view &&
1527
+ !('hiddenChanges' in operation.view) &&
1528
+ operation.eventType !== 'undo' &&
1529
+ operation.eventType !== 'redo'
1530
+ ) {
1531
+ const headOp = this.operations.find((op) => op.id === this.headOperationId);
1532
+ const inherited = headOp.view && headOp.view.hiddenChanges;
1533
+ if (inherited && inherited.length > 0) {
1534
+ operation.view = { ...operation.view, hiddenChanges: inherited };
1535
+ }
1536
+ }
1537
+
1520
1538
  // Generate operation ID
1521
1539
  const id = await generateOperationId(operation);
1522
1540
  const fullOperation = {
@@ -2763,16 +2781,18 @@ class RevsetEngine {
2763
2781
  case 'visible_heads': {
2764
2782
  await this.graph.load();
2765
2783
  const allChanges = this.graph.getAll();
2766
- const changeIdSet = new Set(allChanges.map((c) => c.changeId));
2784
+ const restoreHidden = await this._computeRestoreHidden();
2785
+ const visibleChanges = allChanges.filter((c) => !restoreHidden.has(c.changeId));
2786
+ const changeIdSet = new Set(visibleChanges.map((c) => c.changeId));
2767
2787
  const hasChildren = new Set();
2768
- for (const change of allChanges) {
2788
+ for (const change of visibleChanges) {
2769
2789
  if (change.parents) {
2770
2790
  for (const parent of change.parents) {
2771
2791
  if (changeIdSet.has(parent)) hasChildren.add(parent);
2772
2792
  }
2773
2793
  }
2774
2794
  }
2775
- return allChanges.filter((c) => !hasChildren.has(c.changeId)).map((c) => c.changeId);
2795
+ return visibleChanges.filter((c) => !hasChildren.has(c.changeId)).map((c) => c.changeId);
2776
2796
  }
2777
2797
 
2778
2798
  // `git_refs()` and `git_head()` are deprecated compatibility shims.
@@ -3258,6 +3278,49 @@ class RevsetEngine {
3258
3278
  * @returns {Promise<Set<string>>} change IDs to exclude
3259
3279
  */
3260
3280
  async _computeHiddenOrphans(allChanges) {
3281
+ const restoreHidden = await this._computeRestoreHidden();
3282
+ const hidden = await this._computeUnreferencedOrphans(allChanges);
3283
+ for (const id of restoreHidden) hidden.add(id);
3284
+ return hidden;
3285
+ }
3286
+
3287
+ /**
3288
+ * Changes hidden by `operations.restore()` (issue #56): the head
3289
+ * operation's `view.hiddenChanges`, minus any that the current working
3290
+ * copy / bookmarks / tags make reachable again. Deliberately does NOT use
3291
+ * the operation log's recorded heads for reachability -- later operations
3292
+ * record structural heads, which would include the hidden leaf itself.
3293
+ *
3294
+ * @returns {Promise<Set<string>>}
3295
+ */
3296
+ async _computeRestoreHidden() {
3297
+ const hidden = new Set();
3298
+ if (!this.oplog) return hidden;
3299
+ const headOp = await this.oplog.getHeadOperation();
3300
+ const listed = (headOp && headOp.view && headOp.view.hiddenChanges) || [];
3301
+ if (listed.length === 0) return hidden;
3302
+
3303
+ const roots = new Set([this.workingCopy.getCurrentChangeId()]);
3304
+ await this.bookmarkStore.load();
3305
+ for (const bookmark of await this.bookmarkStore.list()) roots.add(bookmark.changeId);
3306
+ for (const tag of await this.tagStore.list()) roots.add(tag.changeId);
3307
+ const reachable = new Set();
3308
+ for (const rootId of roots) {
3309
+ for (const id of await this.getAncestors(rootId)) reachable.add(id);
3310
+ }
3311
+ for (const id of listed) {
3312
+ if (!reachable.has(id)) hidden.add(id);
3313
+ }
3314
+ return hidden;
3315
+ }
3316
+
3317
+ /**
3318
+ * The #37(b)/#43(2)/#48 rule: unreachable, undescribed orphans.
3319
+ *
3320
+ * @param {any[]} allChanges
3321
+ * @returns {Promise<Set<string>>}
3322
+ */
3323
+ async _computeUnreferencedOrphans(allChanges) {
3261
3324
  const trackedHeads = new Set();
3262
3325
  try {
3263
3326
  const wcId = this.workingCopy && this.workingCopy.getCurrentChangeId();
@@ -8203,6 +8266,61 @@ async function createJJ(options) {
8203
8266
  return heads;
8204
8267
  };
8205
8268
 
8269
+ /**
8270
+ * Changes that `operations.restore(targetOp)` must hide (issue #56): every
8271
+ * change created after `targetOp` (i.e. not among the heads / working copy /
8272
+ * touched changes recorded up to targetOp, nor their ancestors) that is not
8273
+ * reachable from the restored working copy, bookmarks or tags. Hidden, not
8274
+ * deleted -- they reappear if a later operation makes them reachable again.
8275
+ * Previously hidden changes that targetOp's own view already hid stay hidden.
8276
+ *
8277
+ * @param {any[]} ops - Full operation list (chronological)
8278
+ * @param {any} targetOp
8279
+ * @returns {Promise<string[]>}
8280
+ */
8281
+ const computeRestoreHiddenChanges = async (ops, targetOp) => {
8282
+ await graph.load();
8283
+ const ancestorsOf = (/** @type {Iterable<string>} */ seeds) => {
8284
+ const seen = new Set();
8285
+ const stack = [...seeds];
8286
+ while (stack.length > 0) {
8287
+ const id = /** @type {string} */ (stack.pop());
8288
+ if (seen.has(id)) continue;
8289
+ seen.add(id);
8290
+ for (const parent of graph.getParents(id)) stack.push(parent);
8291
+ }
8292
+ return seen;
8293
+ };
8294
+
8295
+ const targetIndex = ops.findIndex((o) => o.id === targetOp.id);
8296
+ const knownSeeds = new Set();
8297
+ for (const op of ops.slice(0, targetIndex + 1)) {
8298
+ const view = op.view || {};
8299
+ for (const id of view.heads || []) knownSeeds.add(id);
8300
+ if (view.workingCopy) knownSeeds.add(view.workingCopy);
8301
+ for (const id of Object.keys(op.changeSnapshot || {})) knownSeeds.add(id);
8302
+ }
8303
+ const known = ancestorsOf(knownSeeds);
8304
+
8305
+ const roots = new Set([workingCopy.getCurrentChangeId()]);
8306
+ await bookmarks.load();
8307
+ for (const bookmark of await bookmarks.list()) roots.add(bookmark.changeId);
8308
+ for (const tag of await tags.list()) roots.add(tag.changeId);
8309
+ const reachable = ancestorsOf(roots);
8310
+
8311
+ const hidden = new Set(
8312
+ ((targetOp.view && targetOp.view.hiddenChanges) || []).filter(
8313
+ (/** @type {string} */ id) => !reachable.has(id)
8314
+ )
8315
+ );
8316
+ for (const change of graph.getAll()) {
8317
+ if (!known.has(change.changeId) && !reachable.has(change.changeId)) {
8318
+ hidden.add(change.changeId);
8319
+ }
8320
+ }
8321
+ return [...hidden];
8322
+ };
8323
+
8206
8324
  /**
8207
8325
  * Re-parent every child of `changeId` onto `newParents` instead — used
8208
8326
  * when a change is being elided from the graph (abandon(), issue #30;
@@ -9330,6 +9448,27 @@ async function createJJ(options) {
9330
9448
  await conflicts.addConflict(conflict);
9331
9449
  }
9332
9450
 
9451
+ // The rebased change's content, path by path: (new base) + (what the
9452
+ // change itself did relative to its old base), as jj's rebase. It
9453
+ // used to keep its own full tree verbatim — keeping every file only
9454
+ // the OLD base had and never picking up what only the NEW base
9455
+ // changed. A path both sides changed differently keeps the change's
9456
+ // own value here; if that's a real conflict, detectConflicts() above
9457
+ // reported it and the marker text below replaces it.
9458
+ const contentChanged = Boolean(oldParentChange);
9459
+ if (oldParentChange) {
9460
+ /** @type {Record<string, string>} */
9461
+ const rebased = {};
9462
+ const allPaths = new Set([...baseFiles.keys(), ...leftFiles.keys(), ...rightFiles.keys()]);
9463
+ for (const filePath of allPaths) {
9464
+ const baseValue = baseFiles.get(filePath);
9465
+ const ownValue = leftFiles.get(filePath);
9466
+ const value = ownValue === baseValue ? rightFiles.get(filePath) : ownValue;
9467
+ if (value !== undefined) rebased[filePath] = value;
9468
+ }
9469
+ change.fileSnapshot = rebased;
9470
+ }
9471
+
9333
9472
  // Materialize real conflict-marker text into the moved change's file
9334
9473
  // content — issue #43(3): rebase()/moveChange() used to record the
9335
9474
  // conflict correctly as DATA (via conflicts.addConflict() above) but
@@ -9371,6 +9510,13 @@ async function createJJ(options) {
9371
9510
  }
9372
9511
  }
9373
9512
 
9513
+ // A checked-out change's files on disk are its content: bring them in
9514
+ // line with the rebased snapshot (markers included), tracked, so the
9515
+ // next command's auto-snapshot finds nothing to re-record.
9516
+ if (contentChanged && changeId === workingCopy.getCurrentChangeId()) {
9517
+ await syncWorkingCopyFiles(change.fileSnapshot || {});
9518
+ }
9519
+
9374
9520
  // Update parent
9375
9521
  change.parents = [newParent];
9376
9522
  await graph.updateChange(change);
@@ -9509,6 +9655,11 @@ async function createJJ(options) {
9509
9655
  * @param {Record<string, any>} [args]
9510
9656
  */
9511
9657
  async describe(args = {}) {
9658
+ // Documented option is `changeId`; `revision` is the older spelling.
9659
+ if (args.changeId && !args.revision) {
9660
+ args = { ...args, revision: args.changeId };
9661
+ }
9662
+
9512
9663
  await graph.load();
9513
9664
  await workingCopy.load();
9514
9665
  await userConfig.load();
@@ -9658,10 +9809,13 @@ async function createJJ(options) {
9658
9809
  * @returns {Promise<Object>} Updated change object
9659
9810
  */
9660
9811
  async metaedit(args = {}) {
9661
- // Normalize: accept 'change' as alias for 'revision'
9812
+ // Normalize: accept 'change' / 'changeId' as aliases for 'revision'
9662
9813
  if (args.change && !args.revision) {
9663
9814
  args = { ...args, revision: args.change };
9664
9815
  }
9816
+ if (args.changeId && !args.revision) {
9817
+ args = { ...args, revision: args.changeId };
9818
+ }
9665
9819
 
9666
9820
  // Normalize: accept 'message' as alias for 'description'
9667
9821
  if (args.message && !args.description) {
@@ -10354,29 +10508,18 @@ async function createJJ(options) {
10354
10508
  // see issue #29.
10355
10509
  const fileSnapshotBeforeSwitch = await snapshotFilesystem();
10356
10510
 
10357
- // CRITICAL FIX: Clean working directory BEFORE snapshotting to prevent cross-branch pollution
10358
- // Remove files that don't belong to the current changeId before we snapshot it
10511
+ // Commit the change being left: every tracked file on disk is its
10512
+ // content, as in new()/describe(). This used to first DELETE every
10513
+ // tracked file missing from the change's recorded fileSnapshot, as
10514
+ // "pollution" from an earlier checkout — but a switch now fully syncs
10515
+ // the working directory to its target (issue #30, below), so nothing
10516
+ // leaks that way any more, and what that cleanup actually deleted was
10517
+ // every new file write() had created in this change: write() tracks a
10518
+ // file without recording it in the change, so it is only committed
10519
+ // here, by the next command.
10359
10520
  if (previousChangeId !== args.changeId) {
10360
10521
  const previousChange = await graph.getChange(previousChangeId);
10361
10522
  if (previousChange) {
10362
- const previousSnapshot = previousChange.fileSnapshot || {};
10363
- const workingDirFiles = await workingCopy.listFiles();
10364
-
10365
- // Delete files that exist in working dir but NOT in previous changeId's snapshot
10366
- // This removes pollution from other branches that were previously checked out
10367
- for (const filePath of workingDirFiles) {
10368
- if (!previousSnapshot[filePath]) {
10369
- try {
10370
- const fullPath = join(dir, filePath);
10371
- await fs.promises.unlink(fullPath);
10372
- await workingCopy.untrackFile(filePath);
10373
- } catch (error) {
10374
- // Ignore errors - file might already be deleted or inaccessible
10375
- }
10376
- }
10377
- }
10378
-
10379
- // Now snapshot the CLEAN working directory (no pollution!)
10380
10523
  const currentSnapshot = await snapshotFilesystem();
10381
10524
  if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
10382
10525
  changeSnapshot[previousChangeId] = structuredClone(previousChange);
@@ -10600,7 +10743,11 @@ async function createJJ(options) {
10600
10743
  redoFileSnapshot,
10601
10744
  redoConflictsSnapshot,
10602
10745
  parents: [],
10603
- view: { ...landingView, fileSnapshot: diskSnapshotBeforeThisOp },
10746
+ view: {
10747
+ ...landingView,
10748
+ fileSnapshot: diskSnapshotBeforeThisOp,
10749
+ hiddenChanges: landingView.hiddenChanges || [],
10750
+ },
10604
10751
  });
10605
10752
 
10606
10753
  const restoredFileCount =
@@ -10723,7 +10870,11 @@ async function createJJ(options) {
10723
10870
  redoOf: undoOp.id,
10724
10871
  changeSnapshot: undoChangeSnapshot,
10725
10872
  parents: [],
10726
- view: { ...redoneView, fileSnapshot: diskSnapshotBeforeThisOp },
10873
+ view: {
10874
+ ...redoneView,
10875
+ fileSnapshot: diskSnapshotBeforeThisOp,
10876
+ hiddenChanges: redoneView.hiddenChanges || [],
10877
+ },
10727
10878
  });
10728
10879
 
10729
10880
  const restoredFileCount =
@@ -11055,6 +11206,13 @@ async function createJJ(options) {
11055
11206
 
11056
11207
  await applyGraphReversal(reversal);
11057
11208
 
11209
+ // Restoring the view hides (not deletes) every change that was
11210
+ // created after targetOp and is not reachable from the restored
11211
+ // working copy / bookmarks / tags -- see issue #56. The list is
11212
+ // recorded in the view so it survives later operations, and so
11213
+ // undo()/redo() of the restore bring the changes back.
11214
+ const hiddenChanges = await computeRestoreHiddenChanges(ops, targetOp);
11215
+
11058
11216
  // Record this restoration as a new operation
11059
11217
  await oplog.recordOperation({
11060
11218
  timestamp: new Date().toISOString(),
@@ -11062,7 +11220,7 @@ async function createJJ(options) {
11062
11220
  description: `restore to operation ${args.operation}`,
11063
11221
  parents: [],
11064
11222
  changeSnapshot,
11065
- view: { ...targetOp.view, fileSnapshot: diskSnapshotBeforeThisOp },
11223
+ view: { ...targetOp.view, fileSnapshot: diskSnapshotBeforeThisOp, hiddenChanges },
11066
11224
  });
11067
11225
 
11068
11226
  return {
@@ -11681,6 +11839,9 @@ async function createJJ(options) {
11681
11839
  }
11682
11840
 
11683
11841
  await graph.load();
11842
+ // Both outcomes below record the working copy in their operation —
11843
+ // a fresh instance hasn't loaded it yet.
11844
+ await workingCopy.load();
11684
11845
  await userConfig.load();
11685
11846
  const siblings = graph.getDivergentSiblings(changeId);
11686
11847