isomorphic-jj 1.9.2 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "isomorphic-jj",
3
- "version": "1.9.2",
3
+ "version": "1.10.0",
4
4
  "description": "Jujutsu (jj) version control semantics in pure JavaScript — stable change IDs, fearless undo, no staging area — for Node.js and browsers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -315,6 +315,38 @@ export async function createJJ(options) {
315
315
  // an earlier, unrelated merge(). Fixed as part of issue #29.)
316
316
  };
317
317
 
318
+ /**
319
+ * Compute the full current set of "head" changeIds — every non-abandoned
320
+ * change with no non-abandoned child. Used to record an accurate
321
+ * `view.heads` when an operation runs (issue #43).
322
+ *
323
+ * Before this helper existed, several oplog-recording call sites
324
+ * (squash(), rebase()/moveChange(), abandon()) hand-wrote a single-
325
+ * element or empty `heads` array covering only the changeId(s) that
326
+ * specific call happened to touch. That silently dropped any OTHER
327
+ * live head the operation didn't itself touch (e.g. a sibling branch
328
+ * left untouched by an abandon()/squash() of a different branch — "two
329
+ * sibling changes report only one head between them"), and squash() in
330
+ * particular recorded the OLD destination changeId instead of the fresh
331
+ * working-copy change it creates on top of it when squashing @ ("after
332
+ * a squash [heads] points at the parent instead of the new tip").
333
+ *
334
+ * @returns {Promise<string[]>}
335
+ */
336
+ const computeCurrentHeads = async () => {
337
+ await graph.load();
338
+ const allChanges = await graph.getAllChanges();
339
+ const nonAbandoned = allChanges.filter((/** @type {any} */ c) => !c.abandoned);
340
+ const nonAbandonedIds = new Set(nonAbandoned.map((/** @type {any} */ c) => c.changeId));
341
+ const heads = [];
342
+ for (const change of nonAbandoned) {
343
+ const children = graph.getChildren(change.changeId);
344
+ const hasLiveChild = children.some((/** @type {string} */ id) => nonAbandonedIds.has(id));
345
+ if (!hasLiveChild) heads.push(change.changeId);
346
+ }
347
+ return heads;
348
+ };
349
+
318
350
  /**
319
351
  * Re-parent every child of `changeId` onto `newParents` instead — used
320
352
  * when a change is being elided from the graph (abandon(), issue #30;
@@ -346,6 +378,18 @@ export async function createJJ(options) {
346
378
  }
347
379
  };
348
380
 
381
+ // NOTE (issue #43(5), punted — see PR description): an earlier version of
382
+ // this file added a `rebaseDescendantsOntoUpdatedParent()` helper, called
383
+ // from edit(), to propagate an edited ancestor's content onto descendants
384
+ // automatically. It was reverted: it directly conflicts with absorb()'s
385
+ // job (folding a working-copy edit back into an ancestor it actually
386
+ // belongs to) — with the auto-rebase in place, edit()+write()+edit()-away
387
+ // already changes the descendant before absorb() ever runs, so absorb()
388
+ // finds nothing left to do (see tests/integration/absorb.test.js
389
+ // "should work after edit()", which regressed against this). Fixing
390
+ // #43(5) properly needs to account for that interaction rather than
391
+ // duplicate it; left for a follow-up.
392
+
349
393
  /**
350
394
  * Determine which operation a fresh undo() call should revert, matching
351
395
  * real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
@@ -781,6 +825,17 @@ export async function createJJ(options) {
781
825
  });
782
826
  }
783
827
 
828
+ await workingCopy.load();
829
+
830
+ // Capture disk state right before this write mutates it, and record
831
+ // an operation for it (issue #43(1)): write() previously recorded NO
832
+ // operation at all, so an edit typed into a change's working copy
833
+ // could never be rewound by undo()/operations.restore() regardless
834
+ // of which op you targeted — the change kept its post-edit content
835
+ // forever. Mirrors the "snapshot before, record after" pattern every
836
+ // other mutator in this file uses (see describe(), moveChange()).
837
+ const fileSnapshotBefore = await snapshotFilesystem();
838
+
784
839
  const fullPath = `${dir}/${args.path}`;
785
840
 
786
841
  // Ensure directory exists
@@ -796,7 +851,6 @@ export async function createJJ(options) {
796
851
  await fs.promises.writeFile(fullPath, data, 'utf8');
797
852
 
798
853
  // Track the file in working copy
799
- await workingCopy.load();
800
854
  const stats = await fs.promises.stat(fullPath);
801
855
  await workingCopy.trackFile(args.path, {
802
856
  mtime: stats.mtime,
@@ -804,6 +858,20 @@ export async function createJJ(options) {
804
858
  mode: stats.mode,
805
859
  });
806
860
 
861
+ await oplog.recordOperation({
862
+ timestamp: new Date().toISOString(),
863
+ user: await getUserOplogInfo(),
864
+ description: `write ${args.path}`,
865
+ parents: [],
866
+ view: {
867
+ bookmarks: {},
868
+ remoteBookmarks: {},
869
+ heads: await computeCurrentHeads(),
870
+ workingCopy: workingCopy.getCurrentChangeId(),
871
+ fileSnapshot: fileSnapshotBefore, // Store filesystem state from before this write
872
+ },
873
+ });
874
+
807
875
  // Return useful information about the written file
808
876
  return {
809
877
  path: args.path,
@@ -1232,6 +1300,15 @@ export async function createJJ(options) {
1232
1300
  async moveChange(args) {
1233
1301
  await graph.load();
1234
1302
 
1303
+ // Capture disk state right before this rebase mutates anything, so
1304
+ // undo()/redo()/operations.restore() can rewind past it — see issue
1305
+ // #43(1): rebase()/moveChange() previously recorded no "files
1306
+ // before" snapshot at all, so restoring to a point right before a
1307
+ // rebase ran had no on-disk state to fall back to and would instead
1308
+ // pick up whatever a LATER operation happened to record (the wrong
1309
+ // point in time).
1310
+ const fileSnapshotBefore = await snapshotFilesystem();
1311
+
1235
1312
  // Support multiple parameter names for flexibility
1236
1313
  const changeId = args.changeId || args.from || args.source;
1237
1314
  const newParent = args.newParent || args.to || args.destination;
@@ -1344,6 +1421,32 @@ export async function createJJ(options) {
1344
1421
  await conflicts.addConflict(conflict);
1345
1422
  }
1346
1423
 
1424
+ // Materialize real conflict-marker text into the moved change's file
1425
+ // content — issue #43(3): rebase()/moveChange() used to record the
1426
+ // conflict correctly as DATA (via conflicts.addConflict() above) but
1427
+ // left the file's content exactly as it was pre-rebase ("ours"),
1428
+ // never writing marker text into change.fileSnapshot or, when this
1429
+ // change is the checked-out working copy, onto disk. Reuses
1430
+ // conflicts.markers()'s own marker-formatting logic (the same format
1431
+ // jj.conflicts.markers() already returns) instead of re-deriving
1432
+ // marker text a second way.
1433
+ if (detectedConflicts.length > 0) {
1434
+ change.fileSnapshot = { ...(change.fileSnapshot || {}) };
1435
+ const isCheckedOut = changeId === workingCopy.getCurrentChangeId();
1436
+ for (const conflict of detectedConflicts) {
1437
+ const markerText = await jj.conflicts.markers({ conflictId: conflict.conflictId });
1438
+ change.fileSnapshot[conflict.path] = markerText;
1439
+ if (isCheckedOut) {
1440
+ const fullPath = path.join(dir, conflict.path);
1441
+ const pathParts = conflict.path.split('/');
1442
+ if (pathParts.length > 1) {
1443
+ await mkdirp(fs, path.join(dir, pathParts.slice(0, -1).join('/')));
1444
+ }
1445
+ await fs.promises.writeFile(fullPath, markerText, 'utf8');
1446
+ }
1447
+ }
1448
+ }
1449
+
1347
1450
  // Update parent
1348
1451
  change.parents = [newParent];
1349
1452
  await graph.updateChange(change);
@@ -1359,8 +1462,9 @@ export async function createJJ(options) {
1359
1462
  view: {
1360
1463
  bookmarks: {},
1361
1464
  remoteBookmarks: {},
1362
- heads: [],
1465
+ heads: await computeCurrentHeads(),
1363
1466
  workingCopy: workingCopy.getCurrentChangeId(),
1467
+ fileSnapshot: fileSnapshotBefore,
1364
1468
  },
1365
1469
  });
1366
1470
 
@@ -1815,6 +1919,16 @@ export async function createJJ(options) {
1815
1919
  description: args.message || '(no description)',
1816
1920
  timestamp: new Date().toISOString(),
1817
1921
  fileSnapshot: initialSnapshot, // Initialize with parent's snapshot
1922
+ // Issue #43(2): marks this as an internal, synthetic artifact
1923
+ // rather than something the user actually created, so the revset
1924
+ // engine's orphan-visibility filter (_computeHiddenOrphans) can
1925
+ // still hide it after it's un-referenced by a later undo()/
1926
+ // operations.restore(), even if some out-of-band mutation (e.g.
1927
+ // write()+snapshot() before the undo unwinds this far) has since
1928
+ // made it look "non-empty" relative to its parent. A real
1929
+ // description (args.message, or a later describe()) still makes
1930
+ // it visible regardless, via the UNDESCRIBED check.
1931
+ autoCreated: true,
1818
1932
  };
1819
1933
 
1820
1934
  // Dispatch change:creating event (preventable)
@@ -1935,17 +2049,51 @@ export async function createJJ(options) {
1935
2049
  await graph.load();
1936
2050
  await workingCopy.load();
1937
2051
  const result = await workingCopy.snapshot();
2052
+
2053
+ // Disk content itself is not mutated by this call (it's a read,
2054
+ // not a write) — the same value is therefore valid both as the
2055
+ // refreshed change.fileSnapshot AND as the recorded operation's
2056
+ // `view.fileSnapshot` "before" value (nothing on disk changes
2057
+ // between the two).
2058
+ const wcId = workingCopy.getCurrentChangeId();
2059
+ const currentDiskSnapshot = await snapshotFilesystem();
2060
+
1938
2061
  // Refresh the working-copy change's content snapshot from disk.
2062
+ /** @type {Record<string, any>} */
2063
+ const changeSnapshot = {};
1939
2064
  try {
1940
- const wcId = workingCopy.getCurrentChangeId();
1941
2065
  const change = await graph.getChange(wcId);
1942
2066
  if (change) {
1943
- change.fileSnapshot = await snapshotFilesystem();
2067
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore()
2068
+ // can reverse this content refresh — see issue #43(1):
2069
+ // snapshot() previously mutated `change.fileSnapshot` in place
2070
+ // with no "before" capture and recorded no operation at all, so
2071
+ // computeGraphReversal() had nothing to find for this changeId
2072
+ // if a restore()/undo() landed between an out-of-band edit and
2073
+ // the next real command.
2074
+ changeSnapshot[wcId] = structuredClone(change);
2075
+ change.fileSnapshot = currentDiskSnapshot;
1944
2076
  await graph.updateChange(change);
1945
2077
  }
1946
2078
  } catch {
1947
2079
  // Best-effort content refresh.
1948
2080
  }
