isomorphic-jj 1.9.2 → 1.11.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/dist/index.mjs CHANGED
@@ -3281,11 +3281,39 @@ class RevsetEngine {
3281
3281
  return parentKeys.every((k) => parentSnapshot[k] === ownSnapshot[k]);
3282
3282
  };
3283
3283
 
3284
+ // issue #43(2): the original #37(b) rule above (unreachable + isEmpty +
3285
+ // undescribed) only hides an orphan while it still happens to have
3286
+ // zero file diff versus its CURRENT parent. Two real shapes slip past
3287
+ // that once undo()/operations.restore() are involved, because both are
3288
+ // purely internal rewrite/undo artifacts that were never "empty" in
3289
+ // any way a user would recognize — they simply never held real user
3290
+ // content of their own, and a later revert of something else can make
3291
+ // isEmpty() disagree even though nothing about THIS change changed:
3292
+ //
3293
+ // 1. squash() creates a fresh empty `@` on top of `dest` when
3294
+ // squashing the working copy. If that squash is later undone,
3295
+ // the fresh `@` becomes unreferenced — but if `dest` itself gets
3296
+ // reverted back to its pre-squash content by the SAME undo,
3297
+ // isEmpty() now compares the orphan's (frozen, post-squash)
3298
+ // fileSnapshot against dest's (reverted, pre-squash) one and
3299
+ // finds a mismatch, even though the orphan is exactly as
3300
+ // "discardable" as it was the instant it was created.
3301
+ // 2. new() creates a fresh empty change on top of the previous `@`.
3302
+ // If something writes into it (write()/snapshot()) before an
3303
+ // undo() unwinds back past the new(), the orphan is left with
3304
+ // real file content that no longer matches its parent, even
3305
+ // though the user never gave it a real description.
3306
+ //
3307
+ // Changes created by squash()/new() are tagged `autoCreated: true` at
3308
+ // creation (see squash()/new() in repository.js) specifically so this
3309
+ // filter can hide them on that basis alone — still gated on being
3310
+ // unreachable and undescribed, so a change the user actually described
3311
+ // (or that's still live) is never hidden by this rule.
3284
3312
  const hidden = new Set();
