isomorphic-jj 1.13.0 → 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,37 @@
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
+
3
35
  ## 1.13.0 — 2026-10-05 — Write-path fixes: converge on a fresh instance, new files lost on edit(), rebase content
4
36
 
5
37
  Fixed in [PR #52](https://github.com/johnhenry/isomorphic-jj/pull/52).
package/README.md CHANGED
@@ -1020,7 +1020,7 @@ repo/
1020
1020
 
1021
1021
  ## Project Status
1022
1022
 
1023
- **Current Version**: 1.13.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)
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
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.
@@ -1038,6 +1038,7 @@ 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).
1041
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).
1042
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.
1043
1044
 
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;
@@ -9537,6 +9655,11 @@ async function createJJ(options) {
9537
9655
  * @param {Record<string, any>} [args]
9538
9656
  */
9539
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
+
9540
9663
  await graph.load();
9541
9664
  await workingCopy.load();
9542
9665
  await userConfig.load();
@@ -9686,10 +9809,13 @@ async function createJJ(options) {
9686
9809
  * @returns {Promise<Object>} Updated change object
9687
9810
  */
9688
9811
  async metaedit(args = {}) {
9689
- // Normalize: accept 'change' as alias for 'revision'
9812
+ // Normalize: accept 'change' / 'changeId' as aliases for 'revision'
9690
9813
  if (args.change && !args.revision) {
9691
9814
  args = { ...args, revision: args.change };
9692
9815
  }
9816
+ if (args.changeId && !args.revision) {
9817
+ args = { ...args, revision: args.changeId };
9818
+ }
9693
9819
 
9694
9820
  // Normalize: accept 'message' as alias for 'description'
9695
9821
  if (args.message && !args.description) {
@@ -10617,7 +10743,11 @@ async function createJJ(options) {
10617
10743
  redoFileSnapshot,
10618
10744
  redoConflictsSnapshot,
10619
10745
  parents: [],
10620
- view: { ...landingView, fileSnapshot: diskSnapshotBeforeThisOp },
10746
+ view: {
10747
+ ...landingView,
10748
+ fileSnapshot: diskSnapshotBeforeThisOp,
10749
+ hiddenChanges: landingView.hiddenChanges || [],
10750
+ },
10621
10751
  });
10622
10752
 
10623
10753
  const restoredFileCount =
@@ -10740,7 +10870,11 @@ async function createJJ(options) {
10740
10870
  redoOf: undoOp.id,
10741
10871
  changeSnapshot: undoChangeSnapshot,
10742
10872
  parents: [],
10743
- view: { ...redoneView, fileSnapshot: diskSnapshotBeforeThisOp },
10873
+ view: {
10874
+ ...redoneView,
10875
+ fileSnapshot: diskSnapshotBeforeThisOp,
10876
+ hiddenChanges: redoneView.hiddenChanges || [],
10877
+ },
10744
10878
  });
10745
10879
 
10746
10880
  const restoredFileCount =
@@ -11072,6 +11206,13 @@ async function createJJ(options) {
11072
11206
 
11073
11207
  await applyGraphReversal(reversal);
11074
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
+
11075
11216
  // Record this restoration as a new operation
11076
11217
  await oplog.recordOperation({
11077
11218
  timestamp: new Date().toISOString(),
@@ -11079,7 +11220,7 @@ async function createJJ(options) {
11079
11220
  description: `restore to operation ${args.operation}`,
11080
11221
  parents: [],
11081
11222
  changeSnapshot,
11082
- view: { ...targetOp.view, fileSnapshot: diskSnapshotBeforeThisOp },
11223
+ view: { ...targetOp.view, fileSnapshot: diskSnapshotBeforeThisOp, hiddenChanges },
11083
11224
  });
11084
11225
 
11085
11226
  return {