2081
+
2082
+ await oplog.recordOperation({
2083
+ timestamp: new Date().toISOString(),
2084
+ user: await getUserOplogInfo(),
2085
+ description: 'snapshot working copy',
2086
+ parents: [],
2087
+ changeSnapshot,
2088
+ view: {
2089
+ bookmarks: {},
2090
+ remoteBookmarks: {},
2091
+ heads: await computeCurrentHeads(),
2092
+ workingCopy: wcId,
2093
+ fileSnapshot: currentDiskSnapshot,
2094
+ },
2095
+ });
2096
+
1949
2097
  return result;
1950
2098
  },
1951
2099
 
@@ -2409,8 +2557,20 @@ export async function createJJ(options) {
2409
2557
  const current = await graph.getChange(changeId);
2410
2558
  if (current) redoChangeSnapshot[changeId] = current;
2411
2559
  }
2560
+ // Capture disk state right before this undo() runs. Used both as
2561
+ // `redoFileSnapshot` (for a later redo() — only meaningful when this
2562
+ // reversal actually touches files) AND, unconditionally, as THIS
2563
+ // undo operation's own `view.fileSnapshot` "before" value below —
2564
+ // see issue #43(1): undo()/redo()/operations.restore() used to
2565
+ // record `view: landingView` verbatim, i.e. reusing the LANDING
2566
+ // operation's own recorded fileSnapshot (disk state before THAT op
2567
+ // ran) instead of disk state before THIS undo ran. A later
2568
+ // restore()/undo() scanning forward past this one would then pick
2569
+ // up that stale, wrong snapshot instead of what was actually on disk
2570
+ // right before this undo executed.
2571
+ const diskSnapshotBeforeThisOp = await snapshotFilesystem();
2412
2572
  const redoFileSnapshot =