3285
3313
  for (const change of allChanges) {
3286
3314
  if (
3287
3315
  !reachable.has(change.changeId) &&
3288
- isEmpty(change) &&
3316
+ (isEmpty(change) || change.autoCreated) &&
3289
3317
  UNDESCRIBED.has(change.description)
3290
3318
  ) {
3291
3319
  hidden.add(change.changeId);
@@ -8085,6 +8113,38 @@ async function createJJ(options) {
8085
8113
  // an earlier, unrelated merge(). Fixed as part of issue #29.)
8086
8114
  };
8087
8115
 
8116
+ /**
8117
+ * Compute the full current set of "head" changeIds — every non-abandoned
8118
+ * change with no non-abandoned child. Used to record an accurate
8119
+ * `view.heads` when an operation runs (issue #43).
8120
+ *
8121
+ * Before this helper existed, several oplog-recording call sites
8122
+ * (squash(), rebase()/moveChange(), abandon()) hand-wrote a single-
8123
+ * element or empty `heads` array covering only the changeId(s) that
8124
+ * specific call happened to touch. That silently dropped any OTHER
8125
+ * live head the operation didn't itself touch (e.g. a sibling branch
8126
+ * left untouched by an abandon()/squash() of a different branch — "two
8127
+ * sibling changes report only one head between them"), and squash() in
8128
+ * particular recorded the OLD destination changeId instead of the fresh
8129
+ * working-copy change it creates on top of it when squashing @ ("after
8130
+ * a squash [heads] points at the parent instead of the new tip").
8131
+ *
8132
+ * @returns {Promise<string[]>}
8133
+ */
8134
+ const computeCurrentHeads = async () => {
8135
+ await graph.load();
8136
+ const allChanges = await graph.getAllChanges();
8137
+ const nonAbandoned = allChanges.filter((/** @type {any} */ c) => !c.abandoned);
8138
+ const nonAbandonedIds = new Set(nonAbandoned.map((/** @type {any} */ c) => c.changeId));
8139
+ const heads = [];
8140
+ for (const change of nonAbandoned) {
8141
+ const children = graph.getChildren(change.changeId);
8142
+ const hasLiveChild = children.some((/** @type {string} */ id) => nonAbandonedIds.has(id));
8143
+ if (!hasLiveChild) heads.push(change.changeId);
8144
+ }
8145
+ return heads;
8146
+ };
8147
+
8088
8148
  /**
8089
8149
  * Re-parent every child of `changeId` onto `newParents` instead — used
8090
8150
  * when a change is being elided from the graph (abandon(), issue #30;
@@ -8116,6 +8176,18 @@ async function createJJ(options) {
8116
8176
  }
8117
8177
  };
8118
8178
 
8179
+ // NOTE (issue #43(5), punted — see PR description): an earlier version of
8180
+ // this file added a `rebaseDescendantsOntoUpdatedParent()` helper, called
8181
+ // from edit(), to propagate an edited ancestor's content onto descendants
8182
+ // automatically. It was reverted: it directly conflicts with absorb()'s
8183
+ // job (folding a working-copy edit back into an ancestor it actually
8184
+ // belongs to) — with the auto-rebase in place, edit()+write()+edit()-away
8185
+ // already changes the descendant before absorb() ever runs, so absorb()
8186
+ // finds nothing left to do (see tests/integration/absorb.test.js
8187
+ // "should work after edit()", which regressed against this). Fixing
8188
+ // #43(5) properly needs to account for that interaction rather than
8189
+ // duplicate it; left for a follow-up.
8190
+
8119
8191
  /**
8120
8192
  * Determine which operation a fresh undo() call should revert, matching
8121
8193
  * real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
@@ -8194,8 +8266,26 @@ async function createJJ(options) {
8194
8266
  const wcId = workingCopy.getCurrentChangeId();
8195
8267
  const change = await graph.getChange(wcId);
8196
8268
  if (change) {
8269
+ const oldFileSnapshot = change.fileSnapshot ? structuredClone(change.fileSnapshot) : {};
8197
8270
  change.fileSnapshot = await snapshotFilesystem();
8198
8271
  await graph.updateChange(change);
8272
+
8273
+ // Issue #45: if the working-copy change isn't a leaf (e.g. we
8274
+ // edit()'d into an older change earlier in the session), its
8275
+ // descendants' fileSnapshot still reflects the OLD content and
8276
+ // needs to be rebased onto the content just committed above —
8277
+ // matching real jj's automatic rebase-on-edit. edit() itself is a
8278
+ // pure checkout and never mutates content, so this is the actual
8279
+ // moment content changes and the right place to propagate. `jj`
8280
+ // (not `this`) is used here because this function is a plain
8281
+ // closure, not a method of the `jj` object — but `jj` is a const
8282
+ // in the same enclosing scope and this function only ever runs
8283
+ // after `jj` is fully constructed, so the self-reference is safe
8284
+ // (same pattern used elsewhere in this file, e.g. `jj.new(...)`).
8285
+ const descendants = await jj._findDescendants(wcId, null);
8286
+ if (descendants.length > 0) {
8287
+ await jj._rebuildDescendants(wcId, null, oldFileSnapshot);
8288
+ }
8199
8289
  }
8200
8290
  } catch {
8201
8291
  // Best-effort content refresh; tracking state is already updated.
@@ -8551,6 +8641,17 @@ async function createJJ(options) {
8551
8641
  });
8552
8642
  }
8553
8643
 
8644
+ await workingCopy.load();
8645
+
8646
+ // Capture disk state right before this write mutates it, and record
8647
+ // an operation for it (issue #43(1)): write() previously recorded NO
8648
+ // operation at all, so an edit typed into a change's working copy
8649
+ // could never be rewound by undo()/operations.restore() regardless
8650
+ // of which op you targeted — the change kept its post-edit content
8651
+ // forever. Mirrors the "snapshot before, record after" pattern every
8652
+ // other mutator in this file uses (see describe(), moveChange()).
8653
+ const fileSnapshotBefore = await snapshotFilesystem();
8654
+
8554
8655
  const fullPath = `${dir}/${args.path}`;
8555
8656
 
8556
8657
  // Ensure directory exists
@@ -8566,7 +8667,6 @@ async function createJJ(options) {
8566
8667
  await fs.promises.writeFile(fullPath, data, 'utf8');
8567
8668
 
8568
8669
  // Track the file in working copy
8569
- await workingCopy.load();
8570
8670
  const stats = await fs.promises.stat(fullPath);
8571
8671
  await workingCopy.trackFile(args.path, {
8572
8672
  mtime: stats.mtime,
@@ -8574,6 +8674,20 @@ async function createJJ(options) {
8574
8674
  mode: stats.mode,
8575
8675
  });
8576
8676
 
8677
+ await oplog.recordOperation({
8678
+ timestamp: new Date().toISOString(),
8679
+ user: await getUserOplogInfo(),
8680
+ description: `write ${args.path}`,
8681
+ parents: [],
8682
+ view: {
8683
+ bookmarks: {},
8684
+ remoteBookmarks: {},
8685
+ heads: await computeCurrentHeads(),
8686
+ workingCopy: workingCopy.getCurrentChangeId(),
8687
+ fileSnapshot: fileSnapshotBefore, // Store filesystem state from before this write
8688
+ },
8689
+ });
8690
+
8577
8691
  // Return useful information about the written file
8578
8692
  return {
8579
8693
  path: args.path,
@@ -9002,6 +9116,15 @@ async function createJJ(options) {
9002
9116
  async moveChange(args) {
9003
9117
  await graph.load();
9004
9118
 
9119
+ // Capture disk state right before this rebase mutates anything, so
9120
+ // undo()/redo()/operations.restore() can rewind past it — see issue
9121
+ // #43(1): rebase()/moveChange() previously recorded no "files
9122
+ // before" snapshot at all, so restoring to a point right before a
9123
+ // rebase ran had no on-disk state to fall back to and would instead
9124
+ // pick up whatever a LATER operation happened to record (the wrong
9125
+ // point in time).
9126
+ const fileSnapshotBefore = await snapshotFilesystem();
9127
+
9005
9128
  // Support multiple parameter names for flexibility
9006
9129
  const changeId = args.changeId || args.from || args.source;
9007
9130
  const newParent = args.newParent || args.to || args.destination;
@@ -9114,6 +9237,32 @@ async function createJJ(options) {
9114
9237
  await conflicts.addConflict(conflict);
9115
9238
  }
9116
9239
 
9240
+ // Materialize real conflict-marker text into the moved change's file
9241
+ // content — issue #43(3): rebase()/moveChange() used to record the
9242
+ // conflict correctly as DATA (via conflicts.addConflict() above) but
9243
+ // left the file's content exactly as it was pre-rebase ("ours"),
9244
+ // never writing marker text into change.fileSnapshot or, when this
9245
+ // change is the checked-out working copy, onto disk. Reuses
9246
+ // conflicts.markers()'s own marker-formatting logic (the same format
9247
+ // jj.conflicts.markers() already returns) instead of re-deriving
9248
+ // marker text a second way.
9249
+ if (detectedConflicts.length > 0) {
9250
+ change.fileSnapshot = { ...(change.fileSnapshot || {}) };
9251
+ const isCheckedOut = changeId === workingCopy.getCurrentChangeId();
9252
+ for (const conflict of detectedConflicts) {
9253
+ const markerText = await jj.conflicts.markers({ conflictId: conflict.conflictId });
9254
+ change.fileSnapshot[conflict.path] = markerText;
9255
+ if (isCheckedOut) {
9256
+ const fullPath = join(dir, conflict.path);
9257
+ const pathParts = conflict.path.split('/');
9258
+ if (pathParts.length > 1) {
9259
+ await mkdirp(fs, join(dir, pathParts.slice(0, -1).join('/')));
9260
+ }
9261
+ await fs.promises.writeFile(fullPath, markerText, 'utf8');
9262
+ }
9263
+ }
9264
+ }
9265
+
9117
9266
  // Update parent
9118
9267
  change.parents = [newParent];
9119
9268
  await graph.updateChange(change);
@@ -9129,8 +9278,9 @@ async function createJJ(options) {
9129
9278
  view: {
9130
9279
  bookmarks: {},
9131
9280
  remoteBookmarks: {},
9132
- heads: [],
9281
+ heads: await computeCurrentHeads(),
9133
9282
  workingCopy: workingCopy.getCurrentChangeId(),
9283
+ fileSnapshot: fileSnapshotBefore,
9134
9284
  },
9135
9285
  });
9136
9286
 
@@ -9585,6 +9735,16 @@ async function createJJ(options) {
9585
9735
  description: args.message || '(no description)',
9586
9736
  timestamp: new Date().toISOString(),
9587
9737
  fileSnapshot: initialSnapshot, // Initialize with parent's snapshot
9738
+ // Issue #43(2): marks this as an internal, synthetic artifact
9739
+ // rather than something the user actually created, so the revset
9740
+ // engine's orphan-visibility filter (_computeHiddenOrphans) can
9741
+ // still hide it after it's un-referenced by a later undo()/
9742
+ // operations.restore(), even if some out-of-band mutation (e.g.
9743
+ // write()+snapshot() before the undo unwinds this far) has since
9744
+ // made it look "non-empty" relative to its parent. A real
9745
+ // description (args.message, or a later describe()) still makes
9746
+ // it visible regardless, via the UNDESCRIBED check.
9747
+ autoCreated: true,
9588
9748
  };
9589
9749
 
9590
9750
  // Dispatch change:creating event (preventable)
@@ -9705,17 +9865,63 @@ async function createJJ(options) {
9705
9865
  await graph.load();
9706
9866
  await workingCopy.load();
9707
9867
  const result = await workingCopy.snapshot();
9868
+
9869
+ // Disk content itself is not mutated by this call (it's a read,
9870
+ // not a write) — the same value is therefore valid both as the
9871
+ // refreshed change.fileSnapshot AND as the recorded operation's
9872
+ // `view.fileSnapshot` "before" value (nothing on disk changes
9873
+ // between the two).
9874
+ const wcId = workingCopy.getCurrentChangeId();
9875
+ const currentDiskSnapshot = await snapshotFilesystem();
9876
+
9708
9877
  // Refresh the working-copy change's content snapshot from disk.
9878
+ /** @type {Record<string, any>} */
9879
+ const changeSnapshot = {};
9709
9880
  try {
9710
- const wcId = workingCopy.getCurrentChangeId();
9711
9881
  const change = await graph.getChange(wcId);
9712
9882
  if (change) {
9713
- change.fileSnapshot = await snapshotFilesystem();
9883
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore()
9884
+ // can reverse this content refresh — see issue #43(1):
9885
+ // snapshot() previously mutated `change.fileSnapshot` in place
9886
+ // with no "before" capture and recorded no operation at all, so
9887
+ // computeGraphReversal() had nothing to find for this changeId
9888
+ // if a restore()/undo() landed between an out-of-band edit and
9889
+ // the next real command.
9890
+ changeSnapshot[wcId] = structuredClone(change);
9891
+ change.fileSnapshot = currentDiskSnapshot;
9714
9892
  await graph.updateChange(change);
9893
+
9894
+ // Issue #45: if the working-copy change isn't a leaf (e.g. we
9895
+ // edit()'d into an older change earlier in the session), its
9896
+ // descendants' fileSnapshot still reflects the OLD content and
9897
+ // needs to be rebased onto the content just committed above —
9898
+ // matching real jj's automatic rebase-on-edit. edit() itself is a
9899
+ // pure checkout and never mutates content, so this is the actual
9900
+ // moment content changes and the right place to propagate.
9901
+ const descendants = await this._findDescendants(wcId, null);
9902
+ if (descendants.length > 0) {
9903
+ await this._rebuildDescendants(wcId, null, changeSnapshot[wcId].fileSnapshot);
9904
+ }
9715
9905
  }
9716
9906
  } catch {
9717
- // Best-effort content refresh.
9907
+ // Best-effort content refresh (including descendant propagation).
9718
9908
  }
9909
+
9910
+ await oplog.recordOperation({
9911
+ timestamp: new Date().toISOString(),
9912
+ user: await getUserOplogInfo(),
9913
+ description: 'snapshot working copy',
9914
+ parents: [],
9915
+ changeSnapshot,
9916
+ view: {
9917
+ bookmarks: {},
9918
+ remoteBookmarks: {},
9919
+ heads: await computeCurrentHeads(),
9920
+ workingCopy: wcId,
9921
+ fileSnapshot: currentDiskSnapshot,
9922
+ },
9923
+ });
9924
+
9719
9925
  return result;
9720
9926
  },
9721
9927
 
@@ -10049,6 +10255,22 @@ async function createJJ(options) {
10049
10255
  changeSnapshot[previousChangeId] = structuredClone(previousChange);
10050
10256
  previousChange.fileSnapshot = currentSnapshot;
10051
10257
  await graph.updateChange(previousChange);
10258
+
10259
+ // Issue #45: this is the OTHER place a checked-out change's
10260
+ // content gets committed to the graph — not through snapshot()/
10261
+ // autoSnapshotWorkingCopy() at all, but here, inline, when
10262
+ // edit() leaves a change it was checked out on. If that change
10263
+ // isn't a leaf (we'd edit()'d into an ancestor), its descendants
10264
+ // still have the OLD content and need the same rebase-on-edit
10265
+ // treatment applied at the other two commit sites.
10266
+ const descendants = await this._findDescendants(previousChangeId, null);
10267
+ if (descendants.length > 0) {
10268
+ await this._rebuildDescendants(
10269
+ previousChangeId,
10270
+ null,
10271
+ changeSnapshot[previousChangeId].fileSnapshot
10272
+ );
10273
+ }
10052
10274
  }
10053
10275
  }
