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