2413
- reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
2573
+ reversal.fileSnapshot !== undefined ? diskSnapshotBeforeThisOp : undefined;
2414
2574
  let redoConflictsSnapshot;
2415
2575
  if (reversal.conflictsSnapshot !== undefined) {
2416
2576
  await conflicts.load();
@@ -2451,7 +2611,7 @@ export async function createJJ(options) {
2451
2611
  redoFileSnapshot,
2452
2612
  redoConflictsSnapshot,
2453
2613
  parents: [],
2454
- view: landingView,
2614
+ view: { ...landingView, fileSnapshot: diskSnapshotBeforeThisOp },
2455
2615
  });
2456
2616
 
2457
2617
  const restoredFileCount =
@@ -2545,6 +2705,15 @@ export async function createJJ(options) {
2545
2705
  await baseGraph.updateChange(snapshot);
2546
2706
  }
2547
2707
  }
2708
+
2709
+ // Capture disk state right before THIS redo() mutates it — see issue
2710
+ // #43(1)/the matching fix in undo(): `view: redoneView` below reuses
2711
+ // the ORIGINAL (re-applied) operation's own recorded pointers, but
2712
+ // its `fileSnapshot` sub-field must reflect disk state before THIS
2713
+ // redo ran, not before that original operation ran, or a later
2714
+ // restore()/undo() scanning forward past this redo would pick up a
2715
+ // stale, wrong snapshot.
2716
+ const diskSnapshotBeforeThisOp = await snapshotFilesystem();
2548
2717
  if (undoOp.redoFileSnapshot !== undefined) {
2549
2718
  await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
2550
2719
  }