10054
10276
  }
@@ -10179,8 +10401,20 @@ async function createJJ(options) {
10179
10401
  const current = await graph.getChange(changeId);
10180
10402
  if (current) redoChangeSnapshot[changeId] = current;
10181
10403
  }
10404
+ // Capture disk state right before this undo() runs. Used both as
10405
+ // `redoFileSnapshot` (for a later redo() — only meaningful when this
10406
+ // reversal actually touches files) AND, unconditionally, as THIS
10407
+ // undo operation's own `view.fileSnapshot` "before" value below —
10408
+ // see issue #43(1): undo()/redo()/operations.restore() used to
10409
+ // record `view: landingView` verbatim, i.e. reusing the LANDING
10410
+ // operation's own recorded fileSnapshot (disk state before THAT op
10411
+ // ran) instead of disk state before THIS undo ran. A later
10412
+ // restore()/undo() scanning forward past this one would then pick
10413
+ // up that stale, wrong snapshot instead of what was actually on disk
10414
+ // right before this undo executed.
10415
+ const diskSnapshotBeforeThisOp = await snapshotFilesystem();
10182
10416
  const redoFileSnapshot =
10183
- reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
10417
+ reversal.fileSnapshot !== undefined ? diskSnapshotBeforeThisOp : undefined;
10184
10418
  let redoConflictsSnapshot;
