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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "isomorphic-jj",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Jujutsu (jj) version control semantics in pure JavaScript — stable change IDs, fearless undo, no staging area — for Node.js and browsers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -282,7 +282,16 @@ export async function createJJ(options) {
282
282
  await graph.load();
283
283
  for (const [changeId, snapshot] of Object.entries(reversal.changeSnapshotToApply)) {
284
284
  if (await graph.getChange(changeId)) {
285
- await graph.updateChange(snapshot);
285
+ // Bypass the git-sync middleware (baseGraph, not the wrapped
286
+ // `graph`): undo/restore are pure reverts, and `snapshot` already
287
+ // carries the change's ORIGINAL commitId from before the reverted
288
+ // operation(s) ran. Going through graph.updateChange() would fire
289
+ // onUpdateChange -> syncChangeToGit(), which mints a brand-new
290
+ // commit (and thus a new commitId) from the CURRENT tree instead
291
+ // of restoring the one that was actually there — see issue #37(c):
292
+ // "undo/restore give reverted commits new commit IDs". Writing the
293
+ // snapshot verbatim via baseGraph puts the original commitId back.
294
+ await baseGraph.updateChange(snapshot);
286
295
  }
287
296
  }
288
297
 
@@ -2420,6 +2429,16 @@ export async function createJJ(options) {
2420
2429
  // which operation it targeted so a following undo() can walk one
2421
2430
  // step further back (resolveUndoTarget) instead of toggling, and
2422
2431
  // carrying exactly what redo() needs to re-apply (captured above).
2432
+ //
2433
+ // Also record `changeSnapshot: redoChangeSnapshot` — the SAME
2434
+ // ground-truth-before-this-op values, but under the field name
2435
+ // computeGraphReversal() actually scans for. Without this, undo()
2436
+ // itself was the one mutating operation in the whole API that never
2437
+ // recorded its own changeSnapshot, so a LATER undo() targeting THIS
2438
+ // undo (to step back past it) had nothing to restore: it would
2439
+ // repoint the working copy and files, but leave whatever this
2440
+ // undo() overwrote (e.g. a change's description) exactly as this
2441
+ // undo() left it. See issue #37(a).
2423
2442
  await oplog.recordOperation({
2424
2443
  timestamp: new Date().toISOString(),
2425
2444
  user: await getUserOplogInfo(),
@@ -2427,6 +2446,7 @@ export async function createJJ(options) {
2427
2446
  eventType: 'undo',
2428
2447
  targetOpId: targetOp.id,
2429
2448
  undoneOpId: targetOp.id, // kept for API/type compatibility
2449
+ changeSnapshot: redoChangeSnapshot,
2430
2450
  redoChangeSnapshot,
2431
2451
  redoFileSnapshot,
2432
2452
  redoConflictsSnapshot,
@@ -2504,9 +2524,25 @@ export async function createJJ(options) {
2504
2524
  // by scanning forward. undo() captured it directly for exactly this
2505
2525
  // reason (see its own comment).
2506
2526
  await graph.load();
2527
+
2528
+ // Capture ground truth right before overwriting, same as undo()
2529
+ // does for its own changeSnapshot (issue #37a) — lets a later
2530
+ // undo() targeting THIS redo op restore what redo() is about to
2531
+ // clobber, instead of finding no changeSnapshot at all.
2532
+ /** @type {Record<string, any>} */
2533
+ const undoChangeSnapshot = {};
2534
+ for (const changeId of Object.keys(undoOp.redoChangeSnapshot || {})) {
2535
+ const current = await graph.getChange(changeId);
2536
+ if (current) undoChangeSnapshot[changeId] = current;
2537
+ }
2538
+
2507
2539
  for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
2508
2540
  if (await graph.getChange(changeId)) {
2509
- await graph.updateChange(snapshot);
2541
+ // Bypass the git-sync middleware — see the matching comment in
2542
+ // applyGraphReversal(). `snapshot` already carries the change's
2543
+ // correct commitId from before the undo it's reversing ran
2544
+ // (issue #37c applies to redo() too).
2545
+ await baseGraph.updateChange(snapshot);
2510
2546
  }
2511
2547
  }
2512
2548
  if (undoOp.redoFileSnapshot !== undefined) {
@@ -2527,6 +2563,7 @@ export async function createJJ(options) {
2527
2563
  description: 'redo operation',
2528
2564
  eventType: 'redo',
2529
2565
  redoOf: undoOp.id,
2566
+ changeSnapshot: undoChangeSnapshot,
2530
2567
  parents: [],
2531
2568
  view: redoneView,
2532
2569
  });
@@ -2817,6 +2854,23 @@ export async function createJJ(options) {
2817
2854
  // Revert the change graph, working-copy files, and conflict state
2818
2855
  // to exactly what they were right after targetOp ran.
2819
2856
  const reversal = computeGraphReversal(ops, targetOp);
2857
+
2858
+ // Capture ground truth for every changeId this restore is about to
2859
+ // overwrite, BEFORE applying the reversal — exactly like undo()
2860
+ // does for its own changeSnapshot. Without this, restore()'s own
2861
+ // oplog entry carried no changeSnapshot at all, so undoing the
2862
+ // restore itself had nothing to put back: a later undo() would
2863
+ // repoint the working copy and files, but leave whatever the
2864
+ // restore had just overwritten (e.g. a change's description)
2865
+ // exactly as the restore left it. See issue #37(a).
2866
+ await graph.load();
2867
+ /** @type {Record<string, any>} */
2868
+ const changeSnapshot = {};
2869
+ for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
2870
+ const current = await graph.getChange(changeId);
2871
+ if (current) changeSnapshot[changeId] = current;
2872
+ }
2873
+
2820
2874
  await applyGraphReversal(reversal);
2821
2875
 
2822
2876
  // Record this restoration as a new operation
@@ -2825,6 +2879,7 @@ export async function createJJ(options) {
2825
2879
  user: await getUserOplogInfo(),
2826
2880
  description: `restore to operation ${args.operation}`,
2827
2881
  parents: [],
2882
+ changeSnapshot,
2828
2883
  view: targetOp.view,
2829
2884
  });
2830
2885
 
@@ -3184,10 +3239,22 @@ export async function createJJ(options) {
3184
3239
  },
3185
3240
  description: '(no description set)',
3186
3241
  timestamp: new Date().toISOString(),
3187
- fileSnapshot: {},
3242
+ // Start from the DESTINATION's tree (which now includes source's
3243
+ // folded-in changes, set above), not an empty object — matches
3244
+ // real `jj squash`, where the new working-copy change looks
3245
+ // identical to its new parent until you touch something. Issue
3246
+ // #39: this used to hard-code `{}`, so status()/snapshot() saw
3247
+ // every on-disk file as untracked/new until something re-wrote
3248
+ // it and triggered a fresh auto-snapshot.
3249
+ fileSnapshot: { ...destChange.fileSnapshot },
3188
3250
  };
3189
3251
 
3190
3252
  await graph.addChange(newChange); // Middleware will sync to Git
3253
+ // Sync the working directory to match (issue #30's fix for
3254
+ // new()/edit(); squash()'s own synthetic post-squash change never
3255
+ // did this) — a no-op when source's parent was already dest (the
3256
+ // common case), but necessary whenever source/dest aren't adjacent.
3257
+ await syncWorkingCopyFiles(newChange.fileSnapshot);
3191
3258
  await workingCopy.setCurrentChange(newChangeId);
3192
3259
  newWorkingCopyId = newChangeId;
3193
3260
  }
@@ -3269,6 +3336,12 @@ export async function createJJ(options) {
3269
3336
  throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
3270
3337
  }
3271
3338
 
3339
+ // Captured BEFORE anything mutates — see issue #38: abandoning the
3340
+ // change @ currently points at must move @ off it (real jj checks
3341
+ // out a fresh empty change on the abandoned change's own parent(s));
3342
+ // abandoning any OTHER change must leave @ exactly where it was.
3343
+ const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
3344
+
3272
3345
  // Dispatch change:abandoning event (preventable)
3273
3346
  await dispatchEventAsync(jj, 'change:abandoning', {
3274
3347
  operation: 'abandon',
@@ -3321,6 +3394,17 @@ export async function createJJ(options) {
3321
3394
  { cancelable: false }
3322
3395
  );
3323
3396
 
3397
+ // If @ was pointing at the change we just abandoned, it can't stay
3398
+ // there — real jj moves @ to a new empty change on the abandoned
3399
+ // change's own parent(s) (issue #38). Reuse new()'s own "create an
3400
+ // empty change with given parents" logic rather than duplicating it
3401
+ // — this is exactly the workaround callers had to reach for
3402
+ // themselves (`new({ parents: [parentOfAt] })` before abandoning).
3403
+ if (wasWorkingCopy) {
3404
+ const newWorkingCopyChange = await jj.new({ parents: change.parents || [] });
3405
+ return { ...change, newWorkingCopyChangeId: newWorkingCopyChange.changeId };
3406
+ }
3407
+
3324
3408
  return change;
3325
3409
  },
3326
3410
 
@@ -539,9 +539,12 @@ export class RevsetEngine {
539
539
  const unquote = (/** @type {string} */ s) => s.trim().replace(/^['"]|['"]$/g, '');
540
540
 
541
541
  switch (name) {
542
- case 'all':
542
+ case 'all': {
543
543
  await this.graph.load();
544
- return this.graph.getAll().map((c) => c.changeId);
544
+ const allChanges = this.graph.getAll();
545
+ const hidden = await this._computeHiddenOrphans(allChanges);
546
+ return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
547
+ }
545
548
 
546
549
  // `builtin_log()` (jj v0.44) is the revset the built-in `jj log` uses
547
550
  // when no user override is configured. isomorphic-jj's `log()` already
@@ -549,9 +552,12 @@ export class RevsetEngine {
549
552
  // `builtin_log()` is a pure alias for that same default — this lets
550
553
  // callers compose it (e.g. `builtin_log() & mine()`) instead of
551
554
  // duplicating `all()`.
552
- case 'builtin_log':
555
+ case 'builtin_log': {
553
556
  await this.graph.load();
554
- return this.graph.getAll().map((c) => c.changeId);
557
+ const allChanges = this.graph.getAll();
558
+ const hidden = await this._computeHiddenOrphans(allChanges);
559
+ return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
560
+ }
555
561
 
556
562
  case 'none':
557
563
  return [];
@@ -1012,6 +1018,108 @@ export class RevsetEngine {
1012
1018
  return ancestors;
1013
1019
  }
1014
1020
 
1021
+ /**
1022
+ * Change IDs that `all()`/`builtin_log()` (and therefore the default
1023
+ * `jj.log()`) should exclude — issue #37(b).
1024
+ *
1025
+ * undo()/redo()/operations.restore() deliberately don't delete a change
1026
+ * record for a `new()`/`squash()` that gets reverted — they only un-ref
1027
+ * it (restore the working-copy pointer/heads that made it reachable),
1028
+ * matching real jj where an unreachable change stays resolvable by its
1029
+ * change id (hidden, not gone) until explicitly gc'd — see
1030
+ * computeGraphReversal's doc comment in repository.js. But that means
1031
+ * the orphaned change record lingers in the graph, and before this fix
1032
+ * `all()` (a flat `graph.getAll()`) surfaced it in `log()` right
1033
+ * alongside everything still actually reachable.
1034
+ *
1035
+ * Real jj hides a commit from the default log when it is BOTH:
1036
+ * - unreachable from any visible head (not itself a head, and not an
1037
+ * ancestor of one), AND
1038
+ * - "discardable": empty (no file diff versus its own parent) and
1039
+ * undescribed.
1040
+ * Merge commits (2+ parents) are conservatively never treated as
1041
+ * "empty" here — real jj's diff-emptiness rule for merges is more
1042
+ * involved and isn't needed by anything this package creates today.
1043
+ *
1044
+ * "Visible head" here deliberately does NOT mean "any change with no
1045
+ * children" (that's what the `visible_heads()` revset function computes,
1046
+ * structurally, from the raw graph) — an orphan `new()`/`squash()`
1047
+ * change that undo() un-referenced has no children EITHER, so it would
1048
+ * trivially count as its own head under that definition and never be
1049
+ * excludable. Real jj instead tracks an explicit, maintained set of
1050
+ * heads in its View (updated as operations run — abandoning/squashing a
1051
+ * change removes it, rebasing children onto a new parent removes the
1052
+ * old parent, etc.). This package doesn't thread the operation log's
1053
+ * `view.heads` through to the revset engine, but the working-copy
1054
+ * pointer plus every bookmark/tag target is the closest available
1055
+ * proxy for "what's actually still in play" — anything only reachable
1056
+ * from those, not from every dangling graph leaf.
1057
+ *
1058
+ * @param {any[]} allChanges - `graph.getAll()`
1059
+ * @returns {Promise<Set<string>>} change IDs to exclude
1060
+ */
1061
+ async _computeHiddenOrphans(allChanges) {
1062
+ const trackedHeads = new Set();
1063
+ try {
1064
+ const wcId = this.workingCopy && this.workingCopy.getCurrentChangeId();
1065
+ if (wcId) trackedHeads.add(wcId);
1066
+ } catch {
1067
+ // working copy not loaded/available — fine, just no wc-based root.
1068
+ }
1069
+ if (this.bookmarkStore) {
1070
+ await this.bookmarkStore.load();
1071
+ for (const bookmark of await this.bookmarkStore.list()) {
1072
+ if (bookmark && bookmark.changeId) trackedHeads.add(bookmark.changeId);
1073
+ }
1074
+ }
1075
+ if (this.tagStore) {
1076
+ for (const tag of await this.tagStore.list()) {
1077
+ if (tag && tag.changeId) trackedHeads.add(tag.changeId);
1078
+ }
1079
+ }
1080
+
1081
+ const reachable = new Set();
1082
+ for (const headId of trackedHeads) {
1083
+ for (const id of await this.getAncestors(headId)) {
1084
+ reachable.add(id);
1085
+ }
1086
+ }
1087
+
1088
+ // Sentinel "no message given" descriptions this package itself writes
1089
+ // (new()'s '(no description)', squash()'s post-squash working-copy
1090
+ // change '(no description set)') are, for visibility purposes, just
1091
+ // as undescribed as a genuinely empty/missing description field.
1092
+ const UNDESCRIBED = new Set([undefined, null, '', '(no description)', '(no description set)']);
1093
+
1094
+ const byId = new Map(allChanges.map((c) => [c.changeId, c]));
1095
+ const isEmpty = (/** @type {any} */ change) => {
1096
+ const parents = change.parents || [];
1097
+ if (parents.length === 0) {
1098
+ return !change.fileSnapshot || Object.keys(change.fileSnapshot).length === 0;
1099
+ }
1100
+ if (parents.length > 1) return false; // merges: not handled by this rule
1101
+ const parent = byId.get(parents[0]);
1102
+ const parentSnapshot = (parent && parent.fileSnapshot) || {};
1103
+ const ownSnapshot = change.fileSnapshot || {};
1104
+ const parentKeys = Object.keys(parentSnapshot);
1105
+ const ownKeys = Object.keys(ownSnapshot);
1106
+ if (parentKeys.length !== ownKeys.length) return false;
1107
+ return parentKeys.every((k) => parentSnapshot[k] === ownSnapshot[k]);
1108
+ };
1109
+
1110
+ const hidden = new Set();
1111
+ for (const change of allChanges) {
1112
+ if (
1113
+ !reachable.has(change.changeId) &&
1114
+ isEmpty(change) &&
1115
+ UNDESCRIBED.has(change.description)
1116
+ ) {
1117
+ hidden.add(change.changeId);
1118
+ }
1119
+ }
1120
+ return hidden;
1121
+ }
1122
+
1015
1123
  /**
1016
1124
  * Resolve a revset argument to a set of change IDs (v1.5).
1017
1125
  *
@@ -7,6 +7,44 @@
7
7
  import { JJError } from '../utils/errors.js';
8
8
  import { validateChangeId, validatePath } from '../utils/validation.js';
9
9
 
10
+ /**
11
+ * Normalize an mtime value to a millisecond epoch number so two stat()
12
+ * results (or a stat() result and a stored value) can be compared by VALUE
13
+ * instead of by object identity (see issue #41).
14
+ *
15
+ * `fs.promises.stat()` returns a fresh `Date` instance on every call, so two
16
+ * separate stats of the same unchanged file are never `===`/`!==`-equal even
17
+ * when the underlying timestamp is identical. Additionally, once a tracked
18
+ * mtime has been through a `JSON.stringify`/`JSON.parse` round-trip (Storage
19
+ * persists working-copy state as JSON), a `Date` becomes an ISO string, not a
20
+ * `Date` — so the stored side of the comparison may be a `Date`, a plain
21
+ * epoch-ms number (this module now stores numbers going forward), or an ISO
22
+ * string (older/reloaded-from-disk state). This normalizes all three to a
23
+ * comparable number.
24
+ *
25
+ * Deliberately duck-types the Date case (`typeof value.getTime ===
26
+ * 'function'`) instead of `value instanceof Date`: Node's built-in `fs`
27
+ * module constructs `Stats.mtime` using its own realm's `Date`, which is not
28
+ * always the same `Date` constructor as this module's under test runners
29
+ * that sandbox each test file in its own VM context (observed under Jest
30
+ * with `--experimental-vm-modules`) — there, a real `Date` instance from
31
+ * `fs.promises.stat()` fails `instanceof Date` here even though it's a
32
+ * genuine Date. Duck-typing survives that realm mismatch.
33
+ *
34
+ * @param {Date|number|string|undefined} value
35
+ * @returns {number}
36
+ */
37
+ function mtimeMs(value) {
38
+ if (value === null || value === undefined) return NaN;
39
+ if (typeof value === 'number') return value;
40
+ if (typeof value === 'string') return new Date(value).getTime();
41
+ // Per the type contract above, anything left is Date-like -- duck-typed
42
+ // (see the comment above) rather than `instanceof Date`, so no runtime
43
+ // check is needed here either; a value outside this contract is a caller
44
+ // bug, not something to silently coerce.
45
+ return /** @type {any} */ (value).getTime();
46
+ }
47
+
10
48
  export class WorkingCopy {
11
49
  /**
12
50
  * @param {Storage} storage - Storage manager instance
@@ -178,8 +216,10 @@ export class WorkingCopy {
178
216
  try {
179
217
  const stats = await this.fs.promises.stat(fullPath);
180
218
 
181
- // Fast path: check mtime and size
182
- if (stats.mtime !== tracked.mtime || stats.size !== tracked.size) {
219
+ // Fast path: check mtime and size. Compare by VALUE (see mtimeMs
220
+ // above) — comparing the raw stat()/stored values with !== is
221
+ // always "different" even when nothing changed (issue #41).
222
+ if (mtimeMs(stats.mtime) !== mtimeMs(tracked.mtime) || stats.size !== tracked.size) {
183
223
  modified.push(path);
184
224
  }
185
225
  } catch (error) {
@@ -290,12 +330,19 @@ export class WorkingCopy {
290
330
  }
291
331
 
292
332
  const prev = this.state.fileStates[rel];
293
- const next = { mtime: stats.mtime, size: stats.size, mode: stats.mode };
333
+ // Store mtime as a plain epoch-ms number (not the raw stat() value):
334
+ // numbers round-trip through JSON exactly, so state reloaded from disk
335
+ // later compares identically to state still cached in-memory. Goes
336
+ // through mtimeMs() rather than assuming `.getTime()` exists directly —
337
+ // some fs implementations (e.g. the in-memory mock used in tests)
338
+ // return mtime as a plain epoch-ms number already, not a Date (see
339
+ // mtimeMs above and issue #41).
340
+ const next = { mtime: mtimeMs(stats.mtime), size: stats.size, mode: stats.mode };
294
341
 
295
342
  if (!prev) {
296
343
  this.state.fileStates[rel] = next;
297
344
  added.push(rel);
298
- } else if (prev.mtime !== stats.mtime || prev.size !== stats.size) {
345
+ } else if (mtimeMs(stats.mtime) !== mtimeMs(prev.mtime) || prev.size !== stats.size) {
299
346
  this.state.fileStates[rel] = next;
300
347
  modified.push(rel);
301
348
  }