@@ -2565,7 +2734,7 @@ export async function createJJ(options) {
2565
2734
  redoOf: undoOp.id,
2566
2735
  changeSnapshot: undoChangeSnapshot,
2567
2736
  parents: [],
2568
- view: redoneView,
2737
+ view: { ...redoneView, fileSnapshot: diskSnapshotBeforeThisOp },
2569
2738
  });
2570
2739
 
2571
2740
  const restoredFileCount =
@@ -2871,6 +3040,17 @@ export async function createJJ(options) {
2871
3040
  if (current) changeSnapshot[changeId] = current;
2872
3041
  }
2873
3042
 
3043
+ // Capture disk state right before THIS restore() mutates it — see
3044
+ // issue #43(1)/the matching fix in undo()/redo(): recording
3045
+ // `view: targetOp.view` verbatim reused targetOp's OWN recorded
3046
+ // `fileSnapshot` (disk state before targetOp ran), not disk state
3047
+ // before THIS restore ran. A later restore()/undo() scanning
3048
+ // forward past this one would then pick up that stale, wrong
3049
+ // snapshot instead of what was actually on disk right before this
3050
+ // restore executed — this is exactly the "forward restore to
3051
+ // 'edit tip' lost style.css" repro.
3052
+ const diskSnapshotBeforeThisOp = await snapshotFilesystem();
3053
+
2874
3054
  await applyGraphReversal(reversal);