10185
10419
  if (reversal.conflictsSnapshot !== undefined) {
10186
10420
  await conflicts.load();
@@ -10221,7 +10455,7 @@ async function createJJ(options) {
10221
10455
  redoFileSnapshot,
10222
10456
  redoConflictsSnapshot,
10223
10457
  parents: [],
10224
- view: landingView,
10458
+ view: { ...landingView, fileSnapshot: diskSnapshotBeforeThisOp },
10225
10459
  });
10226
10460
 
10227
10461
  const restoredFileCount =
@@ -10315,6 +10549,15 @@ async function createJJ(options) {
10315
10549
  await baseGraph.updateChange(snapshot);
10316
10550
  }
10317
10551
  }
10552
+
10553
+ // Capture disk state right before THIS redo() mutates it — see issue
10554
+ // #43(1)/the matching fix in undo(): `view: redoneView` below reuses
10555
+ // the ORIGINAL (re-applied) operation's own recorded pointers, but
10556
+ // its `fileSnapshot` sub-field must reflect disk state before THIS
10557
+ // redo ran, not before that original operation ran, or a later
10558
+ // restore()/undo() scanning forward past this redo would pick up a
10559
+ // stale, wrong snapshot.
10560
+ const diskSnapshotBeforeThisOp = await snapshotFilesystem();
10318
10561
  if (undoOp.redoFileSnapshot !== undefined) {
10319
10562
  await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
10320
10563
  }
