isomorphic-jj 1.9.0 → 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/dist/index.cjs CHANGED
@@ -991,6 +991,44 @@ class ChangeGraph {
991
991
  */
992
992
 
993
993
 
994
+ /**
995
+ * Normalize an mtime value to a millisecond epoch number so two stat()
996
+ * results (or a stat() result and a stored value) can be compared by VALUE
997
+ * instead of by object identity (see issue #41).
998
+ *
999
+ * `fs.promises.stat()` returns a fresh `Date` instance on every call, so two
1000
+ * separate stats of the same unchanged file are never `===`/`!==`-equal even
1001
+ * when the underlying timestamp is identical. Additionally, once a tracked
1002
+ * mtime has been through a `JSON.stringify`/`JSON.parse` round-trip (Storage
1003
+ * persists working-copy state as JSON), a `Date` becomes an ISO string, not a
1004
+ * `Date` — so the stored side of the comparison may be a `Date`, a plain
1005
+ * epoch-ms number (this module now stores numbers going forward), or an ISO
1006
+ * string (older/reloaded-from-disk state). This normalizes all three to a
1007
+ * comparable number.
1008
+ *
1009
+ * Deliberately duck-types the Date case (`typeof value.getTime ===
1010
+ * 'function'`) instead of `value instanceof Date`: Node's built-in `fs`
1011
+ * module constructs `Stats.mtime` using its own realm's `Date`, which is not
1012
+ * always the same `Date` constructor as this module's under test runners
1013
+ * that sandbox each test file in its own VM context (observed under Jest
1014
+ * with `--experimental-vm-modules`) — there, a real `Date` instance from
1015
+ * `fs.promises.stat()` fails `instanceof Date` here even though it's a
1016
+ * genuine Date. Duck-typing survives that realm mismatch.
1017
+ *
1018
+ * @param {Date|number|string|undefined} value
1019
+ * @returns {number}
1020
+ */
1021
+ function mtimeMs(value) {
1022
+ if (value === null || value === undefined) return NaN;
1023
+ if (typeof value === 'number') return value;
1024
+ if (typeof value === 'string') return new Date(value).getTime();
1025
+ // Per the type contract above, anything left is Date-like -- duck-typed
1026
+ // (see the comment above) rather than `instanceof Date`, so no runtime
1027
+ // check is needed here either; a value outside this contract is a caller
1028
+ // bug, not something to silently coerce.
1029
+ return /** @type {any} */ (value).getTime();
1030
+ }
1031
+
994
1032
  class WorkingCopy {
995
1033
  /**
996
1034
  * @param {Storage} storage - Storage manager instance
@@ -1162,8 +1200,10 @@ class WorkingCopy {
1162
1200
  try {
1163
1201
  const stats = await this.fs.promises.stat(fullPath);
1164
1202
 
1165
- // Fast path: check mtime and size
1166
- if (stats.mtime !== tracked.mtime || stats.size !== tracked.size) {
1203
+ // Fast path: check mtime and size. Compare by VALUE (see mtimeMs
1204
+ // above) — comparing the raw stat()/stored values with !== is
1205
+ // always "different" even when nothing changed (issue #41).
1206
+ if (mtimeMs(stats.mtime) !== mtimeMs(tracked.mtime) || stats.size !== tracked.size) {
1167
1207
  modified.push(path);
1168
1208
  }
1169
1209
  } catch (error) {
@@ -1274,12 +1314,19 @@ class WorkingCopy {
1274
1314
  }
1275
1315
 
1276
1316
  const prev = this.state.fileStates[rel];
1277
- const next = { mtime: stats.mtime, size: stats.size, mode: stats.mode };
1317
+ // Store mtime as a plain epoch-ms number (not the raw stat() value):
1318
+ // numbers round-trip through JSON exactly, so state reloaded from disk
1319
+ // later compares identically to state still cached in-memory. Goes
1320
+ // through mtimeMs() rather than assuming `.getTime()` exists directly —
1321
+ // some fs implementations (e.g. the in-memory mock used in tests)
1322
+ // return mtime as a plain epoch-ms number already, not a Date (see
1323
+ // mtimeMs above and issue #41).
1324
+ const next = { mtime: mtimeMs(stats.mtime), size: stats.size, mode: stats.mode };
1278
1325
 
1279
1326
  if (!prev) {
1280
1327
  this.state.fileStates[rel] = next;
1281
1328
  added.push(rel);
1282
- } else if (prev.mtime !== stats.mtime || prev.size !== stats.size) {
1329
+ } else if (mtimeMs(stats.mtime) !== mtimeMs(prev.mtime) || prev.size !== stats.size) {
1283
1330
  this.state.fileStates[rel] = next;
1284
1331
  modified.push(rel);
1285
1332
  }
@@ -2668,9 +2715,12 @@ class RevsetEngine {
2668
2715
  const unquote = (/** @type {string} */ s) => s.trim().replace(/^['"]|['"]$/g, '');
2669
2716
 
2670
2717
  switch (name) {
2671
- case 'all':
2718
+ case 'all': {
2672
2719
  await this.graph.load();
2673
- return this.graph.getAll().map((c) => c.changeId);
2720
+ const allChanges = this.graph.getAll();
2721
+ const hidden = await this._computeHiddenOrphans(allChanges);
2722
+ return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
2723
+ }
2674
2724
 
2675
2725
  // `builtin_log()` (jj v0.44) is the revset the built-in `jj log` uses
2676
2726
  // when no user override is configured. isomorphic-jj's `log()` already
@@ -2678,9 +2728,12 @@ class RevsetEngine {
2678
2728
  // `builtin_log()` is a pure alias for that same default — this lets
2679
2729
  // callers compose it (e.g. `builtin_log() & mine()`) instead of
2680
2730
  // duplicating `all()`.
2681
- case 'builtin_log':
2731
+ case 'builtin_log': {
2682
2732
  await this.graph.load();
2683
- return this.graph.getAll().map((c) => c.changeId);
2733
+ const allChanges = this.graph.getAll();
2734
+ const hidden = await this._computeHiddenOrphans(allChanges);
2735
+ return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
2736
+ }
2684
2737
 
2685
2738
  case 'none':
2686
2739
  return [];
@@ -3141,6 +3194,136 @@ class RevsetEngine {
3141
3194
  return ancestors;
3142
3195
  }
3143
3196
 
3197
+ /**
3198
+ * Change IDs that `all()`/`builtin_log()` (and therefore the default
3199
+ * `jj.log()`) should exclude — issue #37(b).
3200
+ *
3201
+ * undo()/redo()/operations.restore() deliberately don't delete a change
3202
+ * record for a `new()`/`squash()` that gets reverted — they only un-ref
3203
+ * it (restore the working-copy pointer/heads that made it reachable),
3204
+ * matching real jj where an unreachable change stays resolvable by its
3205
+ * change id (hidden, not gone) until explicitly gc'd — see
3206
+ * computeGraphReversal's doc comment in repository.js. But that means
3207
+ * the orphaned change record lingers in the graph, and before this fix
3208
+ * `all()` (a flat `graph.getAll()`) surfaced it in `log()` right
3209
+ * alongside everything still actually reachable.
3210
+ *
3211
+ * Real jj hides a commit from the default log when it is BOTH:
3212
+ * - unreachable from any visible head (not itself a head, and not an
3213
+ * ancestor of one), AND
3214
+ * - "discardable": empty (no file diff versus its own parent) and
3215
+ * undescribed.
3216
+ * Merge commits (2+ parents) are conservatively never treated as
3217
+ * "empty" here — real jj's diff-emptiness rule for merges is more
3218
+ * involved and isn't needed by anything this package creates today.
3219
+ *
3220
+ * "Visible head" here deliberately does NOT mean "any change with no
3221
+ * children" (that's what the `visible_heads()` revset function computes,
3222
+ * structurally, from the raw graph) — an orphan `new()`/`squash()`
3223
+ * change that undo() un-referenced has no children EITHER, so it would
3224
+ * trivially count as its own head under that definition and never be
3225
+ * excludable. Real jj instead tracks an explicit, maintained set of
3226
+ * heads in its View (updated as operations run — abandoning/squashing a
3227
+ * change removes it, rebasing children onto a new parent removes the
3228
+ * old parent, etc.). This package doesn't thread the operation log's
3229
+ * `view.heads` through to the revset engine, but the working-copy
3230
+ * pointer plus every bookmark/tag target is the closest available
3231
+ * proxy for "what's actually still in play" — anything only reachable
3232
+ * from those, not from every dangling graph leaf.
3233
+ *
3234
+ * @param {any[]} allChanges - `graph.getAll()`
3235
+ * @returns {Promise<Set<string>>} change IDs to exclude
3236
+ */
3237
+ async _computeHiddenOrphans(allChanges) {
3238
+ const trackedHeads = new Set();
3239
+ try {
3240
+ const wcId = this.workingCopy && this.workingCopy.getCurrentChangeId();
3241
+ if (wcId) trackedHeads.add(wcId);
3242
+ } catch {
3243
+ // working copy not loaded/available — fine, just no wc-based root.
3244
+ }
3245
+ if (this.bookmarkStore) {
3246
+ await this.bookmarkStore.load();
3247
+ for (const bookmark of await this.bookmarkStore.list()) {
3248
+ if (bookmark && bookmark.changeId) trackedHeads.add(bookmark.changeId);
3249
+ }
3250
+ }
3251
+ if (this.tagStore) {
3252
+ for (const tag of await this.tagStore.list()) {
3253
+ if (tag && tag.changeId) trackedHeads.add(tag.changeId);
3254
+ }
3255
+ }
3256
+
3257
+ const reachable = new Set();
3258
+ for (const headId of trackedHeads) {
3259
+ for (const id of await this.getAncestors(headId)) {
3260
+ reachable.add(id);
3261
+ }
3262
+ }
3263
+
3264
+ // Sentinel "no message given" descriptions this package itself writes
3265
+ // (new()'s '(no description)', squash()'s post-squash working-copy
3266
+ // change '(no description set)') are, for visibility purposes, just
3267
+ // as undescribed as a genuinely empty/missing description field.
3268
+ const UNDESCRIBED = new Set([undefined, null, '', '(no description)', '(no description set)']);
3269
+
3270
+ const byId = new Map(allChanges.map((c) => [c.changeId, c]));
3271
+ const isEmpty = (/** @type {any} */ change) => {
3272
+ const parents = change.parents || [];
3273
+ if (parents.length === 0) {
3274
+ return !change.fileSnapshot || Object.keys(change.fileSnapshot).length === 0;
3275
+ }
3276
+ if (parents.length > 1) return false; // merges: not handled by this rule
3277
+ const parent = byId.get(parents[0]);
3278
+ const parentSnapshot = (parent && parent.fileSnapshot) || {};
3279
+ const ownSnapshot = change.fileSnapshot || {};
3280
+ const parentKeys = Object.keys(parentSnapshot);
3281
+ const ownKeys = Object.keys(ownSnapshot);
3282
+ if (parentKeys.length !== ownKeys.length) return false;
3283
+ return parentKeys.every((k) => parentSnapshot[k] === ownSnapshot[k]);
3284
+ };
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.
3314
+ const hidden = new Set();
3315
+ for (const change of allChanges) {
3316
+ if (
3317
+ !reachable.has(change.changeId) &&
3318
+ (isEmpty(change) || change.autoCreated) &&
3319
+ UNDESCRIBED.has(change.description)
3320
+ ) {
3321
+ hidden.add(change.changeId);
3322
+ }
3323
+ }
3324
+ return hidden;
3325
+ }
3326
+
3144
3327
  /**
3145
3328
  * Resolve a revset argument to a set of change IDs (v1.5).
3146
3329
  *
@@ -7899,7 +8082,16 @@ async function createJJ(options) {
7899
8082
  await graph.load();
7900
8083
  for (const [changeId, snapshot] of Object.entries(reversal.changeSnapshotToApply)) {
7901
8084
  if (await graph.getChange(changeId)) {
7902
- await graph.updateChange(snapshot);
8085
+ // Bypass the git-sync middleware (baseGraph, not the wrapped
8086
+ // `graph`): undo/restore are pure reverts, and `snapshot` already
8087
+ // carries the change's ORIGINAL commitId from before the reverted
8088
+ // operation(s) ran. Going through graph.updateChange() would fire
8089
+ // onUpdateChange -> syncChangeToGit(), which mints a brand-new
8090
+ // commit (and thus a new commitId) from the CURRENT tree instead
8091
+ // of restoring the one that was actually there — see issue #37(c):
8092
+ // "undo/restore give reverted commits new commit IDs". Writing the
8093
+ // snapshot verbatim via baseGraph puts the original commitId back.
8094
+ await baseGraph.updateChange(snapshot);
7903
8095
  }
7904
8096
  }
7905
8097
 
@@ -7923,6 +8115,38 @@ async function createJJ(options) {
7923
8115
  // an earlier, unrelated merge(). Fixed as part of issue #29.)
7924
8116
  };
7925
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
+
7926
8150
  /**
7927
8151
  * Re-parent every child of `changeId` onto `newParents` instead — used
7928
8152
  * when a change is being elided from the graph (abandon(), issue #30;
@@ -7954,6 +8178,18 @@ async function createJJ(options) {
7954
8178
  }
7955
8179
  };
7956
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
+
7957
8193
  /**
7958
8194
  * Determine which operation a fresh undo() call should revert, matching
7959
8195
  * real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
@@ -8389,6 +8625,17 @@ async function createJJ(options) {
8389
8625
  });
8390
8626
  }
8391
8627
 
8628
+ await workingCopy.load();
8629
+
8630
+ // Capture disk state right before this write mutates it, and record
8631
+ // an operation for it (issue #43(1)): write() previously recorded NO
8632
+ // operation at all, so an edit typed into a change's working copy
8633
+ // could never be rewound by undo()/operations.restore() regardless
8634
+ // of which op you targeted — the change kept its post-edit content
8635
+ // forever. Mirrors the "snapshot before, record after" pattern every
8636
+ // other mutator in this file uses (see describe(), moveChange()).
8637
+ const fileSnapshotBefore = await snapshotFilesystem();
8638
+
8392
8639
  const fullPath = `${dir}/${args.path}`;
8393
8640
 
8394
8641
  // Ensure directory exists
@@ -8404,7 +8651,6 @@ async function createJJ(options) {
8404
8651
  await fs.promises.writeFile(fullPath, data, 'utf8');
8405
8652
 
8406
8653
  // Track the file in working copy
8407
- await workingCopy.load();
8408
8654
  const stats = await fs.promises.stat(fullPath);
8409
8655
  await workingCopy.trackFile(args.path, {
8410
8656
  mtime: stats.mtime,
@@ -8412,6 +8658,20 @@ async function createJJ(options) {
8412
8658
  mode: stats.mode,
8413
8659
  });
8414
8660
 
8661
+ await oplog.recordOperation({
8662
+ timestamp: new Date().toISOString(),
8663
+ user: await getUserOplogInfo(),
8664
+ description: `write ${args.path}`,
8665
+ parents: [],
8666
+ view: {
8667
+ bookmarks: {},
8668
+ remoteBookmarks: {},
8669
+ heads: await computeCurrentHeads(),
8670
+ workingCopy: workingCopy.getCurrentChangeId(),
8671
+ fileSnapshot: fileSnapshotBefore, // Store filesystem state from before this write
8672
+ },
8673
+ });
8674
+
8415
8675
  // Return useful information about the written file
8416
8676
  return {
8417
8677
  path: args.path,
@@ -8840,6 +9100,15 @@ async function createJJ(options) {
8840
9100
  async moveChange(args) {
8841
9101
  await graph.load();
8842
9102
 
9103
+ // Capture disk state right before this rebase mutates anything, so
9104
+ // undo()/redo()/operations.restore() can rewind past it — see issue
9105
+ // #43(1): rebase()/moveChange() previously recorded no "files
9106
+ // before" snapshot at all, so restoring to a point right before a
9107
+ // rebase ran had no on-disk state to fall back to and would instead
9108
+ // pick up whatever a LATER operation happened to record (the wrong
9109
+ // point in time).
9110
+ const fileSnapshotBefore = await snapshotFilesystem();
9111
+
8843
9112
  // Support multiple parameter names for flexibility
8844
9113
  const changeId = args.changeId || args.from || args.source;
8845
9114
  const newParent = args.newParent || args.to || args.destination;
@@ -8952,6 +9221,32 @@ async function createJJ(options) {
8952
9221
  await conflicts.addConflict(conflict);
8953
9222
  }
8954
9223
 
9224
+ // Materialize real conflict-marker text into the moved change's file
9225
+ // content — issue #43(3): rebase()/moveChange() used to record the
9226
+ // conflict correctly as DATA (via conflicts.addConflict() above) but
9227
+ // left the file's content exactly as it was pre-rebase ("ours"),
9228
+ // never writing marker text into change.fileSnapshot or, when this
9229
+ // change is the checked-out working copy, onto disk. Reuses
9230
+ // conflicts.markers()'s own marker-formatting logic (the same format
9231
+ // jj.conflicts.markers() already returns) instead of re-deriving
9232
+ // marker text a second way.
9233
+ if (detectedConflicts.length > 0) {
9234
+ change.fileSnapshot = { ...(change.fileSnapshot || {}) };
9235
+ const isCheckedOut = changeId === workingCopy.getCurrentChangeId();
9236
+ for (const conflict of detectedConflicts) {
9237
+ const markerText = await jj.conflicts.markers({ conflictId: conflict.conflictId });
9238
+ change.fileSnapshot[conflict.path] = markerText;
9239
+ if (isCheckedOut) {
9240
+ const fullPath = join(dir, conflict.path);
9241
+ const pathParts = conflict.path.split('/');
9242
+ if (pathParts.length > 1) {
9243
+ await mkdirp(fs, join(dir, pathParts.slice(0, -1).join('/')));
9244
+ }
9245
+ await fs.promises.writeFile(fullPath, markerText, 'utf8');
9246
+ }
9247
+ }
9248
+ }
9249
+
8955
9250
  // Update parent
8956
9251
  change.parents = [newParent];
8957
9252
  await graph.updateChange(change);
@@ -8967,8 +9262,9 @@ async function createJJ(options) {
8967
9262
  view: {
8968
9263
  bookmarks: {},
8969
9264
  remoteBookmarks: {},
8970
- heads: [],
9265
+ heads: await computeCurrentHeads(),
8971
9266
  workingCopy: workingCopy.getCurrentChangeId(),
9267
+ fileSnapshot: fileSnapshotBefore,
8972
9268
  },
8973
9269
  });
8974
9270
 
@@ -9423,6 +9719,16 @@ async function createJJ(options) {
9423
9719
  description: args.message || '(no description)',
9424
9720
  timestamp: new Date().toISOString(),
9425
9721
  fileSnapshot: initialSnapshot, // Initialize with parent's snapshot
9722
+ // Issue #43(2): marks this as an internal, synthetic artifact
9723
+ // rather than something the user actually created, so the revset
9724
+ // engine's orphan-visibility filter (_computeHiddenOrphans) can
9725
+ // still hide it after it's un-referenced by a later undo()/
9726
+ // operations.restore(), even if some out-of-band mutation (e.g.
9727
+ // write()+snapshot() before the undo unwinds this far) has since
9728
+ // made it look "non-empty" relative to its parent. A real
9729
+ // description (args.message, or a later describe()) still makes
9730
+ // it visible regardless, via the UNDESCRIBED check.
9731
+ autoCreated: true,
9426
9732
  };
9427
9733
 
9428
9734
  // Dispatch change:creating event (preventable)
@@ -9543,17 +9849,51 @@ async function createJJ(options) {
9543
9849
  await graph.load();
9544
9850
  await workingCopy.load();
9545
9851
  const result = await workingCopy.snapshot();
9852
+
9853
+ // Disk content itself is not mutated by this call (it's a read,
9854
+ // not a write) — the same value is therefore valid both as the
9855
+ // refreshed change.fileSnapshot AND as the recorded operation's
9856
+ // `view.fileSnapshot` "before" value (nothing on disk changes
9857
+ // between the two).
9858
+ const wcId = workingCopy.getCurrentChangeId();
9859
+ const currentDiskSnapshot = await snapshotFilesystem();
9860
+
9546
9861
  // Refresh the working-copy change's content snapshot from disk.
9862
+ /** @type {Record<string, any>} */
9863
+ const changeSnapshot = {};
9547
9864
  try {
9548
- const wcId = workingCopy.getCurrentChangeId();
9549
9865
  const change = await graph.getChange(wcId);
9550
9866
  if (change) {
9551
- change.fileSnapshot = await snapshotFilesystem();
9867
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore()
9868
+ // can reverse this content refresh — see issue #43(1):
9869
+ // snapshot() previously mutated `change.fileSnapshot` in place
9870
+ // with no "before" capture and recorded no operation at all, so
9871
+ // computeGraphReversal() had nothing to find for this changeId
9872
+ // if a restore()/undo() landed between an out-of-band edit and
9873
+ // the next real command.
9874
+ changeSnapshot[wcId] = structuredClone(change);
9875
+ change.fileSnapshot = currentDiskSnapshot;
9552
9876
  await graph.updateChange(change);
9553
9877
  }
9554
9878
  } catch {
9555
9879
  // Best-effort content refresh.
9556
9880
  }
9881
+
9882
+ await oplog.recordOperation({
9883
+ timestamp: new Date().toISOString(),
9884
+ user: await getUserOplogInfo(),
9885
+ description: 'snapshot working copy',
9886
+ parents: [],
9887
+ changeSnapshot,
9888
+ view: {
9889
+ bookmarks: {},
9890
+ remoteBookmarks: {},
9891
+ heads: await computeCurrentHeads(),
9892
+ workingCopy: wcId,
9893
+ fileSnapshot: currentDiskSnapshot,
9894
+ },
9895
+ });
9896
+
9557
9897
  return result;
9558
9898
  },
9559
9899
 
@@ -10017,8 +10357,20 @@ async function createJJ(options) {
10017
10357
  const current = await graph.getChange(changeId);
10018
10358
  if (current) redoChangeSnapshot[changeId] = current;
10019
10359
  }
10360
+ // Capture disk state right before this undo() runs. Used both as
10361
+ // `redoFileSnapshot` (for a later redo() — only meaningful when this
10362
+ // reversal actually touches files) AND, unconditionally, as THIS
10363
+ // undo operation's own `view.fileSnapshot` "before" value below —
10364
+ // see issue #43(1): undo()/redo()/operations.restore() used to
10365
+ // record `view: landingView` verbatim, i.e. reusing the LANDING
10366
+ // operation's own recorded fileSnapshot (disk state before THAT op
10367
+ // ran) instead of disk state before THIS undo ran. A later
10368
+ // restore()/undo() scanning forward past this one would then pick
10369
+ // up that stale, wrong snapshot instead of what was actually on disk
10370
+ // right before this undo executed.
10371
+ const diskSnapshotBeforeThisOp = await snapshotFilesystem();
10020
10372
  const redoFileSnapshot =
10021
- reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
10373
+ reversal.fileSnapshot !== undefined ? diskSnapshotBeforeThisOp : undefined;
10022
10374
  let redoConflictsSnapshot;
10023
10375
  if (reversal.conflictsSnapshot !== undefined) {
10024
10376
  await conflicts.load();
@@ -10037,6 +10389,16 @@ async function createJJ(options) {
10037
10389
  // which operation it targeted so a following undo() can walk one
10038
10390
  // step further back (resolveUndoTarget) instead of toggling, and
10039
10391
  // carrying exactly what redo() needs to re-apply (captured above).
10392
+ //
10393
+ // Also record `changeSnapshot: redoChangeSnapshot` — the SAME
10394
+ // ground-truth-before-this-op values, but under the field name
10395
+ // computeGraphReversal() actually scans for. Without this, undo()
10396
+ // itself was the one mutating operation in the whole API that never
10397
+ // recorded its own changeSnapshot, so a LATER undo() targeting THIS
10398
+ // undo (to step back past it) had nothing to restore: it would
10399
+ // repoint the working copy and files, but leave whatever this
10400
+ // undo() overwrote (e.g. a change's description) exactly as this
10401
+ // undo() left it. See issue #37(a).
10040
10402
  await oplog.recordOperation({
10041
10403
  timestamp: new Date().toISOString(),
10042
10404
  user: await getUserOplogInfo(),
@@ -10044,11 +10406,12 @@ async function createJJ(options) {
10044
10406
  eventType: 'undo',
10045
10407
  targetOpId: targetOp.id,
10046
10408
  undoneOpId: targetOp.id, // kept for API/type compatibility
10409
+ changeSnapshot: redoChangeSnapshot,
10047
10410
  redoChangeSnapshot,
10048
10411
  redoFileSnapshot,
10049
10412
  redoConflictsSnapshot,
10050
10413
  parents: [],
10051
- view: landingView,
10414
+ view: { ...landingView, fileSnapshot: diskSnapshotBeforeThisOp },
10052
10415
  });
10053
10416
 
10054
10417
  const restoredFileCount =
@@ -10121,11 +10484,36 @@ async function createJJ(options) {
10121
10484
  // by scanning forward. undo() captured it directly for exactly this
10122
10485
  // reason (see its own comment).
10123
10486
  await graph.load();
10487
+
10488
+ // Capture ground truth right before overwriting, same as undo()
10489
+ // does for its own changeSnapshot (issue #37a) — lets a later
10490
+ // undo() targeting THIS redo op restore what redo() is about to
10491
+ // clobber, instead of finding no changeSnapshot at all.
10492
+ /** @type {Record<string, any>} */
10493
+ const undoChangeSnapshot = {};
10494
+ for (const changeId of Object.keys(undoOp.redoChangeSnapshot || {})) {
10495
+ const current = await graph.getChange(changeId);
10496
+ if (current) undoChangeSnapshot[changeId] = current;
10497
+ }
10498
+
10124
10499
  for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
10125
10500
  if (await graph.getChange(changeId)) {
10126
- await graph.updateChange(snapshot);
10127
- }
10128
- }
10501
+ // Bypass the git-sync middleware — see the matching comment in
10502
+ // applyGraphReversal(). `snapshot` already carries the change's
10503
+ // correct commitId from before the undo it's reversing ran
10504
+ // (issue #37c applies to redo() too).
10505
+ await baseGraph.updateChange(snapshot);
10506
+ }
10507
+ }
10508
+
10509
+ // Capture disk state right before THIS redo() mutates it — see issue
10510
+ // #43(1)/the matching fix in undo(): `view: redoneView` below reuses
10511
+ // the ORIGINAL (re-applied) operation's own recorded pointers, but
10512
+ // its `fileSnapshot` sub-field must reflect disk state before THIS
10513
+ // redo ran, not before that original operation ran, or a later
10514
+ // restore()/undo() scanning forward past this redo would pick up a
10515
+ // stale, wrong snapshot.
10516
+ const diskSnapshotBeforeThisOp = await snapshotFilesystem();
10129
10517
  if (undoOp.redoFileSnapshot !== undefined) {
10130
10518
  await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
10131
10519
  }
@@ -10144,8 +10532,9 @@ async function createJJ(options) {
10144
10532
  description: 'redo operation',
10145
10533
  eventType: 'redo',
10146
10534
  redoOf: undoOp.id,
10535
+ changeSnapshot: undoChangeSnapshot,
10147
10536
  parents: [],
10148
- view: redoneView,
10537
+ view: { ...redoneView, fileSnapshot: diskSnapshotBeforeThisOp },
10149
10538
  });
10150
10539
 
10151
10540
  const restoredFileCount =
@@ -10434,6 +10823,34 @@ async function createJJ(options) {
10434
10823
  // Revert the change graph, working-copy files, and conflict state
10435
10824
  // to exactly what they were right after targetOp ran.
10436
10825
  const reversal = computeGraphReversal(ops, targetOp);
10826
+
10827
+ // Capture ground truth for every changeId this restore is about to
10828
+ // overwrite, BEFORE applying the reversal — exactly like undo()
10829
+ // does for its own changeSnapshot. Without this, restore()'s own
10830
+ // oplog entry carried no changeSnapshot at all, so undoing the
10831
+ // restore itself had nothing to put back: a later undo() would
10832
+ // repoint the working copy and files, but leave whatever the
10833
+ // restore had just overwritten (e.g. a change's description)
10834
+ // exactly as the restore left it. See issue #37(a).
10835
+ await graph.load();
10836
+ /** @type {Record<string, any>} */
10837
+ const changeSnapshot = {};
10838
+ for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
10839
+ const current = await graph.getChange(changeId);
10840
+ if (current) changeSnapshot[changeId] = current;
10841
+ }
10842
+
10843
+ // Capture disk state right before THIS restore() mutates it — see
10844
+ // issue #43(1)/the matching fix in undo()/redo(): recording
10845
+ // `view: targetOp.view` verbatim reused targetOp's OWN recorded
10846
+ // `fileSnapshot` (disk state before targetOp ran), not disk state
10847
+ // before THIS restore ran. A later restore()/undo() scanning
10848
+ // forward past this one would then pick up that stale, wrong
10849
+ // snapshot instead of what was actually on disk right before this
10850
+ // restore executed — this is exactly the "forward restore to
10851
+ // 'edit tip' lost style.css" repro.
10852
+ const diskSnapshotBeforeThisOp = await snapshotFilesystem();
10853
+
10437
10854
  await applyGraphReversal(reversal);
10438
10855
 
10439
10856
  // Record this restoration as a new operation
@@ -10442,7 +10859,8 @@ async function createJJ(options) {
10442
10859
  user: await getUserOplogInfo(),
10443
10860
  description: `restore to operation ${args.operation}`,
10444
10861
  parents: [],
10445
- view: targetOp.view,
10862
+ changeSnapshot,
10863
+ view: { ...targetOp.view, fileSnapshot: diskSnapshotBeforeThisOp },
10446
10864
  });
10447
10865
 
10448
10866
  return {
@@ -10678,6 +11096,15 @@ async function createJJ(options) {
10678
11096
  await workingCopy.load();
10679
11097
  await userConfig.load();
10680
11098
 
11099
+ // Capture disk state right before this squash mutates anything, so
11100
+ // undo()/redo()/operations.restore() can rewind past it — see issue
11101
+ // #43(1): squash() previously recorded no "files before" snapshot at
11102
+ // all, so restoring to a point right before a squash ran had no
11103
+ // recorded on-disk state and would fall through to whatever a LATER
11104
+ // operation happened to record instead (the repro: a forward
11105
+ // restore to "edit tip" lost `style.css`).
11106
+ const fileSnapshotBefore = await snapshotFilesystem();
11107
+
10681
11108
  // Support 'into' as alias for 'dest' (matches JJ CLI)
10682
11109
  const dest = args.into || args.dest;
10683
11110
 
@@ -10801,10 +11228,34 @@ async function createJJ(options) {
10801
11228
  },
10802
11229
  description: '(no description set)',
10803
11230
  timestamp: new Date().toISOString(),
10804
- fileSnapshot: {},
11231
+ // Start from the DESTINATION's tree (which now includes source's
11232
+ // folded-in changes, set above), not an empty object — matches
11233
+ // real `jj squash`, where the new working-copy change looks
11234
+ // identical to its new parent until you touch something. Issue
11235
+ // #39: this used to hard-code `{}`, so status()/snapshot() saw
11236
+ // every on-disk file as untracked/new until something re-wrote
11237
+ // it and triggered a fresh auto-snapshot.
11238
+ fileSnapshot: { ...destChange.fileSnapshot },
11239
+ // Marks this as an internal, synthetic artifact rather than
11240
+ // something the user actually created — issue #43(2): if this
11241
+ // squash is later undone, this change becomes unreferenced, and
11242
+ // by then `destChange` may have been reverted to pre-squash
11243
+ // content, making this change look "non-empty" relative to its
11244
+ // (now-reverted) parent even though it never held any real user
11245
+ // content of its own. The revset engine's orphan-visibility
11246
+ // filter (_computeHiddenOrphans) treats `autoCreated` as
11247
+ // sufficient on its own (given it's also unreferenced and
11248
+ // undescribed) rather than re-diffing against a parent that may
11249
+ // no longer resemble what this change was actually created from.
11250
+ autoCreated: true,
10805
11251
  };
10806
11252
 
10807
11253
  await graph.addChange(newChange); // Middleware will sync to Git
11254
+ // Sync the working directory to match (issue #30's fix for
11255
+ // new()/edit(); squash()'s own synthetic post-squash change never
11256
+ // did this) — a no-op when source's parent was already dest (the
11257
+ // common case), but necessary whenever source/dest aren't adjacent.
11258
+ await syncWorkingCopyFiles(newChange.fileSnapshot);
10808
11259
  await workingCopy.setCurrentChange(newChangeId);
10809
11260
  newWorkingCopyId = newChangeId;
10810
11261
  }
@@ -10819,8 +11270,18 @@ async function createJJ(options) {
10819
11270
  view: {
10820
11271
  bookmarks: {},
10821
11272
  remoteBookmarks: {},
10822
- heads: [destChangeId],
11273
+ // issue #43(1): this used to hard-code `[destChangeId]` — wrong
11274
+ // whenever squash() created a fresh working-copy change on top
11275
+ // of dest (the real new tip was `newWorkingCopyId`, not
11276
+ // `destChangeId` itself), and incomplete whenever a sibling head
11277
+ // elsewhere in the graph existed and wasn't touched by this
11278
+ // squash at all ("two sibling changes report only one head
11279
+ // between them"). computeCurrentHeads() reflects the WHOLE
11280
+ // repo's actual head set after this squash, not just the
11281
+ // changeId(s) this call happened to touch.
11282
+ heads: await computeCurrentHeads(),
10823
11283
  workingCopy: newWorkingCopyId,
11284
+ fileSnapshot: fileSnapshotBefore,
10824
11285
  },
10825
11286
  });
10826
11287
 
@@ -10886,6 +11347,18 @@ async function createJJ(options) {
10886
11347
  throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
10887
11348
  }
10888
11349
 
11350
+ // Captured BEFORE anything mutates — see issue #38: abandoning the
11351
+ // change @ currently points at must move @ off it (real jj checks
11352
+ // out a fresh empty change on the abandoned change's own parent(s));
11353
+ // abandoning any OTHER change must leave @ exactly where it was.
11354
+ const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
11355
+
11356
+ // Capture disk state right before this abandon mutates anything, so
11357
+ // undo()/redo()/operations.restore() can rewind past it — see issue
11358
+ // #43(1): abandon() previously recorded no "files before" snapshot
11359
+ // at all.
11360
+ const fileSnapshotBefore = await snapshotFilesystem();
11361
+
10889
11362
  // Dispatch change:abandoning event (preventable)
10890
11363
  await dispatchEventAsync(jj, 'change:abandoning', {
10891
11364
  operation: 'abandon',
@@ -10920,8 +11393,16 @@ async function createJJ(options) {
10920
11393
  view: {
10921
11394
  bookmarks: {},
10922
11395
  remoteBookmarks: {},
10923
- heads: [],
11396
+ // issue #43(1): this used to hard-code `[]`, dropping every live
11397
+ // head from the recorded operation (including any untouched
11398
+ // sibling branch — "two sibling changes report only one head
11399
+ // between them"). computeCurrentHeads() reflects the actual
11400
+ // repo-wide head set right after this abandon (and its
11401
+ // reparenting) ran, before the compensating jj.new() below (if
11402
+ // any) records its own separate operation.
11403
+ heads: await computeCurrentHeads(),
10924
11404
  workingCopy: workingCopy.getCurrentChangeId(),
11405
+ fileSnapshot: fileSnapshotBefore,
10925
11406
  },
10926
11407
  });
10927
11408
 
@@ -10938,6 +11419,17 @@ async function createJJ(options) {
10938
11419
  { cancelable: false }
10939
11420
  );
10940
11421
 
11422
+ // If @ was pointing at the change we just abandoned, it can't stay
11423
+ // there — real jj moves @ to a new empty change on the abandoned
11424
+ // change's own parent(s) (issue #38). Reuse new()'s own "create an
11425
+ // empty change with given parents" logic rather than duplicating it
11426
+ // — this is exactly the workaround callers had to reach for
11427
+ // themselves (`new({ parents: [parentOfAt] })` before abandoning).
11428
+ if (wasWorkingCopy) {
11429
+ const newWorkingCopyChange = await jj.new({ parents: change.parents || [] });
11430
+ return { ...change, newWorkingCopyChangeId: newWorkingCopyChange.changeId };
11431
+ }
11432
+
10941
11433
  return change;
10942
11434
  },
10943
11435
 
@@ -13172,6 +13664,32 @@ async function createJJ(options) {
13172
13664
  throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
13173
13665
  }
13174
13666
 
13667
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore()
13668
+ // can reverse this resolution — issue #43(4): conflicts.resolve()
13669
+ // previously recorded no operation at all, so resolving a conflict
13670
+ // could never be undone: no changeSnapshot of the working-copy
13671
+ // change's content, no conflictsSnapshot of the pre-resolve
13672
+ // conflict state, and no fileSnapshot of the on-disk content
13673
+ // before the resolved content was written.
13674
+ await graph.load();
13675
+ await workingCopy.load();
13676
+ // structuredClone matters here (unlike the read-only conflictsSnapshot
13677
+ // capture in moveChange()/merge()): resolveConflict() below mutates
13678
+ // the SAME conflict object in place (`conflict.resolved = true`),
13679
+ // and Object.fromEntries() on the live Map would otherwise leave
13680
+ // this "before" snapshot holding a reference to that very object —
13681
+ // corrupting it into already showing `resolved: true` by the time
13682
+ // it's recorded.
13683
+ const conflictsSnapshotBefore = structuredClone({
13684
+ conflicts: Object.fromEntries(conflicts.conflicts),
13685
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
13686
+ });
13687
+ const fileSnapshotBefore = await snapshotFilesystem();
13688
+ const wcId = workingCopy.getCurrentChangeId();
13689
+ const wcChange = await graph.getChange(wcId);
13690
+ /** @type {Record<string, any>} */
13691
+ const changeSnapshot = wcChange ? { [wcId]: structuredClone(wcChange) } : {};
13692
+
13175
13693
  let resolvedContent;
13176
13694
 
13177
13695
  // v0.5: Support different resolution methods
@@ -13215,6 +13733,36 @@ async function createJJ(options) {
13215
13733
  await conflicts.resolveConflict(args.conflictId, 'manual');
13216
13734
  await conflicts.save();
13217
13735
 
13736
+ // Refresh the working-copy change's own fileSnapshot to match the
13737
+ // resolved on-disk content (conflicted files always live under
13738
+ // the checked-out working copy — see the writeFile above), so
13739
+ // undo()/restore() has an accurate "after" state to compare
13740
+ // against, and log()/read() see the resolution immediately rather
13741
+ // than waiting for the next auto-snapshot.
13742
+ if (wcChange) {
13743
+ wcChange.fileSnapshot = {
13744
+ ...(wcChange.fileSnapshot || {}),
13745
+ [conflict.path]: resolvedContent,
13746
+ };
13747
+ await graph.updateChange(wcChange);
13748
+ }
13749
+
13750
+ await oplog.recordOperation({
13751
+ timestamp: new Date().toISOString(),
13752
+ user: await getUserOplogInfo(),
13753
+ description: `resolve conflict ${args.conflictId} (${conflict.path})`,
13754
+ parents: [],
13755
+ changeSnapshot,
13756
+ conflictsSnapshot: conflictsSnapshotBefore,
13757
+ view: {
13758
+ bookmarks: {},
13759
+ remoteBookmarks: {},
13760
+ heads: await computeCurrentHeads(),
13761
+ workingCopy: wcId,
13762
+ fileSnapshot: fileSnapshotBefore,
13763
+ },
13764
+ });
13765
+
13218
13766
  return { resolved: true };
13219
13767
  },
13220
13768