2875
3055
 
2876
3056
  // Record this restoration as a new operation
@@ -2880,7 +3060,7 @@ export async function createJJ(options) {
2880
3060
  description: `restore to operation ${args.operation}`,
2881
3061
  parents: [],
2882
3062
  changeSnapshot,
2883
- view: targetOp.view,
3063
+ view: { ...targetOp.view, fileSnapshot: diskSnapshotBeforeThisOp },
2884
3064
  });
2885
3065
 
2886
3066
  return {
@@ -3116,6 +3296,15 @@ export async function createJJ(options) {
3116
3296
  await workingCopy.load();
3117
3297
  await userConfig.load();
3118
3298
 
3299
+ // Capture disk state right before this squash mutates anything, so
3300
+ // undo()/redo()/operations.restore() can rewind past it — see issue
3301
+ // #43(1): squash() previously recorded no "files before" snapshot at
3302
+ // all, so restoring to a point right before a squash ran had no
3303
+ // recorded on-disk state and would fall through to whatever a LATER
3304
+ // operation happened to record instead (the repro: a forward
3305
+ // restore to "edit tip" lost `style.css`).
3306
+ const fileSnapshotBefore = await snapshotFilesystem();
3307
+
3119
3308
  // Support 'into' as alias for 'dest' (matches JJ CLI)
3120
3309
  const dest = args.into || args.dest;
3121
3310
 
@@ -3247,6 +3436,18 @@ export async function createJJ(options) {
3247
3436
  // every on-disk file as untracked/new until something re-wrote
3248
3437
  // it and triggered a fresh auto-snapshot.
3249
3438
  fileSnapshot: { ...destChange.fileSnapshot },
3439
+ // Marks this as an internal, synthetic artifact rather than
3440
+ // something the user actually created — issue #43(2): if this
3441
+ // squash is later undone, this change becomes unreferenced, and
3442
+ // by then `destChange` may have been reverted to pre-squash
3443
+ // content, making this change look "non-empty" relative to its
3444
+ // (now-reverted) parent even though it never held any real user
3445
+ // content of its own. The revset engine's orphan-visibility
3446
+ // filter (_computeHiddenOrphans) treats `autoCreated` as
3447
+ // sufficient on its own (given it's also unreferenced and
3448
+ // undescribed) rather than re-diffing against a parent that may
3449
+ // no longer resemble what this change was actually created from.
3450
+ autoCreated: true,
3250
3451
  };
3251
3452
 
3252
3453
  await graph.addChange(newChange); // Middleware will sync to Git
@@ -3269,8 +3470,18 @@ export async function createJJ(options) {
3269
3470
  view: {
3270
3471
  bookmarks: {},
3271
3472
  remoteBookmarks: {},
3272
- heads: [destChangeId],
3473
+ // issue #43(1): this used to hard-code `[destChangeId]` — wrong
3474
+ // whenever squash() created a fresh working-copy change on top
3475
+ // of dest (the real new tip was `newWorkingCopyId`, not
3476
+ // `destChangeId` itself), and incomplete whenever a sibling head
3477
+ // elsewhere in the graph existed and wasn't touched by this
3478
+ // squash at all ("two sibling changes report only one head
3479
+ // between them"). computeCurrentHeads() reflects the WHOLE
3480
+ // repo's actual head set after this squash, not just the
3481
+ // changeId(s) this call happened to touch.
3482
+ heads: await computeCurrentHeads(),
3273
3483
  workingCopy: newWorkingCopyId,
3484
+ fileSnapshot: fileSnapshotBefore,
3274
3485
  },
3275
3486
  });
3276
3487
 
@@ -3342,6 +3553,12 @@ export async function createJJ(options) {
3342
3553
  // abandoning any OTHER change must leave @ exactly where it was.
3343
3554
  const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
3344
3555
 
3556
+ // Capture disk state right before this abandon mutates anything, so
3557
+ // undo()/redo()/operations.restore() can rewind past it — see issue
3558
+ // #43(1): abandon() previously recorded no "files before" snapshot
3559
+ // at all.
3560
+ const fileSnapshotBefore = await snapshotFilesystem();
3561
+
3345
3562
  // Dispatch change:abandoning event (preventable)
3346
3563
  await dispatchEventAsync(jj, 'change:abandoning', {
3347
3564
  operation: 'abandon',
@@ -3376,8 +3593,16 @@ export async function createJJ(options) {
3376
3593
  view: {
3377
3594
  bookmarks: {},
3378
3595
  remoteBookmarks: {},
3379
- heads: [],
3596
+ // issue #43(1): this used to hard-code `[]`, dropping every live
3597
+ // head from the recorded operation (including any untouched
3598
+ // sibling branch — "two sibling changes report only one head
3599
+ // between them"). computeCurrentHeads() reflects the actual
3600
+ // repo-wide head set right after this abandon (and its
3601
+ // reparenting) ran, before the compensating jj.new() below (if
3602
+ // any) records its own separate operation.
3603
+ heads: await computeCurrentHeads(),
3380
3604
  workingCopy: workingCopy.getCurrentChangeId(),
3605
+ fileSnapshot: fileSnapshotBefore,
3381
3606
  },
3382
3607
  });
3383
3608
 
@@ -5639,6 +5864,32 @@ export async function createJJ(options) {
5639
5864
  throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
5640
5865
  }
5641
5866
 
5867
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore()
5868
+ // can reverse this resolution — issue #43(4): conflicts.resolve()
5869
+ // previously recorded no operation at all, so resolving a conflict
5870
+ // could never be undone: no changeSnapshot of the working-copy
5871
+ // change's content, no conflictsSnapshot of the pre-resolve
5872
+ // conflict state, and no fileSnapshot of the on-disk content
5873
+ // before the resolved content was written.
5874
+ await graph.load();
5875
+ await workingCopy.load();
5876
+ // structuredClone matters here (unlike the read-only conflictsSnapshot
5877
+ // capture in moveChange()/merge()): resolveConflict() below mutates
5878
+ // the SAME conflict object in place (`conflict.resolved = true`),
5879
+ // and Object.fromEntries() on the live Map would otherwise leave
5880
+ // this "before" snapshot holding a reference to that very object —
5881
+ // corrupting it into already showing `resolved: true` by the time
5882
+ // it's recorded.
5883
+ const conflictsSnapshotBefore = structuredClone({
5884
+ conflicts: Object.fromEntries(conflicts.conflicts),
5885
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
5886
+ });
5887
+ const fileSnapshotBefore = await snapshotFilesystem();
5888
+ const wcId = workingCopy.getCurrentChangeId();
5889
+ const wcChange = await graph.getChange(wcId);
5890
+ /** @type {Record<string, any>} */
5891
+ const changeSnapshot = wcChange ? { [wcId]: structuredClone(wcChange) } : {};
5892
+
5642
5893
  let resolvedContent;
5643
5894
 
5644
5895
  // v0.5: Support different resolution methods
@@ -5682,6 +5933,36 @@ export async function createJJ(options) {
5682
5933
  await conflicts.resolveConflict(args.conflictId, 'manual');
5683
5934
  await conflicts.save();
5684
5935
 
5936
+ // Refresh the working-copy change's own fileSnapshot to match the
5937
+ // resolved on-disk content (conflicted files always live under
5938
+ // the checked-out working copy — see the writeFile above), so
5939
+ // undo()/restore() has an accurate "after" state to compare
5940
+ // against, and log()/read() see the resolution immediately rather
5941
+ // than waiting for the next auto-snapshot.
5942
+ if (wcChange) {
5943
+ wcChange.fileSnapshot = {
5944
+ ...(wcChange.fileSnapshot || {}),
5945
+ [conflict.path]: resolvedContent,
5946
+ };
5947
+ await graph.updateChange(wcChange);
5948
+ }
5949
+
5950
+ await oplog.recordOperation({
5951
+ timestamp: new Date().toISOString(),
5952
+ user: await getUserOplogInfo(),
5953
+ description: `resolve conflict ${args.conflictId} (${conflict.path})`,
5954
+ parents: [],
5955
+ changeSnapshot,
5956
+ conflictsSnapshot: conflictsSnapshotBefore,
5957
+ view: {
5958
+ bookmarks: {},
5959
+ remoteBookmarks: {},
5960
+ heads: await computeCurrentHeads(),
5961
+ workingCopy: wcId,
5962
+ fileSnapshot: fileSnapshotBefore,
5963
+ },
5964
+ });
5965
+
5685
5966
  return { resolved: true };
5686
5967
  },
5687
5968
 
@@ -1107,11 +1107,39 @@ export class RevsetEngine {
1107
1107
  return parentKeys.every((k) => parentSnapshot[k] === ownSnapshot[k]);
1108
1108
  };
1109
1109
 
1110
+ // issue #43(2): the original #37(b) rule above (unreachable + isEmpty +
1111
+ // undescribed) only hides an orphan while it still happens to have
1112
+ // zero file diff versus its CURRENT parent. Two real shapes slip past
1113
+ // that once undo()/operations.restore() are involved, because both are
1114
+ // purely internal rewrite/undo artifacts that were never "empty" in
1115
+ // any way a user would recognize — they simply never held real user
1116
+ // content of their own, and a later revert of something else can make
1117
+ // isEmpty() disagree even though nothing about THIS change changed:
1118
+ //
1119
+ // 1. squash() creates a fresh empty `@` on top of `dest` when
1120
+ // squashing the working copy. If that squash is later undone,
1121
+ // the fresh `@` becomes unreferenced — but if `dest` itself gets
1122
+ // reverted back to its pre-squash content by the SAME undo,
1123
+ // isEmpty() now compares the orphan's (frozen, post-squash)
1124
+ // fileSnapshot against dest's (reverted, pre-squash) one and
1125
+ // finds a mismatch, even though the orphan is exactly as
1126
+ // "discardable" as it was the instant it was created.
1127
+ // 2. new() creates a fresh empty change on top of the previous `@`.
1128
+ // If something writes into it (write()/snapshot()) before an
1129
+ // undo() unwinds back past the new(), the orphan is left with
1130
+ // real file content that no longer matches its parent, even
1131
+ // though the user never gave it a real description.
1132
+ //
1133
+ // Changes created by squash()/new() are tagged `autoCreated: true` at
1134
+ // creation (see squash()/new() in repository.js) specifically so this
1135
+ // filter can hide them on that basis alone — still gated on being
1136
+ // unreachable and undescribed, so a change the user actually described
1137
+ // (or that's still live) is never hidden by this rule.
1110
1138
  const hidden = new Set();
1111
1139
  for (const change of allChanges) {
1112
1140
  if (
1113
1141
  !reachable.has(change.changeId) &&
1114
- isEmpty(change) &&
1142
+ (isEmpty(change) || change.autoCreated) &&
1115
1143
  UNDESCRIBED.has(change.description)
1116
1144
  ) {
1117
1145
  hidden.add(change.changeId);