@@ -10335,7 +10578,7 @@ async function createJJ(options) {
10335
10578
  redoOf: undoOp.id,
10336
10579
  changeSnapshot: undoChangeSnapshot,
10337
10580
  parents: [],
10338
- view: redoneView,
10581
+ view: { ...redoneView, fileSnapshot: diskSnapshotBeforeThisOp },
10339
10582
  });
10340
10583
 
10341
10584
  const restoredFileCount =
@@ -10641,6 +10884,17 @@ async function createJJ(options) {
10641
10884
  if (current) changeSnapshot[changeId] = current;
10642
10885
  }
10643
10886
 
10887
+ // Capture disk state right before THIS restore() mutates it — see
10888
+ // issue #43(1)/the matching fix in undo()/redo(): recording
10889
+ // `view: targetOp.view` verbatim reused targetOp's OWN recorded
10890
+ // `fileSnapshot` (disk state before targetOp ran), not disk state
10891
+ // before THIS restore ran. A later restore()/undo() scanning
10892
+ // forward past this one would then pick up that stale, wrong
10893
+ // snapshot instead of what was actually on disk right before this
10894
+ // restore executed — this is exactly the "forward restore to
10895
+ // 'edit tip' lost style.css" repro.
10896
+ const diskSnapshotBeforeThisOp = await snapshotFilesystem();
10897
+
10644
10898
  await applyGraphReversal(reversal);
10645
10899
 
10646
10900
  // Record this restoration as a new operation
@@ -10650,7 +10904,7 @@ async function createJJ(options) {
10650
10904
  description: `restore to operation ${args.operation}`,
10651
10905
  parents: [],
10652
10906
  changeSnapshot,
10653
- view: targetOp.view,
10907
+ view: { ...targetOp.view, fileSnapshot: diskSnapshotBeforeThisOp },
10654
10908
  });
10655
10909
 
