isomorphic-jj 1.9.0 → 1.9.2

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
@@ -989,6 +989,44 @@ class ChangeGraph {
989
989
  */
990
990
 
991
991
 
992
+ /**
993
+ * Normalize an mtime value to a millisecond epoch number so two stat()
994
+ * results (or a stat() result and a stored value) can be compared by VALUE
995
+ * instead of by object identity (see issue #41).
996
+ *
997
+ * `fs.promises.stat()` returns a fresh `Date` instance on every call, so two
998
+ * separate stats of the same unchanged file are never `===`/`!==`-equal even
999
+ * when the underlying timestamp is identical. Additionally, once a tracked
1000
+ * mtime has been through a `JSON.stringify`/`JSON.parse` round-trip (Storage
1001
+ * persists working-copy state as JSON), a `Date` becomes an ISO string, not a
1002
+ * `Date` — so the stored side of the comparison may be a `Date`, a plain
1003
+ * epoch-ms number (this module now stores numbers going forward), or an ISO
1004
+ * string (older/reloaded-from-disk state). This normalizes all three to a
1005
+ * comparable number.
1006
+ *
1007
+ * Deliberately duck-types the Date case (`typeof value.getTime ===
1008
+ * 'function'`) instead of `value instanceof Date`: Node's built-in `fs`
1009
+ * module constructs `Stats.mtime` using its own realm's `Date`, which is not
1010
+ * always the same `Date` constructor as this module's under test runners
1011
+ * that sandbox each test file in its own VM context (observed under Jest
1012
+ * with `--experimental-vm-modules`) — there, a real `Date` instance from
1013
+ * `fs.promises.stat()` fails `instanceof Date` here even though it's a
1014
+ * genuine Date. Duck-typing survives that realm mismatch.
1015
+ *
1016
+ * @param {Date|number|string|undefined} value
1017
+ * @returns {number}
1018
+ */
1019
+ function mtimeMs(value) {
1020
+ if (value === null || value === undefined) return NaN;
1021
+ if (typeof value === 'number') return value;
1022
+ if (typeof value === 'string') return new Date(value).getTime();
1023
+ // Per the type contract above, anything left is Date-like -- duck-typed
1024
+ // (see the comment above) rather than `instanceof Date`, so no runtime
1025
+ // check is needed here either; a value outside this contract is a caller
1026
+ // bug, not something to silently coerce.
1027
+ return /** @type {any} */ (value).getTime();
1028
+ }
1029
+
992
1030
  class WorkingCopy {
993
1031
  /**
994
1032
  * @param {Storage} storage - Storage manager instance
@@ -1160,8 +1198,10 @@ class WorkingCopy {
1160
1198
  try {
1161
1199
  const stats = await this.fs.promises.stat(fullPath);
1162
1200
 
1163
- // Fast path: check mtime and size
1164
- if (stats.mtime !== tracked.mtime || stats.size !== tracked.size) {
1201
+ // Fast path: check mtime and size. Compare by VALUE (see mtimeMs
1202
+ // above) — comparing the raw stat()/stored values with !== is
1203
+ // always "different" even when nothing changed (issue #41).
1204
+ if (mtimeMs(stats.mtime) !== mtimeMs(tracked.mtime) || stats.size !== tracked.size) {
1165
1205
  modified.push(path);
1166
1206
  }
1167
1207
  } catch (error) {
@@ -1272,12 +1312,19 @@ class WorkingCopy {
1272
1312
  }
1273
1313
 
1274
1314
  const prev = this.state.fileStates[rel];
1275
- const next = { mtime: stats.mtime, size: stats.size, mode: stats.mode };
1315
+ // Store mtime as a plain epoch-ms number (not the raw stat() value):
1316
+ // numbers round-trip through JSON exactly, so state reloaded from disk
1317
+ // later compares identically to state still cached in-memory. Goes
1318
+ // through mtimeMs() rather than assuming `.getTime()` exists directly —
1319
+ // some fs implementations (e.g. the in-memory mock used in tests)
1320
+ // return mtime as a plain epoch-ms number already, not a Date (see
1321
+ // mtimeMs above and issue #41).
1322
+ const next = { mtime: mtimeMs(stats.mtime), size: stats.size, mode: stats.mode };
1276
1323
 
1277
1324
  if (!prev) {
1278
1325
  this.state.fileStates[rel] = next;
1279
1326
  added.push(rel);
1280
- } else if (prev.mtime !== stats.mtime || prev.size !== stats.size) {
1327
+ } else if (mtimeMs(stats.mtime) !== mtimeMs(prev.mtime) || prev.size !== stats.size) {
1281
1328
  this.state.fileStates[rel] = next;
1282
1329
  modified.push(rel);
1283
1330
  }
@@ -2666,9 +2713,12 @@ class RevsetEngine {
2666
2713
  const unquote = (/** @type {string} */ s) => s.trim().replace(/^['"]|['"]$/g, '');
2667
2714
 
2668
2715
  switch (name) {
2669
- case 'all':
2716
+ case 'all': {
2670
2717
  await this.graph.load();
2671
- return this.graph.getAll().map((c) => c.changeId);
2718
+ const allChanges = this.graph.getAll();
2719
+ const hidden = await this._computeHiddenOrphans(allChanges);
2720
+ return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
2721
+ }
2672
2722
 
2673
2723
  // `builtin_log()` (jj v0.44) is the revset the built-in `jj log` uses
2674
2724
  // when no user override is configured. isomorphic-jj's `log()` already
@@ -2676,9 +2726,12 @@ class RevsetEngine {
2676
2726
  // `builtin_log()` is a pure alias for that same default — this lets
2677
2727
  // callers compose it (e.g. `builtin_log() & mine()`) instead of
2678
2728
  // duplicating `all()`.
2679
- case 'builtin_log':
2729
+ case 'builtin_log': {
2680
2730
  await this.graph.load();
2681
- return this.graph.getAll().map((c) => c.changeId);
2731
+ const allChanges = this.graph.getAll();
2732
+ const hidden = await this._computeHiddenOrphans(allChanges);
2733
+ return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
2734
+ }
2682
2735
 
2683
2736
  case 'none':
2684
2737
  return [];
@@ -3139,6 +3192,108 @@ class RevsetEngine {
3139
3192
  return ancestors;
3140
3193
  }
3141
3194
 
3195
+ /**
3196
+ * Change IDs that `all()`/`builtin_log()` (and therefore the default
3197
+ * `jj.log()`) should exclude — issue #37(b).
3198
+ *
3199
+ * undo()/redo()/operations.restore() deliberately don't delete a change
3200
+ * record for a `new()`/`squash()` that gets reverted — they only un-ref
3201
+ * it (restore the working-copy pointer/heads that made it reachable),
3202
+ * matching real jj where an unreachable change stays resolvable by its
3203
+ * change id (hidden, not gone) until explicitly gc'd — see
3204
+ * computeGraphReversal's doc comment in repository.js. But that means
3205
+ * the orphaned change record lingers in the graph, and before this fix
3206
+ * `all()` (a flat `graph.getAll()`) surfaced it in `log()` right
3207
+ * alongside everything still actually reachable.
3208
+ *
3209
+ * Real jj hides a commit from the default log when it is BOTH:
3210
+ * - unreachable from any visible head (not itself a head, and not an
3211
+ * ancestor of one), AND
3212
+ * - "discardable": empty (no file diff versus its own parent) and
3213
+ * undescribed.
3214
+ * Merge commits (2+ parents) are conservatively never treated as
3215
+ * "empty" here — real jj's diff-emptiness rule for merges is more
3216
+ * involved and isn't needed by anything this package creates today.
3217
+ *
3218
+ * "Visible head" here deliberately does NOT mean "any change with no
3219
+ * children" (that's what the `visible_heads()` revset function computes,
3220
+ * structurally, from the raw graph) — an orphan `new()`/`squash()`
3221
+ * change that undo() un-referenced has no children EITHER, so it would
3222
+ * trivially count as its own head under that definition and never be
3223
+ * excludable. Real jj instead tracks an explicit, maintained set of
3224
+ * heads in its View (updated as operations run — abandoning/squashing a
3225
+ * change removes it, rebasing children onto a new parent removes the
3226
+ * old parent, etc.). This package doesn't thread the operation log's
3227
+ * `view.heads` through to the revset engine, but the working-copy
3228
+ * pointer plus every bookmark/tag target is the closest available
3229
+ * proxy for "what's actually still in play" — anything only reachable
3230
+ * from those, not from every dangling graph leaf.
3231
+ *
3232
+ * @param {any[]} allChanges - `graph.getAll()`
3233
+ * @returns {Promise<Set<string>>} change IDs to exclude
3234
+ */
3235
+ async _computeHiddenOrphans(allChanges) {
3236
+ const trackedHeads = new Set();
3237
+ try {
3238
+ const wcId = this.workingCopy && this.workingCopy.getCurrentChangeId();
3239
+ if (wcId) trackedHeads.add(wcId);
3240
+ } catch {
3241
+ // working copy not loaded/available — fine, just no wc-based root.
3242
+ }
3243
+ if (this.bookmarkStore) {
3244
+ await this.bookmarkStore.load();
3245
+ for (const bookmark of await this.bookmarkStore.list()) {
3246
+ if (bookmark && bookmark.changeId) trackedHeads.add(bookmark.changeId);
3247
+ }
3248
+ }
3249
+ if (this.tagStore) {
3250
+ for (const tag of await this.tagStore.list()) {
3251
+ if (tag && tag.changeId) trackedHeads.add(tag.changeId);
3252
+ }
3253
+ }
3254
+
3255
+ const reachable = new Set();
3256
+ for (const headId of trackedHeads) {
3257
+ for (const id of await this.getAncestors(headId)) {
3258
+ reachable.add(id);
3259
+ }
3260
+ }
3261
+
3262
+ // Sentinel "no message given" descriptions this package itself writes
3263
+ // (new()'s '(no description)', squash()'s post-squash working-copy
3264
+ // change '(no description set)') are, for visibility purposes, just
3265
+ // as undescribed as a genuinely empty/missing description field.
3266
+ const UNDESCRIBED = new Set([undefined, null, '', '(no description)', '(no description set)']);
3267
+
3268
+ const byId = new Map(allChanges.map((c) => [c.changeId, c]));
3269
+ const isEmpty = (/** @type {any} */ change) => {
3270
+ const parents = change.parents || [];
3271
+ if (parents.length === 0) {
3272
+ return !change.fileSnapshot || Object.keys(change.fileSnapshot).length === 0;
3273
+ }
3274
+ if (parents.length > 1) return false; // merges: not handled by this rule
3275
+ const parent = byId.get(parents[0]);
3276
+ const parentSnapshot = (parent && parent.fileSnapshot) || {};
3277
+ const ownSnapshot = change.fileSnapshot || {};
3278
+ const parentKeys = Object.keys(parentSnapshot);
3279
+ const ownKeys = Object.keys(ownSnapshot);
3280
+ if (parentKeys.length !== ownKeys.length) return false;
3281
+ return parentKeys.every((k) => parentSnapshot[k] === ownSnapshot[k]);
3282
+ };
3283
+
3284
+ const hidden = new Set();
3285
+ for (const change of allChanges) {
3286
+ if (
3287
+ !reachable.has(change.changeId) &&
3288
+ isEmpty(change) &&
3289
+ UNDESCRIBED.has(change.description)
3290
+ ) {
3291
+ hidden.add(change.changeId);
3292
+ }
3293
+ }
3294
+ return hidden;
3295
+ }
3296
+
3142
3297
  /**
3143
3298
  * Resolve a revset argument to a set of change IDs (v1.5).
3144
3299
  *
@@ -7897,7 +8052,16 @@ async function createJJ(options) {
7897
8052
  await graph.load();
7898
8053
  for (const [changeId, snapshot] of Object.entries(reversal.changeSnapshotToApply)) {
7899
8054
  if (await graph.getChange(changeId)) {
7900
- await graph.updateChange(snapshot);
8055
+ // Bypass the git-sync middleware (baseGraph, not the wrapped
8056
+ // `graph`): undo/restore are pure reverts, and `snapshot` already
8057
+ // carries the change's ORIGINAL commitId from before the reverted
8058
+ // operation(s) ran. Going through graph.updateChange() would fire
8059
+ // onUpdateChange -> syncChangeToGit(), which mints a brand-new
8060
+ // commit (and thus a new commitId) from the CURRENT tree instead
8061
+ // of restoring the one that was actually there — see issue #37(c):
8062
+ // "undo/restore give reverted commits new commit IDs". Writing the
8063
+ // snapshot verbatim via baseGraph puts the original commitId back.
8064
+ await baseGraph.updateChange(snapshot);
7901
8065
  }
7902
8066
  }
7903
8067
 
@@ -10035,6 +10199,16 @@ async function createJJ(options) {
10035
10199
  // which operation it targeted so a following undo() can walk one
10036
10200
  // step further back (resolveUndoTarget) instead of toggling, and
10037
10201
  // carrying exactly what redo() needs to re-apply (captured above).
10202
+ //
10203
+ // Also record `changeSnapshot: redoChangeSnapshot` — the SAME
10204
+ // ground-truth-before-this-op values, but under the field name
10205
+ // computeGraphReversal() actually scans for. Without this, undo()
10206
+ // itself was the one mutating operation in the whole API that never
10207
+ // recorded its own changeSnapshot, so a LATER undo() targeting THIS
10208
+ // undo (to step back past it) had nothing to restore: it would
10209
+ // repoint the working copy and files, but leave whatever this
10210
+ // undo() overwrote (e.g. a change's description) exactly as this
10211
+ // undo() left it. See issue #37(a).
10038
10212
  await oplog.recordOperation({
10039
10213
  timestamp: new Date().toISOString(),
10040
10214
  user: await getUserOplogInfo(),
@@ -10042,6 +10216,7 @@ async function createJJ(options) {
10042
10216
  eventType: 'undo',
10043
10217
  targetOpId: targetOp.id,
10044
10218
  undoneOpId: targetOp.id, // kept for API/type compatibility
10219
+ changeSnapshot: redoChangeSnapshot,
10045
10220
  redoChangeSnapshot,
10046
10221
  redoFileSnapshot,
10047
10222
  redoConflictsSnapshot,
@@ -10119,9 +10294,25 @@ async function createJJ(options) {
10119
10294
  // by scanning forward. undo() captured it directly for exactly this
10120
10295
  // reason (see its own comment).
10121
10296
  await graph.load();
10297
+
10298
+ // Capture ground truth right before overwriting, same as undo()
10299
+ // does for its own changeSnapshot (issue #37a) — lets a later
10300
+ // undo() targeting THIS redo op restore what redo() is about to
10301
+ // clobber, instead of finding no changeSnapshot at all.
10302
+ /** @type {Record<string, any>} */
10303
+ const undoChangeSnapshot = {};
10304
+ for (const changeId of Object.keys(undoOp.redoChangeSnapshot || {})) {
10305
+ const current = await graph.getChange(changeId);
10306
+ if (current) undoChangeSnapshot[changeId] = current;
10307
+ }
10308
+
10122
10309
  for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
10123
10310
  if (await graph.getChange(changeId)) {
10124
- await graph.updateChange(snapshot);
10311
+ // Bypass the git-sync middleware — see the matching comment in
10312
+ // applyGraphReversal(). `snapshot` already carries the change's
10313
+ // correct commitId from before the undo it's reversing ran
10314
+ // (issue #37c applies to redo() too).
10315
+ await baseGraph.updateChange(snapshot);
10125
10316
  }
10126
10317
  }
10127
10318
  if (undoOp.redoFileSnapshot !== undefined) {
@@ -10142,6 +10333,7 @@ async function createJJ(options) {
10142
10333
  description: 'redo operation',
10143
10334
  eventType: 'redo',
10144
10335
  redoOf: undoOp.id,
10336
+ changeSnapshot: undoChangeSnapshot,
10145
10337
  parents: [],
10146
10338
  view: redoneView,
10147
10339
  });
@@ -10432,6 +10624,23 @@ async function createJJ(options) {
10432
10624
  // Revert the change graph, working-copy files, and conflict state
10433
10625
  // to exactly what they were right after targetOp ran.
10434
10626
  const reversal = computeGraphReversal(ops, targetOp);
10627
+
10628
+ // Capture ground truth for every changeId this restore is about to
10629
+ // overwrite, BEFORE applying the reversal — exactly like undo()
10630
+ // does for its own changeSnapshot. Without this, restore()'s own
10631
+ // oplog entry carried no changeSnapshot at all, so undoing the
10632
+ // restore itself had nothing to put back: a later undo() would
10633
+ // repoint the working copy and files, but leave whatever the
10634
+ // restore had just overwritten (e.g. a change's description)
10635
+ // exactly as the restore left it. See issue #37(a).
10636
+ await graph.load();
10637
+ /** @type {Record<string, any>} */
10638
+ const changeSnapshot = {};
10639
+ for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
10640
+ const current = await graph.getChange(changeId);
10641
+ if (current) changeSnapshot[changeId] = current;
10642
+ }
10643
+
10435
10644
  await applyGraphReversal(reversal);
10436
10645
 
10437
10646
  // Record this restoration as a new operation
@@ -10440,6 +10649,7 @@ async function createJJ(options) {
10440
10649
  user: await getUserOplogInfo(),
10441
10650
  description: `restore to operation ${args.operation}`,
10442
10651
  parents: [],
10652
+ changeSnapshot,
10443
10653
  view: targetOp.view,
10444
10654
  });
10445
10655
 
@@ -10799,10 +11009,22 @@ async function createJJ(options) {
10799
11009
  },
10800
11010
  description: '(no description set)',
10801
11011
  timestamp: new Date().toISOString(),
10802
- fileSnapshot: {},
11012
+ // Start from the DESTINATION's tree (which now includes source's
11013
+ // folded-in changes, set above), not an empty object — matches
11014
+ // real `jj squash`, where the new working-copy change looks
11015
+ // identical to its new parent until you touch something. Issue
11016
+ // #39: this used to hard-code `{}`, so status()/snapshot() saw
11017
+ // every on-disk file as untracked/new until something re-wrote
11018
+ // it and triggered a fresh auto-snapshot.
11019
+ fileSnapshot: { ...destChange.fileSnapshot },
10803
11020
  };
10804
11021
 
10805
11022
  await graph.addChange(newChange); // Middleware will sync to Git
11023
+ // Sync the working directory to match (issue #30's fix for
11024
+ // new()/edit(); squash()'s own synthetic post-squash change never
11025
+ // did this) — a no-op when source's parent was already dest (the
11026
+ // common case), but necessary whenever source/dest aren't adjacent.
11027
+ await syncWorkingCopyFiles(newChange.fileSnapshot);
10806
11028
  await workingCopy.setCurrentChange(newChangeId);
10807
11029
  newWorkingCopyId = newChangeId;
10808
11030
  }
@@ -10884,6 +11106,12 @@ async function createJJ(options) {
10884
11106
  throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
10885
11107
  }
10886
11108
 
11109
+ // Captured BEFORE anything mutates — see issue #38: abandoning the
11110
+ // change @ currently points at must move @ off it (real jj checks
11111
+ // out a fresh empty change on the abandoned change's own parent(s));
11112
+ // abandoning any OTHER change must leave @ exactly where it was.
11113
+ const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
11114
+
10887
11115
  // Dispatch change:abandoning event (preventable)
10888
11116
  await dispatchEventAsync(jj, 'change:abandoning', {
10889
11117
  operation: 'abandon',
@@ -10936,6 +11164,17 @@ async function createJJ(options) {
10936
11164
  { cancelable: false }
10937
11165
  );
10938
11166
 
11167
+ // If @ was pointing at the change we just abandoned, it can't stay
11168
+ // there — real jj moves @ to a new empty change on the abandoned
11169
+ // change's own parent(s) (issue #38). Reuse new()'s own "create an
11170
+ // empty change with given parents" logic rather than duplicating it
11171
+ // — this is exactly the workaround callers had to reach for
11172
+ // themselves (`new({ parents: [parentOfAt] })` before abandoning).
11173
+ if (wasWorkingCopy) {
11174
+ const newWorkingCopyChange = await jj.new({ parents: change.parents || [] });
11175
+ return { ...change, newWorkingCopyChangeId: newWorkingCopyChange.changeId };
11176
+ }
11177
+
10939
11178
  return change;
10940
11179
  },
10941
11180