10656
10910
  return {
@@ -10886,6 +11140,15 @@ async function createJJ(options) {
10886
11140
  await workingCopy.load();
10887
11141
  await userConfig.load();
10888
11142
 
11143
+ // Capture disk state right before this squash mutates anything, so
11144
+ // undo()/redo()/operations.restore() can rewind past it — see issue
11145
+ // #43(1): squash() previously recorded no "files before" snapshot at
11146
+ // all, so restoring to a point right before a squash ran had no
11147
+ // recorded on-disk state and would fall through to whatever a LATER
11148
+ // operation happened to record instead (the repro: a forward
11149
+ // restore to "edit tip" lost `style.css`).
11150
+ const fileSnapshotBefore = await snapshotFilesystem();
11151
+
10889
11152
  // Support 'into' as alias for 'dest' (matches JJ CLI)
10890
11153
  const dest = args.into || args.dest;
10891
11154
 
@@ -11017,6 +11280,18 @@ async function createJJ(options) {
11017
11280
  // every on-disk file as untracked/new until something re-wrote
11018
11281
  // it and triggered a fresh auto-snapshot.
11019
11282
  fileSnapshot: { ...destChange.fileSnapshot },
11283
+ // Marks this as an internal, synthetic artifact rather than
11284
+ // something the user actually created — issue #43(2): if this
11285
+ // squash is later undone, this change becomes unreferenced, and
11286
+ // by then `destChange` may have been reverted to pre-squash
11287
+ // content, making this change look "non-empty" relative to its
11288
+ // (now-reverted) parent even though it never held any real user
11289
+ // content of its own. The revset engine's orphan-visibility
11290
+ // filter (_computeHiddenOrphans) treats `autoCreated` as
11291
+ // sufficient on its own (given it's also unreferenced and
11292
+ // undescribed) rather than re-diffing against a parent that may
11293
+ // no longer resemble what this change was actually created from.
11294
+ autoCreated: true,
11020
11295
  };
11021
11296
 
11022
11297
  await graph.addChange(newChange); // Middleware will sync to Git
@@ -11039,8 +11314,18 @@ async function createJJ(options) {
11039
11314
  view: {
11040
11315
  bookmarks: {},
11041
11316
  remoteBookmarks: {},
11042
- heads: [destChangeId],
11317
+ // issue #43(1): this used to hard-code `[destChangeId]` — wrong
11318
+ // whenever squash() created a fresh working-copy change on top
11319
+ // of dest (the real new tip was `newWorkingCopyId`, not
11320
+ // `destChangeId` itself), and incomplete whenever a sibling head
11321
+ // elsewhere in the graph existed and wasn't touched by this
11322
+ // squash at all ("two sibling changes report only one head
11323
+ // between them"). computeCurrentHeads() reflects the WHOLE
11324
+ // repo's actual head set after this squash, not just the
11325
+ // changeId(s) this call happened to touch.
11326
+ heads: await computeCurrentHeads(),
11043
11327
  workingCopy: newWorkingCopyId,
11328
+ fileSnapshot: fileSnapshotBefore,
11044
11329
  },
11045
11330
  });
11046
11331
 
@@ -11112,6 +11397,12 @@ async function createJJ(options) {
11112
11397
  // abandoning any OTHER change must leave @ exactly where it was.
11113
11398
  const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
11114
11399
 
11400
+ // Capture disk state right before this abandon mutates anything, so
11401
+ // undo()/redo()/operations.restore() can rewind past it — see issue
11402
+ // #43(1): abandon() previously recorded no "files before" snapshot
11403
+ // at all.
11404
+ const fileSnapshotBefore = await snapshotFilesystem();
11405
+
11115
11406
  // Dispatch change:abandoning event (preventable)
11116
11407
  await dispatchEventAsync(jj, 'change:abandoning', {
11117
11408
  operation: 'abandon',
@@ -11146,8 +11437,16 @@ async function createJJ(options) {
11146
11437
  view: {
11147
11438
  bookmarks: {},
11148
11439
  remoteBookmarks: {},
11149
- heads: [],
11440
+ // issue #43(1): this used to hard-code `[]`, dropping every live
11441
+ // head from the recorded operation (including any untouched
11442
+ // sibling branch — "two sibling changes report only one head
11443
+ // between them"). computeCurrentHeads() reflects the actual
11444
+ // repo-wide head set right after this abandon (and its
11445
+ // reparenting) ran, before the compensating jj.new() below (if
11446
+ // any) records its own separate operation.
11447
+ heads: await computeCurrentHeads(),
11150
11448
  workingCopy: workingCopy.getCurrentChangeId(),
11449
+ fileSnapshot: fileSnapshotBefore,
11151
11450
  },
11152
11451
  });
11153
11452
 
@@ -12210,9 +12509,27 @@ async function createJJ(options) {
12210
12509
  * @private
12211
12510
  * @param {string} ancestorId - The ancestor change that was updated
12212
12511
  * @param {string} stopAtChangeId - Don't rebuild this change (usually working copy)
12213
- * @deprecated Use _rebuildDescendantsWithStates instead
12512
+ * @param {Record<string, string>} [originalAncestorSnapshot] - The
12513
+ * ancestor's fileSnapshot as it was BEFORE the caller's own
12514
+ * `graph.updateChange()` for `ancestorId` ran. Callers must invoke this
12515
+ * function AFTER committing the ancestor's new content (that's the
12516
+ * natural order — the ancestor's content must exist before its
12517
+ * descendants can be rebased onto it), but that means a live
12518
+ * `graph.getChange(ancestorId).fileSnapshot` read inside this function
12519
+ * would already see the NEW content. For the ancestor's DIRECT
12520
+ * children, this function compares each descendant's stale content
12521
+ * against "the original parent state" to figure out which lines are
12522
+ * the descendant's own edits; if that "original" state were actually
12523
+ * the already-new ancestor content, every line of the stale
12524
+ * descendant would look like a deliberate edit (stale != new) and get
12525
+ * preserved verbatim — silently reproducing the staleness instead of
12526
+ * fixing it. Passing the pre-mutation snapshot here lets the ancestor's
12527
+ * direct children be diffed against the correct baseline. Deeper
12528
+ * descendants are unaffected (their own immediate parent hasn't been
12529
+ * mutated yet when this function reads it, so the live read is
12530
+ * correct for them). Optional for backward compatibility.
12214
12531
  */
12215
- async _rebuildDescendants(ancestorId, stopAtChangeId) {
12532
+ async _rebuildDescendants(ancestorId, stopAtChangeId, originalAncestorSnapshot) {
12216
12533
  // Find all descendants of ancestorId
12217
12534
  const allChanges = graph.getAll();
12218
12535
 
@@ -12220,11 +12537,16 @@ async function createJJ(options) {
12220
12537
  const originalParentStates = new Map();
12221
12538
  for (const change of allChanges) {
12222
12539
  if (change.parents && change.parents.length > 0) {
12223
- const parent = await graph.getChange(change.parents[0]);
12540
+ const parentId = change.parents[0];
12541
+ const parent = await graph.getChange(parentId);
12224
12542
  if (parent) {
12543
+ const parentSnapshot =
12544
+ parentId === ancestorId && originalAncestorSnapshot !== undefined
12545
+ ? JSON.parse(JSON.stringify(originalAncestorSnapshot))
12546
+ : JSON.parse(JSON.stringify(parent.fileSnapshot || {}));
12225
12547
  originalParentStates.set(change.changeId, {
12226
- parentId: change.parents[0],
12227
- parentSnapshot: JSON.parse(JSON.stringify(parent.fileSnapshot || {})),
12548
+ parentId,
12549
+ parentSnapshot,
12228
12550
  });
12229
12551
  }
12230
12552
  }
@@ -12294,30 +12616,78 @@ async function createJJ(options) {
12294
12616
  const allFiles = new Set([
12295
12617
  ...Object.keys(descendant.fileSnapshot || {}),
12296
12618
  ...Object.keys(parent.fileSnapshot || {}),
12619
+ ...Object.keys(originalParentSnapshot || {}),
12297
12620
  ]);
12298
12621
 
12299
12622
  for (const filePath of allFiles) {
12623
+ const descendantExists = Object.prototype.hasOwnProperty.call(
12624
+ descendant.fileSnapshot || {},
12625
+ filePath
12626
+ );
12627
+ const originalParentExists = Object.prototype.hasOwnProperty.call(
12628
+ originalParentSnapshot || {},
12629
+ filePath
12630
+ );
12631
+ const updatedParentExists = Object.prototype.hasOwnProperty.call(
12632
+ parent.fileSnapshot || {},
12633
+ filePath
12634
+ );
12635
+
12636
+ // A file's EXISTENCE is a separate question from its content —
12637
+ // coercing "doesn't exist" to '' before diffing (the previous
12638
+ // version of this loop did) means a descendant that deliberately
12639
+ // deleted a file the old parent had gets that file resurrected as
12640
+ // an empty string once rebuilt, instead of staying deleted. This
12641
+ // was a real, latent bug: nothing called this function at all
12642
+ // until issue #45 gave it a real caller, so it was never
12643
+ // exercised against a deletion before.
12644
+ if (
12645
+ descendantExists === originalParentExists &&
12646
+ (!descendantExists ||
12647
+ descendant.fileSnapshot[filePath] === originalParentSnapshot[filePath])
12648
+ ) {
12649
+ // Descendant never touched this file relative to its old
12650
+ // parent (same existence + content as before) -- just follow
12651
+ // whatever the updated parent did with it (added, removed, or
12652
+ // changed since the descendant branched off).
12653
+ if (updatedParentExists) newSnapshot[filePath] = parent.fileSnapshot[filePath];
12654
+ continue; // else: stays deleted -- don't add it to newSnapshot
12655
+ }
12656
+
12657
+ if (originalParentExists && !descendantExists) {
12658
+ // Descendant deliberately deleted a file the old parent had —
12659
+ // preserve that deletion rather than resurrecting it.
12660
+ continue;
12661
+ }
12662
+
12663
+ if (!originalParentExists && descendantExists && !updatedParentExists) {
12664
+ // Descendant added a file that didn't exist on either side of
12665
+ // the parent at all -- keep the descendant's own addition.
12666
+ newSnapshot[filePath] = descendant.fileSnapshot[filePath];
12667
+ continue;
12668
+ }
12669
+
12670
+ // Both the descendant and the parent have real content for this
12671
+ // file (whether or not it existed originally) -- do the actual
12672
+ // line-level replay: find which lines the descendant changed
12673
+ // from the OLD parent, then reapply just those lines onto the
12674
+ // NEW parent's content.
12300
12675
  const descendantContent = descendant.fileSnapshot[filePath] || '';
12301
12676
  const originalParentContent = originalParentSnapshot[filePath] || '';
12302
12677
  const updatedParentContent = parent.fileSnapshot[filePath] || '';
12303
12678
 
12304
- // Find which lines descendant modified from original parent
12305
12679
  const descendantLines = descendantContent.split('\n');
12306
12680
  const originalParentLines = originalParentContent.split('\n');
12307
12681
  const updatedParentLines = updatedParentContent.split('\n');
12308
12682
 
12309
- // Rebuild file: start with updated parent, apply descendant's modifications
12310
12683
  const resultLines = [...updatedParentLines];
12311
12684
 
12312
- // Find lines that descendant modified
12313
12685
  for (let i = 0; i < Math.max(descendantLines.length, originalParentLines.length); i++) {
12314
12686
  const descendantLine = i < descendantLines.length ? descendantLines[i] : undefined;
12315
12687
  const originalParentLine =
12316
12688
  i < originalParentLines.length ? originalParentLines[i] : undefined;
12317
12689
 
12318
- // If descendant modified this line from original parent, apply that modification
12319
12690
  if (descendantLine !== originalParentLine) {
12320
- // Extend resultLines if needed
12321
12691
  while (resultLines.length <= i) {
12322
12692
  resultLines.push('');
12323
12693
  }
@@ -13409,6 +13779,32 @@ async function createJJ(options) {
13409
13779
  throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
13410
13780
  }
13411
13781
 
13782
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore()
13783
+ // can reverse this resolution — issue #43(4): conflicts.resolve()
13784
+ // previously recorded no operation at all, so resolving a conflict
13785
+ // could never be undone: no changeSnapshot of the working-copy
13786
+ // change's content, no conflictsSnapshot of the pre-resolve
13787
+ // conflict state, and no fileSnapshot of the on-disk content
13788
+ // before the resolved content was written.
13789
+ await graph.load();
13790
+ await workingCopy.load();
13791
+ // structuredClone matters here (unlike the read-only conflictsSnapshot
13792
+ // capture in moveChange()/merge()): resolveConflict() below mutates
13793
+ // the SAME conflict object in place (`conflict.resolved = true`),
13794
+ // and Object.fromEntries() on the live Map would otherwise leave
13795
+ // this "before" snapshot holding a reference to that very object —
13796
+ // corrupting it into already showing `resolved: true` by the time
13797
+ // it's recorded.
13798
+ const conflictsSnapshotBefore = structuredClone({
13799
+ conflicts: Object.fromEntries(conflicts.conflicts),
13800
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
13801
+ });
13802
+ const fileSnapshotBefore = await snapshotFilesystem();
13803
+ const wcId = workingCopy.getCurrentChangeId();
13804
+ const wcChange = await graph.getChange(wcId);
13805
+ /** @type {Record<string, any>} */
13806
+ const changeSnapshot = wcChange ? { [wcId]: structuredClone(wcChange) } : {};
13807
+
13412
13808
  let resolvedContent;
13413
13809
 
13414
13810
  // v0.5: Support different resolution methods
@@ -13452,6 +13848,36 @@ async function createJJ(options) {
13452
13848
  await conflicts.resolveConflict(args.conflictId, 'manual');
13453
13849
  await conflicts.save();
13454
13850
 
13851
+ // Refresh the working-copy change's own fileSnapshot to match the
13852
+ // resolved on-disk content (conflicted files always live under
13853
+ // the checked-out working copy — see the writeFile above), so
13854
+ // undo()/restore() has an accurate "after" state to compare
13855
+ // against, and log()/read() see the resolution immediately rather
13856
+ // than waiting for the next auto-snapshot.
13857
+ if (wcChange) {
13858
+ wcChange.fileSnapshot = {
13859
+ ...(wcChange.fileSnapshot || {}),
13860
+ [conflict.path]: resolvedContent,
13861
+ };
13862
+ await graph.updateChange(wcChange);
13863
+ }
13864
+
13865
+ await oplog.recordOperation({
13866
+ timestamp: new Date().toISOString(),
13867
+ user: await getUserOplogInfo(),
13868
+ description: `resolve conflict ${args.conflictId} (${conflict.path})`,
13869
+ parents: [],
13870
+ changeSnapshot,
13871
+ conflictsSnapshot: conflictsSnapshotBefore,
13872
+ view: {
13873
+ bookmarks: {},
13874
+ remoteBookmarks: {},
13875
+ heads: await computeCurrentHeads(),
13876
+ workingCopy: wcId,
13877
+ fileSnapshot: fileSnapshotBefore,
13878
+ },
13879
+ });
13880
+
13455
13881
  return { resolved: true };
13456
13882
  },
13457
13883