isomorphic-jj 1.7.0 → 1.9.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.
@@ -16,8 +16,9 @@ import { BackgroundOps } from '../core/background-ops.js';
16
16
  import { UserConfig } from '../core/user-config.js';
17
17
  import { IsomorphicGitBackend } from '../backend/isomorphic-git-backend.js';
18
18
  import { JJError } from '../utils/errors.js';
19
- import { generateChangeId } from '../utils/id-generation.js';
20
- import path from 'path';
19
+ import { generateChangeId, randomHex } from '../utils/id-generation.js';
20
+ import * as path from '../utils/posix-path.js';
21
+ import { mkdirp } from '../utils/mkdirp.js';
21
22
 
22
23
  /**
23
24
  * Create and initialize a JJ repository instance
@@ -116,7 +117,7 @@ export async function createJJ(options) {
116
117
  break;
117
118
  }
118
119
 
119
- const content = await fs.promises.readFile(fullPath, 'utf-8');
120
+ const content = await fs.promises.readFile(fullPath, 'utf8');
120
121
  fileSnapshot[filePath] = content;
121
122
  totalSnapshotSize += stats.size;
122
123
  } catch (error) {
@@ -130,6 +131,257 @@ export async function createJJ(options) {
130
131
  return fileSnapshot;
131
132
  };
132
133
 
134
+ /**
135
+ * Fully sync the working directory to a target file snapshot: writes
136
+ * every file the target has (create/update), AND deletes every currently
137
+ * tracked file the target does NOT have.
138
+ *
139
+ * Both edit() and new() used to only overlay the target's files on top of
140
+ * whatever was already on disk, so a file that existed in the previously
141
+ * checked-out change but not in the target leaked into the next
142
+ * snapshot — see issue #30. undo()/redo() had the same gap restoring
143
+ * file state — see issue #29. This is the one place that does it
144
+ * correctly, used by all four.
145
+ *
146
+ * @param {Record<string, string>} targetSnapshot - path -> file content
147
+ * @returns {Promise<void>}
148
+ */
149
+ const syncWorkingCopyFiles = async (targetSnapshot) => {
150
+ const snapshot = targetSnapshot || {};
151
+ const currentFiles = await workingCopy.listFiles();
152
+
153
+ // Remove files that aren't part of the target.
154
+ for (const filePath of currentFiles) {
155
+ if (!(filePath in snapshot)) {
156
+ try {
157
+ const fullPath = path.join(dir, filePath);
158
+ await fs.promises.unlink(fullPath);
159
+ } catch (error) {
160
+ // Already gone / inaccessible — fine, we just want it absent.
161
+ }
162
+ await workingCopy.untrackFile(filePath);
163
+ }
164
+ }
165
+
166
+ // Write/update every file the target has.
167
+ for (const [filePath, content] of Object.entries(snapshot)) {
168
+ const fullPath = path.join(dir, filePath);
169
+ const pathParts = filePath.split('/');
170
+ if (pathParts.length > 1) {
171
+ const fullDirPath = path.join(dir, pathParts.slice(0, -1).join('/'));
172
+ await mkdirp(fs, fullDirPath);
173
+ }
174
+ await fs.promises.writeFile(fullPath, content, 'utf8');
175
+
176
+ const stats = await fs.promises.stat(fullPath);
177
+ await workingCopy.trackFile(filePath, {
178
+ mtime: stats.mtime,
179
+ size: stats.size,
180
+ mode: stats.mode,
181
+ });
182
+ }
183
+ };
184
+
185
+ /**
186
+ * Compute the ChangeGraph/file/conflicts state that existed right after
187
+ * `landingOp` finished — i.e. what undo()/redo()/operations.restore()
188
+ * need to re-establish when winding the repository forward or back to
189
+ * that point (see issue #29).
190
+ *
191
+ * Every operation that mutates EXISTING change records in place records
192
+ * (additively — most operations touch none of this and that's fine,
193
+ * there's nothing to reverse for them):
194
+ * - changeSnapshot: { [changeId]: fullRecordBeforeThisOpRan }
195
+ * - view.fileSnapshot: on-disk file state right BEFORE this op ran
196
+ * - conflictsSnapshot: ConflictModel state right BEFORE this op ran
197
+ *
198
+ * Deliberately NOT reverted: changes an operation newly *created* (via
199
+ * graph.addChange() — new(), squash()'s synthetic empty working-copy
200
+ * change, ...). Undoing their creation only un-refs them (restores the
201
+ * working-copy pointer/heads that made them reachable) — it does not
202
+ * delete the change record — matching real jj, where an "unreachable"
203
+ * change stays resolvable by its change id (hidden, not gone) until
204
+ * explicitly gc'd, and this package's own history-editing tests rely on
205
+ * exactly that: rebasing onto a change created by a since-undone new()
206
+ * must still work.
207
+ *
208
+ * "The state right after landingOp" is therefore exactly what the FIRST
209
+ * operation after landingOp recorded as ITS "before" state, merged across
210
+ * every operation between landingOp and the current head in case
211
+ * different operations touched different changeIds (a single op's own
212
+ * snapshot only covers what IT touched). Concretely, per field:
213
+ * - changeSnapshot: first (earliest / closest-to-landingOp) entry per
214
+ * changeId wins — later entries in the range reflect that changeId's
215
+ * state at a LATER point than we're restoring to.
216
+ * - fileSnapshot / conflictsSnapshot: the first op in the range that
217
+ * recorded one, since whole-state snapshots aren't per-changeId and
218
+ * an op that didn't record one simply didn't change that state.
219
+ *
220
+ * This same helper backs undo() (landingOp = the parent of whatever's
221
+ * being undone), redo() (landingOp = the operation a prior undo()
222
+ * reverted), and operations.restore() (landingOp = the target operation
223
+ * itself) — one reversal primitive, not three.
224
+ *
225
+ * @param {Array<any>} ops - Full chronological operation list (oplog.list())
226
+ * @param {any} landingOp - The operation whose resulting state to recompute
227
+ * @returns {{
228
+ * changeSnapshotToApply: Record<string, any>,
229
+ * fileSnapshot: Record<string, string>|undefined,
230
+ * conflictsSnapshot: any,
231
+ * }}
232
+ */
233
+ const computeGraphReversal = (/** @type {any[]} */ ops, /** @type {any} */ landingOp) => {
234
+ const landingIndex = ops.findIndex((op) => op.id === landingOp.id);
235
+
236
+ /** @type {Record<string, any>} */
237
+ const changeSnapshotToApply = {};
238
+ /** @type {Record<string, string>|undefined} */
239
+ let fileSnapshot;
240
+ /** @type {any} */
241
+ let conflictsSnapshot;
242
+
243
+ for (let i = landingIndex + 1; i < ops.length; i++) {
244
+ const op = ops[i];
245
+
246
+ if (op.changeSnapshot) {
247
+ for (const [changeId, snapshot] of Object.entries(op.changeSnapshot)) {
248
+ if (!(changeId in changeSnapshotToApply)) {
249
+ changeSnapshotToApply[changeId] = snapshot;
250
+ }
251
+ }
252
+ }
253
+
254
+ // `!= null` (not `!== undefined`) on purpose: some operations record
255
+ // an explicit `null` for "I ran, and I deliberately took no snapshot"
256
+ // (e.g. describe() of a non-working-copy revision) — that's exactly
257
+ // as uninformative as never having set the field, so keep scanning
258
+ // rather than "finding" a null snapshot.
259
+ if (fileSnapshot === undefined && op.view && op.view.fileSnapshot != null) {
260
+ fileSnapshot = op.view.fileSnapshot;
261
+ }
262
+
263
+ if (conflictsSnapshot === undefined && op.conflictsSnapshot != null) {
264
+ conflictsSnapshot = op.conflictsSnapshot;
265
+ }
266
+ }
267
+
268
+ return { changeSnapshotToApply, fileSnapshot, conflictsSnapshot };
269
+ };
270
+
271
+ /**
272
+ * Apply a reversal computed by computeGraphReversal() to the live graph,
273
+ * working-copy files, and conflict state. Does NOT touch the working-copy
274
+ * pointer or bookmarks — callers set those from the landing op's own
275
+ * `view` directly (pointers, unlike file/graph/conflict content, are
276
+ * already correct as recorded, see computeGraphReversal's doc comment).
277
+ *
278
+ * @param {ReturnType<typeof computeGraphReversal>} reversal
279
+ * @returns {Promise<void>}
280
+ */
281
+ const applyGraphReversal = async (reversal) => {
282
+ await graph.load();
283
+ for (const [changeId, snapshot] of Object.entries(reversal.changeSnapshotToApply)) {
284
+ if (await graph.getChange(changeId)) {
285
+ await graph.updateChange(snapshot);
286
+ }
287
+ }
288
+
289
+ if (reversal.fileSnapshot !== undefined) {
290
+ await syncWorkingCopyFiles(reversal.fileSnapshot);
291
+ }
292
+
293
+ await conflicts.load();
294
+ if (reversal.conflictsSnapshot !== undefined) {
295
+ conflicts.conflicts = new Map(Object.entries(reversal.conflictsSnapshot.conflicts || {}));
296
+ conflicts.fileConflicts = new Map(
297
+ Object.entries(reversal.conflictsSnapshot.fileConflicts || {})
298
+ );
299
+ await conflicts.save();
300
+ }
301
+ // Else: no operation in the reverted range touched conflicts — leave
302
+ // current conflict state alone. (Previously undo() unconditionally
303
+ // cleared ALL conflicts whenever the op being undone hadn't recorded
304
+ // its own conflictsSnapshot, which is nearly every non-merge() op —
305
+ // undoing an unrelated describe() would silently wipe conflicts from
306
+ // an earlier, unrelated merge(). Fixed as part of issue #29.)
307
+ };
308
+
309
+ /**
310
+ * Re-parent every child of `changeId` onto `newParents` instead — used
311
+ * when a change is being elided from the graph (abandon(), issue #30;
312
+ * converge(), issue #32) so its children don't end up pointing at
313
+ * something no longer meant to be there. Mutates `changeSnapshot` in
314
+ * place, adding each touched child's PRE-reparent record so the caller's
315
+ * own oplog entry lets undo()/redo()/operations.restore() reverse this
316
+ * too (the same mechanism the caller already uses for the elided
317
+ * change's own snapshot).
318
+ *
319
+ * @param {string} changeId - The change being elided
320
+ * @param {string[]} newParents - What its children should point at instead
321
+ * @param {Record<string, any>} changeSnapshot - Accumulator to add each touched child's pre-state to
322
+ * @returns {Promise<void>}
323
+ */
324
+ const reparentChildrenOnto = async (changeId, newParents, changeSnapshot) => {
325
+ const childIds = graph.getChildren(changeId);
326
+ for (const childId of childIds) {
327
+ const child = await graph.getChange(childId);
328
+ if (!child) continue;
329
+ changeSnapshot[childId] = structuredClone(child);
330
+ const reparented = child.parents.flatMap((/** @type {string} */ p) =>
331
+ p === changeId ? newParents : [p]
332
+ );
333
+ // De-dupe (e.g. the child could already have one of the new parents
334
+ // as another of its own parents) while preserving first-seen order.
335
+ child.parents = [...new Set(reparented)];
336
+ await graph.updateChange(child);
337
+ }
338
+ };
339
+
340
+ /**
341
+ * Determine which operation a fresh undo() call should revert, matching
342
+ * real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
343
+ * a normal operation, this will undo that last operation by restoring its
344
+ * parent. If jj undo is used repeatedly, it will restore increasingly
345
+ * older operations, going further back into the past.").
346
+ *
347
+ * The raw op-log parent chain can't answer this directly once undo/redo
348
+ * bookkeeping operations are mixed into the log: an 'undo' operation's
349
+ * own parent is the operation it just undid, so naively targeting
350
+ * `ops[ops.length - 1]` on a SECOND undo() would undo the undo (toggle
351
+ * back to the future) instead of stepping one further step into the past
352
+ * — this was issue #29's "undo() twice only undoes the undo" bug.
353
+ *
354
+ * @param {Array<any>} ops - Full chronological operation list
355
+ * @returns {any} The operation object undo() should revert
356
+ */
357
+ const resolveUndoTarget = (ops) => {
358
+ const head = ops[ops.length - 1];
359
+ const findOp = (/** @type {any} */ id) => ops.find((op) => op.id === id);
360
+
361
+ if (head.eventType === 'undo') {
362
+ // We're stepping further into the past: what should be undone now is
363
+ // the operation right BEFORE the one `head` already undid — not
364
+ // head's own parent, which IS the operation head undid (targeting it
365
+ // again would just toggle back to the present).
366
+ const previouslyUndone = head.targetOpId && findOp(head.targetOpId);
367
+ const stepBack = previouslyUndone && findOp(previouslyUndone.parents[0]);
368
+ return stepBack || previouslyUndone || head;
369
+ }
370
+
371
+ if (head.eventType === 'redo') {
372
+ // A redo re-established some earlier operation as current; a fresh
373
+ // undo right after it should behave exactly as if that operation
374
+ // were freshly current and had never been touched by undo/redo at
375
+ // all — i.e. undo it again (real jj: redo "moves in the direction of
376
+ // the future"; undoing right after simply resumes stepping back).
377
+ const redoneUndo = head.redoOf && findOp(head.redoOf);
378
+ const target = redoneUndo && redoneUndo.targetOpId && findOp(redoneUndo.targetOpId);
379
+ return target || head;
380
+ }
381
+
382
+ return head;
383
+ };
384
+
133
385
  /**
134
386
  * Automatic working-copy snapshot (v1.6).
135
387
  *
@@ -314,6 +566,25 @@ export async function createJJ(options) {
314
566
  }
315
567
  },
316
568
 
569
+ // Used by undo()/redo()/operations.restore() to reverse an
570
+ // addChange() when winding the graph back — see issue #29. No Git
571
+ // sync hook: a change created after the operation being reverted was
572
+ // never pushed anywhere to unwind, and the Git backend has no
573
+ // "forget this commit" operation (nor should it — Git objects are
574
+ // content-addressed and harmless to leave orphaned).
575
+ deleteChange: (/** @type {any} */ changeId) => baseGraph.deleteChange(changeId),
576
+
577
+ // See issue #32 / converge(): a divergent copy is another commit
578
+ // claiming an existing changeId; no Git sync hook, for the same
579
+ // reason addChange()'s primary copy already goes through
580
+ // onAddChange() when it's first created — the divergent copy needs
581
+ // no additional sync of its own.
582
+ addDivergentCopy: (/** @type {any} */ change) => baseGraph.addDivergentCopy(change),
583
+ getDivergentSiblings: (/** @type {any} */ changeId) =>
584
+ baseGraph.getDivergentSiblings(changeId),
585
+ deleteDivergentCopy: (/** @type {any} */ changeId, /** @type {any} */ commitId) =>
586
+ baseGraph.deleteDivergentCopy(changeId, commitId),
587
+
317
588
  // Delegate other operations
318
589
  init: () => baseGraph.init(),
319
590
  };
@@ -471,6 +742,20 @@ export async function createJJ(options) {
471
742
  /**
472
743
  * Write a file to the working copy
473
744
  *
745
+ * Note (issue #30): this does NOT update the working-copy change's
746
+ * `commitId`/`fileSnapshot` — those stay as they were until the next
747
+ * command that snapshots the working copy (describe(), new(), edit(),
748
+ * status(), or any other call that goes through
749
+ * autoSnapshotWorkingCopy()/snapshot()). This is intentional, not a
750
+ * bug: jj has no staging area, but it also doesn't recompute a git
751
+ * tree/commit hash on every single file write (that would mean
752
+ * hashing on every keystroke of an external editor) — real jj only
753
+ * takes a fresh working-copy snapshot when an actual `jj` command
754
+ * runs. write() is the equivalent of editing a file with your OS's own
755
+ * tools outside of jj entirely; describe()/new()/edit()/status()/etc.
756
+ * are the equivalent of running a jj command, which is what triggers
757
+ * the (auto) snapshot in both real jj and here.
758
+ *
474
759
  * @param {Record<string, any>} args - Arguments
475
760
  * @returns {Promise<Object>} File information including path, size, and mode
476
761
  */
@@ -494,7 +779,7 @@ export async function createJJ(options) {
494
779
  if (pathParts.length > 1) {
495
780
  const dirPath = pathParts.slice(0, -1).join('/');
496
781
  const fullDirPath = `${dir}/${dirPath}`;
497
- await fs.promises.mkdir(fullDirPath, { recursive: true });
782
+ await mkdirp(fs, fullDirPath);
498
783
  }
499
784
 
500
785
  // Write the file
@@ -552,7 +837,7 @@ export async function createJJ(options) {
552
837
  const fullPath = path.join(dir, args.path);
553
838
  try {
554
839
  if (encoding === 'utf-8' || encoding === 'utf8') {
555
- return await fs.promises.readFile(fullPath, 'utf-8');
840
+ return await fs.promises.readFile(fullPath, 'utf8');
556
841
  } else {
557
842
  return await fs.promises.readFile(fullPath);
558
843
  }
@@ -774,7 +1059,7 @@ export async function createJJ(options) {
774
1059
  if (pathParts.length > 1) {
775
1060
  const dirPath = pathParts.slice(0, -1).join('/');
776
1061
  const fullDirPath = path.join(dir, dirPath);
777
- await fs.promises.mkdir(fullDirPath, { recursive: true });
1062
+ await mkdirp(fs, fullDirPath);
778
1063
  }
779
1064
 
780
1065
  // Create writable stream
@@ -887,7 +1172,7 @@ export async function createJJ(options) {
887
1172
  // Ensure destination directory exists
888
1173
  const toDir = toPath.substring(0, toPath.lastIndexOf('/'));
889
1174
  try {
890
- await fs.promises.mkdir(toDir, { recursive: true });
1175
+ await mkdirp(fs, toDir);
891
1176
  } catch (error) {
892
1177
  throw new JJError(
893
1178
  'FILE_SYSTEM_ERROR',
@@ -1005,6 +1290,51 @@ export async function createJJ(options) {
1005
1290
  // TODO: Prevent creating cycles in the change graph
1006
1291
  // For now, we'll allow it but it should be validated
1007
1292
 
1293
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
1294
+ // reverse the reparenting — see issue #29.
1295
+ const changeSnapshotBefore = structuredClone(change);
1296
+
1297
+ // Three-way merge against the OLD base before reparenting (issue #31):
1298
+ // real jj's rebase re-derives the rebased change's content as
1299
+ // (old base, old commit, new base) — if a sibling changed the same
1300
+ // lines differently, that's a genuine conflict, not something
1301
+ // reparenting-and-copying-verbatim can silently paper over. This
1302
+ // reuses the exact same detectConflicts() machinery merge() already
1303
+ // uses (see merge(), a few hundred lines down) rather than
1304
+ // duplicating three-way-merge logic a third time in this codebase.
1305
+ const oldParentId = change.parents[0];
1306
+ const oldParentChange = oldParentId ? await graph.getChange(oldParentId) : null;
1307
+ const baseFiles = new Map(
1308
+ Object.entries((oldParentChange && oldParentChange.fileSnapshot) || {})
1309
+ );
1310
+ const leftFiles = new Map(Object.entries(change.fileSnapshot || {}));
1311
+ const rightFiles = new Map(Object.entries(newParentChange.fileSnapshot || {}));
1312
+
1313
+ await conflicts.load();
1314
+ const conflictsSnapshot = {
1315
+ conflicts: Object.fromEntries(conflicts.conflicts),
1316
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
1317
+ };
1318
+
1319
+ const detectedConflicts = oldParentChange
1320
+ ? await conflicts.detectConflicts({
1321
+ baseFiles,
1322
+ leftFiles,
1323
+ rightFiles,
1324
+ drivers: {},
1325
+ // Not necessarily the checked-out working copy, so don't let
1326
+ // merge drivers write to disk on its behalf.
1327
+ workingCopyDir: /** @type {any} */ (null),
1328
+ baseChange: oldParentId,
1329
+ leftChange: changeId,
1330
+ rightChange: newParent,
1331
+ })
1332
+ : [];
1333
+
1334
+ for (const conflict of detectedConflicts) {
1335
+ await conflicts.addConflict(conflict);
1336
+ }
1337
+
1008
1338
  // Update parent
1009
1339
  change.parents = [newParent];
1010
1340
  await graph.updateChange(change);
@@ -1015,6 +1345,8 @@ export async function createJJ(options) {
1015
1345
  user: await getUserOplogInfo(),
1016
1346
  description: `move change ${changeId.slice(0, 8)} to ${newParent.slice(0, 8)}`,
1017
1347
  parents: [],
1348
+ changeSnapshot: { [changeId]: changeSnapshotBefore },
1349
+ conflictsSnapshot,
1018
1350
  view: {
1019
1351
  bookmarks: {},
1020
1352
  remoteBookmarks: {},
@@ -1023,7 +1355,7 @@ export async function createJJ(options) {
1023
1355
  },
1024
1356
  });
1025
1357
 
1026
- return change;
1358
+ return { ...change, conflicts: detectedConflicts };
1027
1359
  },
1028
1360
 
1029
1361
  /**
@@ -1155,6 +1487,11 @@ export async function createJJ(options) {
1155
1487
  const isWorkingCopy = targetChangeId === workingCopy.getCurrentChangeId();
1156
1488
  const fileSnapshotBefore = isWorkingCopy ? await snapshotFilesystem() : null;
1157
1489
 
1490
+ // Snapshot the change record itself from BEFORE this operation mutates
1491
+ // it in place, so undo() can restore ChangeGraph content (not just
1492
+ // on-disk files/working-copy pointer) — mirrors fileSnapshotBefore above.
1493
+ const changeSnapshotBefore = structuredClone(change);
1494
+
1158
1495
  // Dispatch change:updating event (preventable)
1159
1496
  await dispatchEventAsync(jj, 'change:updating', {
1160
1497
  operation: 'describe',
@@ -1203,7 +1540,7 @@ export async function createJJ(options) {
1203
1540
  break;
1204
1541
  }
1205
1542
 
1206
- const content = await fs.promises.readFile(fullPath, 'utf-8');
1543
+ const content = await fs.promises.readFile(fullPath, 'utf8');
1207
1544
  fileSnapshot[filePath] = content;
1208
1545
  totalSnapshotSize += stats.size;
1209
1546
  } catch (error) {
@@ -1235,6 +1572,10 @@ export async function createJJ(options) {
1235
1572
  },
1236
1573
  description: `describe change ${targetChangeId.slice(0, 8)}`,
1237
1574
  parents: [],
1575
+ // Snapshot of the affected ChangeGraph node from BEFORE this
1576
+ // operation ran, so undo() can restore in-place content mutations
1577
+ // (e.g. the description change below) — not just files/pointers.
1578
+ changeSnapshot: { [targetChangeId]: changeSnapshotBefore },
1238
1579
  view: {
1239
1580
  bookmarks: {},
1240
1581
  remoteBookmarks: {},
@@ -1397,6 +1738,8 @@ export async function createJJ(options) {
1397
1738
 
1398
1739
  // Snapshot filesystem BEFORE operation (for undo) unless preserveSnapshot is true
1399
1740
  let fileSnapshot;
1741
+ /** @type {Record<string, any>} */
1742
+ const changeSnapshot = {};
1400
1743
  if (!args.preserveSnapshot) {
1401
1744
  fileSnapshot = await snapshotFilesystem();
1402
1745
 
@@ -1405,6 +1748,10 @@ export async function createJJ(options) {
1405
1748
  const currentChangeId = workingCopy.getCurrentChangeId();
1406
1749
  const currentChange = await graph.getChange(currentChangeId);
1407
1750
  if (currentChange && fileSnapshot) {
1751
+ // Snapshot BEFORE mutating so undo()/redo() can revert this
1752
+ // in-place update too, not just the pointer/new-change creation
1753
+ // below — see issue #29.
1754
+ changeSnapshot[currentChangeId] = structuredClone(currentChange);
1408
1755
  currentChange.fileSnapshot = fileSnapshot;
1409
1756
  await graph.updateChange(currentChange);
1410
1757
  }
@@ -1432,6 +1779,7 @@ export async function createJJ(options) {
1432
1779
  const user = /** @type {any} */ (userConfig.getUser());
1433
1780
 
1434
1781
  // Initialize with parent's file snapshot
1782
+ /** @type {Record<string, string>} */
1435
1783
  let initialSnapshot = {};
1436
1784
  if (parents && parents.length > 0) {
1437
1785
  const parent = await graph.getChange(parents[0]);
@@ -1475,10 +1823,19 @@ export async function createJJ(options) {
1475
1823
  // Handle insertBefore: rebase target to have new change as parent
1476
1824
  if (args.insertBefore) {
1477
1825
  const targetChange = await graph.getChange(args.insertBefore);
1826
+ changeSnapshot[args.insertBefore] = structuredClone(targetChange);
1478
1827
  targetChange.parents = [newChangeId];
1479
1828
  await graph.updateChange(targetChange);
1480
1829
  }
1481
1830
 
1831
+ // Sync the working directory to the new change's snapshot: write
1832
+ // everything it has AND remove anything it doesn't (issue #30 — this
1833
+ // used to never touch disk at all, so `new({ parents: [...] })`
1834
+ // targeting anything other than the current head silently left the
1835
+ // OLD working copy's files in place instead of checking out the new
1836
+ // parent's).
1837
+ await syncWorkingCopyFiles(initialSnapshot);
1838
+
1482
1839
  await workingCopy.setCurrentChange(newChangeId);
1483
1840
 
1484
1841
  // Record operation with filesystem snapshot
@@ -1491,6 +1848,13 @@ export async function createJJ(options) {
1491
1848
  },
1492
1849
  description: `new change ${newChangeId.slice(0, 8)}`,
1493
1850
  parents: [],
1851
+ // See issue #29: lets undo()/redo()/operations.restore() reverse
1852
+ // the existing records this op updated in place (the old
1853
+ // working-copy change's refreshed fileSnapshot, and insertBefore's
1854
+ // target's reparented parents). The newly created change itself
1855
+ // (newChangeId) is intentionally NOT torn down on undo — see
1856
+ // computeGraphReversal's doc comment.
1857
+ changeSnapshot,
1494
1858
  view: {
1495
1859
  bookmarks: {},
1496
1860
  remoteBookmarks: {},
@@ -1747,11 +2111,28 @@ export async function createJJ(options) {
1747
2111
  // Try to resolve as revset
1748
2112
  try {
1749
2113
  const changes = await revset.evaluate(args.change);
2114
+ if (changes.length > 1) {
2115
+ // An id-prefix (or other) revset that matches more than one
2116
+ // change must not silently pick a "winner" — see issue #13.
2117
+ throw new JJError(
2118
+ 'AMBIGUOUS_ID',
2119
+ `"${args.change}" is ambiguous: it resolves to ${changes.length} changes (${changes.join(', ')})`,
2120
+ {
2121
+ expression: args.change,
2122
+ matches: changes,
2123
+ suggestion: 'Use a longer prefix or an exact change ID to disambiguate',
2124
+ }
2125
+ );
2126
+ }
1750
2127
  if (changes.length > 0) {
1751
2128
  changeId = changes[0];
1752
2129
  }
1753
- } catch {
1754
- // Use as-is
2130
+ } catch (error) {
2131
+ if (error instanceof JJError && error.code === 'AMBIGUOUS_ID') {
2132
+ throw error;
2133
+ }
2134
+ // Not a valid revset expression - use args.change as-is (e.g. a
2135
+ // literal change ID that revset parsing rejected).
1755
2136
  }
1756
2137
  }
1757
2138
 
@@ -1854,6 +2235,13 @@ export async function createJJ(options) {
1854
2235
 
1855
2236
  const previousChangeId = workingCopy.getCurrentChangeId();
1856
2237
 
2238
+ /** @type {Record<string, any>} */
2239
+ const changeSnapshot = {};
2240
+
2241
+ // Capture disk state right before we touch anything, for undo() —
2242
+ // see issue #29.
2243
+ const fileSnapshotBeforeSwitch = await snapshotFilesystem();
2244
+
1857
2245
  // CRITICAL FIX: Clean working directory BEFORE snapshotting to prevent cross-branch pollution
1858
2246
  // Remove files that don't belong to the current changeId before we snapshot it
1859
2247
  if (previousChangeId !== args.changeId) {
@@ -1875,13 +2263,14 @@ export async function createJJ(options) {
1875
2263
  }
1876
2264
  }
1877
2265
  }
1878
- }
1879
2266
 
1880
- // Now snapshot the CLEAN working directory (no pollution!)
1881
- const currentSnapshot = await snapshotFilesystem();
1882
- if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
1883
- previousChange.fileSnapshot = currentSnapshot;
1884
- await graph.updateChange(previousChange);
2267
+ // Now snapshot the CLEAN working directory (no pollution!)
2268
+ const currentSnapshot = await snapshotFilesystem();
2269
+ if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
2270
+ changeSnapshot[previousChangeId] = structuredClone(previousChange);
2271
+ previousChange.fileSnapshot = currentSnapshot;
2272
+ await graph.updateChange(previousChange);
2273
+ }
1885
2274
  }
1886
2275
  }
1887
2276
 
@@ -1894,38 +2283,17 @@ export async function createJJ(options) {
1894
2283
  timestamp: new Date().toISOString(),
1895
2284
  });
1896
2285
 
1897
- // Restore files from the change's snapshot to working directory
1898
- if (change.fileSnapshot) {
1899
- for (const [filePath, content] of Object.entries(change.fileSnapshot)) {
1900
- try {
1901
- const fullPath = path.join(dir, filePath);
1902
-
1903
- // Ensure directory exists
1904
- const pathParts = filePath.split('/');
1905
- if (pathParts.length > 1) {
1906
- const dirPath = pathParts.slice(0, -1).join('/');
1907
- const fullDirPath = path.join(dir, dirPath);
1908
- await fs.promises.mkdir(fullDirPath, { recursive: true });
1909
- }
1910
-
1911
- // Write file content
1912
- await fs.promises.writeFile(fullPath, content, 'utf8');
1913
-
1914
- // Update working copy tracking
1915
- const stats = await fs.promises.stat(fullPath);
1916
- await workingCopy.trackFile(filePath, {
1917
- mtime: stats.mtime,
1918
- size: stats.size,
1919
- mode: stats.mode,
1920
- });
1921
- } catch (error) {
1922
- throw new JJError(
1923
- 'FILE_RESTORE_FAILED',
1924
- `Failed to restore file ${filePath}: ${error.message}`,
1925
- { filePath, originalError: error.message }
1926
- );
1927
- }
1928
- }
2286
+ // Fully sync the working directory to the target change's snapshot:
2287
+ // write everything it has AND remove anything it doesn't (issue #30
2288
+ // — this used to only overlay the target's files on top of whatever
2289
+ // was already on disk, so a file present in the PREVIOUS change but
2290
+ // absent from the target leaked into the next snapshot).
2291
+ try {
2292
+ await syncWorkingCopyFiles(change.fileSnapshot || {});
2293
+ } catch (error) {
2294
+ throw new JJError('FILE_RESTORE_FAILED', `Failed to restore files: ${error.message}`, {
2295
+ originalError: error.message,
2296
+ });
1929
2297
  }
1930
2298
 
1931
2299
  // Set this change as the working copy
@@ -1941,11 +2309,13 @@ export async function createJJ(options) {
1941
2309
  },
1942
2310
  description: `edit change ${args.changeId.slice(0, 8)}`,
1943
2311
  parents: [],
2312
+ changeSnapshot,
1944
2313
  view: {
1945
2314
  bookmarks: {},
1946
2315
  remoteBookmarks: {},
1947
2316
  heads: [args.changeId],
1948
2317
  workingCopy: args.changeId,
2318
+ fileSnapshot: fileSnapshotBeforeSwitch,
1949
2319
  },
1950
2320
  });
1951
2321
 
@@ -1976,91 +2346,107 @@ export async function createJJ(options) {
1976
2346
  /**
1977
2347
  * Undo last operation
1978
2348
  *
2349
+ * Reverts the most recent operation — including graph-rewriting ones
2350
+ * (squash/rebase/abandon/new/edit), not just working-copy/file state
2351
+ * (see issue #29). Matches real jj's progressive-undo semantics
2352
+ * (`jj-undo(1)`): called again with nothing else in between, it steps
2353
+ * one further operation into the past rather than undoing itself.
2354
+ *
1979
2355
  * @returns {Promise<Object>} Information about the undo including the undone operation and restored state
1980
2356
  */
1981
2357
  async undo() {
1982
- // Get the operation we're about to undo to access its pre-state
1983
2358
  await oplog.load();
1984
2359
  const ops = await oplog.list();
1985
2360
  if (ops.length === 0) {
1986
2361
  throw new JJError('NOTHING_TO_UNDO', 'No operations to undo');
1987
2362
  }
1988
2363
 
1989
- // Get the current operation (the one being undone) to restore its pre-state
1990
- const currentOp = ops[ops.length - 1];
1991
-
1992
- const previousView = /** @type {any} */ (await oplog.undo());
1993
-
1994
- // Restore state from previous view
1995
- await workingCopy.setCurrentChange(previousView.workingCopy);
2364
+ const targetOp = resolveUndoTarget(ops);
2365
+ const landingOpId = targetOp.parents && targetOp.parents[0];
2366
+ const landingOp = landingOpId ? ops.find((op) => op.id === landingOpId) : null;
1996
2367
 
1997
- // Restore filesystem from previous operation's snapshot (taken BEFORE that operation)
1998
- // This is how JJ snapshots the working copy before every command
1999
- if (previousView.fileSnapshot) {
2000
- for (const [filePath, content] of Object.entries(previousView.fileSnapshot)) {
2001
- try {
2002
- const fullPath = path.join(dir, filePath);
2003
-
2004
- // Ensure directory exists
2005
- const pathParts = filePath.split('/');
2006
- if (pathParts.length > 1) {
2007
- const dirPath = pathParts.slice(0, -1).join('/');
2008
- const fullDirPath = path.join(dir, dirPath);
2009
- await fs.promises.mkdir(fullDirPath, { recursive: true });
2010
- }
2011
-
2012
- // Write file content
2013
- await fs.promises.writeFile(fullPath, content, 'utf8');
2014
- } catch (error) {
2015
- throw new JJError(
2016
- 'UNDO_FILE_RESTORE_FAILED',
2017
- `Failed to restore file ${filePath} during undo: ${error.message}`,
2018
- { filePath, originalError: error.message }
2019
- );
2020
- }
2021
- }
2368
+ /** @type {ReturnType<typeof computeGraphReversal>} */
2369
+ let reversal;
2370
+ /** @type {any} */
2371
+ let landingView;
2372
+ if (landingOp) {
2373
+ reversal = computeGraphReversal(ops, landingOp);
2374
+ landingView = landingOp.view || {};
2375
+ } else {
2376
+ // Undoing the very first (root) operation: there's nothing before
2377
+ // it to restore to, so land on its own recorded state rather than
2378
+ // throwing (mirrors the previous no-parent fallback).
2379
+ reversal = {
2380
+ changeSnapshotToApply: {},
2381
+ fileSnapshot: undefined,
2382
+ conflictsSnapshot: undefined,
2383
+ };
2384
+ landingView = targetOp.view || {};
2385
+ }
2386
+
2387
+ // Capture EXACTLY what's about to be overwritten, from ground truth,
2388
+ // before applying the reversal — this is what redo() re-applies
2389
+ // later. It can't just recompute "the state after targetOp" the way
2390
+ // undo() itself computes "the state after landingOp" (by scanning
2391
+ // for a LATER op that also touched the same changeId/files/
2392
+ // conflicts): that scan only works going backward, where "nothing
2393
+ // touched it since" means "it's already correct". Going forward,
2394
+ // "nothing touched it since targetOp" means the opposite — its
2395
+ // effect is still live and needs to be captured now, not rederived.
2396
+ await graph.load();
2397
+ /** @type {Record<string, any>} */
2398
+ const redoChangeSnapshot = {};
2399
+ for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
2400
+ const current = await graph.getChange(changeId);
2401
+ if (current) redoChangeSnapshot[changeId] = current;
2402
+ }
2403
+ const redoFileSnapshot =
2404
+ reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
2405
+ let redoConflictsSnapshot;
2406
+ if (reversal.conflictsSnapshot !== undefined) {
2407
+ await conflicts.load();
2408
+ redoConflictsSnapshot = {
2409
+ conflicts: Object.fromEntries(conflicts.conflicts),
2410
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
2411
+ };
2022
2412
  }
2023
2413
 
2024
- // Restore conflicts state from before the undone operation
2025
- // The conflictsSnapshot in an operation represents the state BEFORE that operation ran
2026
- await conflicts.load();
2027
- if (currentOp && currentOp.conflictsSnapshot) {
2028
- // Restore conflicts from the operation we're undoing (its pre-state)
2029
- conflicts.conflicts = new Map(Object.entries(currentOp.conflictsSnapshot.conflicts || {}));
2030
- conflicts.fileConflicts = new Map(
2031
- Object.entries(currentOp.conflictsSnapshot.fileConflicts || {})
2032
- );
2033
- await conflicts.save();
2034
- } else {
2035
- // No conflicts snapshot - clear conflicts
2036
- await conflicts.clear();
2414
+ if (landingView.workingCopy) {
2415
+ await workingCopy.setCurrentChange(landingView.workingCopy);
2037
2416
  }
2417
+ await applyGraphReversal(reversal);
2038
2418
 
2039
- // Record undo operation. Tag it with the view it undid so that redo()
2040
- // can re-apply it (jj's progressive undo/redo, added in jj v0.33).
2419
+ // Record undo as its own operation (matching real jj) — tagged with
2420
+ // which operation it targeted so a following undo() can walk one
2421
+ // step further back (resolveUndoTarget) instead of toggling, and
2422
+ // carrying exactly what redo() needs to re-apply (captured above).
2041
2423
  await oplog.recordOperation({
2042
2424
  timestamp: new Date().toISOString(),
2043
2425
  user: await getUserOplogInfo(),
2044
2426
  description: 'undo operation',
2045
2427
  eventType: 'undo',
2046
- undoneOpId: currentOp.id,
2047
- undoneView: currentOp.view,
2048
- undoneConflictsSnapshot: currentOp.conflictsSnapshot || null,
2428
+ targetOpId: targetOp.id,
2429
+ undoneOpId: targetOp.id, // kept for API/type compatibility
2430
+ redoChangeSnapshot,
2431
+ redoFileSnapshot,
2432
+ redoConflictsSnapshot,
2049
2433
  parents: [],
2050
- view: previousView,
2434
+ view: landingView,
2051
2435
  });
2052
2436
 
2053
- // Return information about what was undone
2437
+ const restoredFileCount =
2438
+ reversal.fileSnapshot !== undefined ? Object.keys(reversal.fileSnapshot).length : 0;
2439
+
2054
2440
  return {
2055
2441
  undoneOperation: {
2056
- description: currentOp.description,
2057
- timestamp: currentOp.timestamp,
2058
- user: currentOp.user,
2442
+ description: targetOp.description,
2443
+ timestamp: targetOp.timestamp,
2444
+ user: targetOp.user,
2059
2445
  },
2060
2446
  restoredState: {
2061
- workingCopy: previousView.workingCopy,
2062
- heads: previousView.heads,
2063
- fileCount: previousView.fileSnapshot ? Object.keys(previousView.fileSnapshot).length : 0,
2447
+ workingCopy: landingView.workingCopy,
2448
+ heads: landingView.heads,
2449
+ fileCount: restoredFileCount,
2064
2450
  },
2065
2451
  };
2066
2452
  },
@@ -2068,9 +2454,9 @@ export async function createJJ(options) {
2068
2454
  /**
2069
2455
  * Redo an operation previously undone with undo() (v1.5)
2070
2456
  *
2071
- * Mirrors `jj redo` (jj v0.33): progressively re-applies the operations that
2072
- * `undo()` reverted, most-recent-undo first. Each redo consumes exactly one
2073
- * pending undo.
2457
+ * Mirrors `jj redo` (jj v0.33): re-applies the operation that the most
2458
+ * recent not-yet-redone undo() reverted — including its graph rewrites,
2459
+ * not just working-copy/file state (see issue #29).
2074
2460
  *
2075
2461
  * @returns {Promise<Object>} Information about the redone operation
2076
2462
  */
@@ -2101,42 +2487,36 @@ export async function createJJ(options) {
2101
2487
  });
2102
2488
  }
2103
2489
 
2104
- const redoneView = undoOp.undoneView || {};
2490
+ const targetOpId = undoOp.targetOpId || undoOp.undoneOpId;
2491
+ const targetOp = targetOpId && ops.find((op) => op.id === targetOpId);
2492
+ const redoneView = (targetOp && targetOp.view) || {};
2105
2493
 
2106
- // Restore the working-copy pointer.
2107
2494
  if (redoneView.workingCopy) {
2108
2495
  await workingCopy.setCurrentChange(redoneView.workingCopy);
2109
2496
  }
2110
2497
 
2111
- // Restore the working-copy files captured in the undone view.
2112
- if (redoneView.fileSnapshot) {
2113
- for (const [filePath, content] of Object.entries(redoneView.fileSnapshot)) {
2114
- try {
2115
- const fullPath = path.join(dir, filePath);
2116
- const pathParts = filePath.split('/');
2117
- if (pathParts.length > 1) {
2118
- const fullDirPath = path.join(dir, pathParts.slice(0, -1).join('/'));
2119
- await fs.promises.mkdir(fullDirPath, { recursive: true });
2120
- }
2121
- await fs.promises.writeFile(fullPath, content, 'utf8');
2122
- } catch (error) {
2123
- throw new JJError(
2124
- 'REDO_FILE_RESTORE_FAILED',
2125
- `Failed to restore file ${filePath} during redo: ${error.message}`,
2126
- { filePath, originalError: error.message }
2127
- );
2128
- }
2498
+ // Re-apply exactly what this undo captured right before it reverted
2499
+ // things — NOT computeGraphReversal(ops, targetOp): that scans for a
2500
+ // LATER op touching the same changeId/files/conflicts, which is the
2501
+ // right question for undo() ("has anything since made my target
2502
+ // stale?") but the wrong one here — after an undo, NOTHING later
2503
+ // re-established targetOp's own effect, so there's nothing to find
2504
+ // by scanning forward. undo() captured it directly for exactly this
2505
+ // reason (see its own comment).
2506
+ await graph.load();
2507
+ for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
2508
+ if (await graph.getChange(changeId)) {
2509
+ await graph.updateChange(snapshot);
2129
2510
  }
2130
2511
  }
2131
-
2132
- // Restore conflicts to the state that existed after the redone operation.
2512
+ if (undoOp.redoFileSnapshot !== undefined) {
2513
+ await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
2514
+ }
2133
2515
  await conflicts.load();
2134
- if (undoOp.undoneConflictsSnapshot) {
2135
- conflicts.conflicts = new Map(
2136
- Object.entries(undoOp.undoneConflictsSnapshot.conflicts || {})
2137
- );
2516
+ if (undoOp.redoConflictsSnapshot !== undefined) {
2517
+ conflicts.conflicts = new Map(Object.entries(undoOp.redoConflictsSnapshot.conflicts || {}));
2138
2518
  conflicts.fileConflicts = new Map(
2139
- Object.entries(undoOp.undoneConflictsSnapshot.fileConflicts || {})
2519
+ Object.entries(undoOp.redoConflictsSnapshot.fileConflicts || {})
2140
2520
  );
2141
2521
  await conflicts.save();
2142
2522
  }
@@ -2151,15 +2531,18 @@ export async function createJJ(options) {
2151
2531
  view: redoneView,
2152
2532
  });
2153
2533
 
2534
+ const restoredFileCount =
2535
+ undoOp.redoFileSnapshot !== undefined ? Object.keys(undoOp.redoFileSnapshot).length : 0;
2536
+
2154
2537
  return {
2155
2538
  redoneOperation: {
2156
2539
  description: undoOp.description,
2157
- undoneOpId: undoOp.undoneOpId,
2540
+ undoneOpId: targetOpId,
2158
2541
  },
2159
2542
  restoredState: {
2160
2543
  workingCopy: redoneView.workingCopy,
2161
2544
  heads: redoneView.heads,
2162
- fileCount: redoneView.fileSnapshot ? Object.keys(redoneView.fileSnapshot).length : 0,
2545
+ fileCount: restoredFileCount,
2163
2546
  },
2164
2547
  };
2165
2548
  },
@@ -2386,6 +2769,12 @@ export async function createJJ(options) {
2386
2769
  /**
2387
2770
  * Restore repository to a specific operation (matches `jj operation restore`)
2388
2771
  *
2772
+ * Fully reverts everything recorded after the target operation —
2773
+ * including graph rewrites (squash/rebase/abandon/new/edit), working
2774
+ * copy files, and conflict state, not just bookmarks/the working-copy
2775
+ * pointer (see issue #29) — using the same reversal primitive as
2776
+ * undo()/redo().
2777
+ *
2389
2778
  * @param {Record<string, any>} args - Arguments
2390
2779
  * @returns {Promise<Object>} Restore result
2391
2780
  */
@@ -2425,6 +2814,11 @@ export async function createJJ(options) {
2425
2814
  await workingCopy.setCurrentChange(targetOp.view.workingCopy);
2426
2815
  }
2427
2816
 
2817
+ // Revert the change graph, working-copy files, and conflict state
2818
+ // to exactly what they were right after targetOp ran.
2819
+ const reversal = computeGraphReversal(ops, targetOp);
2820
+ await applyGraphReversal(reversal);
2821
+
2428
2822
  // Record this restoration as a new operation
2429
2823
  await oplog.recordOperation({
2430
2824
  timestamp: new Date().toISOString(),
@@ -2478,6 +2872,7 @@ export async function createJJ(options) {
2478
2872
  // Compute the inverse changes
2479
2873
  const inversChanges = /** @type {Record<string, any>} */ ({
2480
2874
  bookmarks: {},
2875
+ tags: {},
2481
2876
  heads: [],
2482
2877
  });
2483
2878
 
@@ -2517,6 +2912,46 @@ export async function createJJ(options) {
2517
2912
 
2518
2913
  await bookmarks.save();
2519
2914
 
2915
+ // Revert tag changes (mirrors the bookmark diff above — issue #12).
2916
+ // Unlike bookmarks, TagStore has no "move" primitive (tags are meant
2917
+ // to be immutable once created), so a moved tag is reverted via
2918
+ // delete+recreate, matching how tag.set() itself updates a tag.
2919
+ const prevTags = previousOp.view?.tags || {};
2920
+ const targetTags = targetOp.view?.tags || {};
2921
+
2922
+ for (const name of Object.keys(targetTags)) {
2923
+ if (!prevTags[name]) {
2924
+ // Tag was added - delete it
2925
+ if (await tags.exists(name)) {
2926
+ await tags.delete(name);
2927
+ }
2928
+ inversChanges.tags[name] = { action: 'deleted' };
2929
+ } else if (prevTags[name] !== targetTags[name]) {
2930
+ // Tag was moved - restore old target
2931
+ if (await tags.exists(name)) {
2932
+ await tags.delete(name);
2933
+ }
2934
+ await tags.create(name, prevTags[name]);
2935
+ inversChanges.tags[name] = {
2936
+ action: 'moved',
2937
+ from: targetTags[name],
2938
+ to: prevTags[name],
2939
+ };
2940
+ }
2941
+ }
2942
+
2943
+ // Check for deleted tags (existed before, don't exist after)
2944
+ for (const name of Object.keys(prevTags)) {
2945
+ if (!targetTags[name] && !(await tags.exists(name))) {
2946
+ // Tag was deleted - restore it
2947
+ await tags.create(name, prevTags[name]);
2948
+ inversChanges.tags[name] = {
2949
+ action: 'restored',
2950
+ to: prevTags[name],
2951
+ };
2952
+ }
2953
+ }
2954
+
2520
2955
  // Revert working copy change if it changed
2521
2956
  if (previousOp.view?.workingCopy !== targetOp.view?.workingCopy) {
2522
2957
  await workingCopy.load();
@@ -2531,6 +2966,7 @@ export async function createJJ(options) {
2531
2966
  parents: [],
2532
2967
  view: {
2533
2968
  bookmarks: prevBookmarks,
2969
+ tags: prevTags,
2534
2970
  remoteBookmarks: previousOp.view?.remoteBookmarks || {},
2535
2971
  heads: previousOp.view?.heads || [],
2536
2972
  workingCopy: previousOp.view?.workingCopy,
@@ -2674,10 +3110,53 @@ export async function createJJ(options) {
2674
3110
  timestamp: new Date().toISOString(),
2675
3111
  });
2676
3112
 
3113
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
3114
+ // reverse the squash (un-abandon source, restore dest's description
3115
+ // and content) — see issue #29.
3116
+ /** @type {Record<string, any>} */
3117
+ const changeSnapshot = {
3118
+ [source]: structuredClone(sourceChange),
3119
+ [destChangeId]: structuredClone(destChange),
3120
+ };
3121
+
2677
3122
  // Mark source as abandoned
2678
3123
  sourceChange.abandoned = true;
2679
3124
  await graph.updateChange(sourceChange);
2680
3125
 
3126
+ // Merge source's file content into dest (issue #30: squash() used to
3127
+ // only combine descriptions, leaving dest's tree untouched). Compute
3128
+ // what source itself actually changed relative to ITS OWN parent
3129
+ // (its "diff") and fold just that onto dest, rather than overwriting
3130
+ // dest's whole snapshot with source's — a path source never touched
3131
+ // (inherited unchanged from that parent) must not clobber a
3132
+ // dest-specific version of the same path.
3133
+ const sourceParentId = sourceChange.parents && sourceChange.parents[0];
3134
+ const sourceParentChange = sourceParentId ? await graph.getChange(sourceParentId) : null;
3135
+ const sourceBaseSnapshot = (sourceParentChange && sourceParentChange.fileSnapshot) || {};
3136
+ const sourceSnapshot = sourceChange.fileSnapshot || {};
3137
+ const mergedSnapshot = { ...(destChange.fileSnapshot || {}) };
3138
+ const touchedPaths = new Set([
3139
+ ...Object.keys(sourceBaseSnapshot),
3140
+ ...Object.keys(sourceSnapshot),
3141
+ ]);
3142
+ for (const filePath of touchedPaths) {
3143
+ const baseValue = sourceBaseSnapshot[filePath];
3144
+ const sourceValue = sourceSnapshot[filePath];
3145
+ if (sourceValue === baseValue) {
3146
+ // source never touched this path (inherited as-is from its own
3147
+ // parent) — leave whatever dest already had for it alone.
3148
+ continue;
3149
+ }
3150
+ if (sourceValue === undefined) {
3151
+ // source deleted a file it inherited from its parent.
3152
+ delete mergedSnapshot[filePath];
3153
+ } else {
3154
+ // source added or modified this path.
3155
+ mergedSnapshot[filePath] = sourceValue;
3156
+ }
3157
+ }
3158
+ destChange.fileSnapshot = mergedSnapshot;
3159
+
2681
3160
  // Update description of dest to indicate squash (middleware will sync to Git)
2682
3161
  destChange.description += `\n\n(squashed from ${source.slice(0, 8)})`;
2683
3162
  await graph.updateChange(destChange);
@@ -2719,6 +3198,7 @@ export async function createJJ(options) {
2719
3198
  user: await getUserOplogInfo(),
2720
3199
  description: `squash ${source.slice(0, 8)} into ${destChangeId.slice(0, 8)}`,
2721
3200
  parents: [],
3201
+ changeSnapshot,
2722
3202
  view: {
2723
3203
  bookmarks: {},
2724
3204
  remoteBookmarks: {},
@@ -2797,15 +3277,29 @@ export async function createJJ(options) {
2797
3277
  timestamp: new Date().toISOString(),
2798
3278
  });
2799
3279
 
3280
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
3281
+ // reverse the abandon (un-abandon this change, un-reparent children)
3282
+ // — see issue #29.
3283
+ /** @type {Record<string, any>} */
3284
+ const changeSnapshot = { [changeId]: structuredClone(change) };
3285
+
2800
3286
  change.abandoned = true;
2801
3287
  await graph.updateChange(change);
2802
3288
 
3289
+ // Re-parent children onto the abandoned change's own parent(s)
3290
+ // (issue #30: this used to leave children pointing at the now-
3291
+ // abandoned commit). Matches real `jj abandon`: the abandoned
3292
+ // change is elided from the graph and its children attach directly
3293
+ // to whatever it was attached to.
3294
+ await reparentChildrenOnto(changeId, change.parents, changeSnapshot);
3295
+
2803
3296
  // Record operation
2804
3297
  await oplog.recordOperation({
2805
3298
  timestamp: new Date().toISOString(),
2806
3299
  user: { name: user.name, email: user.email, hostname: 'localhost' },
2807
3300
  description: `abandon change ${changeId.slice(0, 8)}`,
2808
3301
  parents: [],
3302
+ changeSnapshot,
2809
3303
  view: {
2810
3304
  bookmarks: {},
2811
3305
  remoteBookmarks: {},
@@ -2830,6 +3324,206 @@ export async function createJJ(options) {
2830
3324
  return change;
2831
3325
  },
2832
3326
 
3327
+ /**
3328
+ * Converge divergent copies of a change (matches `jj converge`, jj
3329
+ * v0.45.0+ — issue #32, extended for N-way per the isomorphic-jj v0.45
3330
+ * catch-up follow-up) — resolves ANY number of divergent copies of one
3331
+ * changeId in a single call. Real jj's own CLI currently only
3332
+ * converges one change-id per invocation (an explicit TODO in its own
3333
+ * source as of v0.45.1: "consider adding logic to deal with more than
3334
+ * one divergent change-id in one invocation"), which is what fixed
3335
+ * this package's own changeId-at-a-time API design — but its
3336
+ * underlying engine (`TruncatedEvolutionGraph`/`find_divergent_changes`)
3337
+ * already resolves every divergent revision of THAT one change-id
3338
+ * together, not just a pair. This generalizes the same per-path rule
3339
+ * accordingly: two-way "does the other side's value differ from base"
3340
+ * becomes N-way "how many DISTINCT values, across every copy, differ
3341
+ * from base" — 0 means nobody touched it, 1 means exactly one copy (or
3342
+ * several copies agreeing on the same new value) changed it and that
3343
+ * value wins, 2+ is a genuine conflict between that many copies.
3344
+ *
3345
+ * A change is divergent when more than one visible commit shares its
3346
+ * change id (see the `divergent()` revset, and
3347
+ * ChangeGraph.addDivergentCopy() for how a divergent copy gets added
3348
+ * in the first place — nothing in this library creates one on its
3349
+ * own yet; this resolves one once something has, e.g. a future git
3350
+ * fetch/concurrent-operation integration).
3351
+ *
3352
+ * Non-interactive-mode convention, matching this package's other
3353
+ * ambiguous-outcome APIs: like merge(), a genuine per-path conflict is
3354
+ * returned as data (`{ resolved: false, conflicts }`), not thrown —
3355
+ * you get a result back either way and decide what to do with
3356
+ * unresolved conflicts. What DOES throw is anything that isn't even a
3357
+ * well-formed convergence attempt (no divergence to resolve at all) —
3358
+ * the same category as merge()'s own `MERGE_ERROR` throw for "no
3359
+ * common ancestor found".
3360
+ *
3361
+ * @param {Record<string, any>|string} args - `{ changeId }`, or a bare changeId string
3362
+ * @returns {Promise<Object>} `{ changeId, resolved, conflicts, commitId? }`
3363
+ */
3364
+ async converge(args) {
3365
+ const changeId = typeof args === 'string' ? args : args && (args.changeId || args.change);
3366
+ if (!changeId || typeof changeId !== 'string') {
3367
+ throw new JJError('INVALID_ARGUMENT', 'Missing changeId argument', {
3368
+ suggestion: 'Provide the divergent changeId to converge: { changeId: "..." }',
3369
+ });
3370
+ }
3371
+
3372
+ await graph.load();
3373
+ await userConfig.load();
3374
+ const siblings = graph.getDivergentSiblings(changeId);
3375
+
3376
+ if (siblings.length < 2) {
3377
+ throw new JJError(
3378
+ 'NOT_DIVERGENT',
3379
+ `Change ${changeId} has no divergent copies to converge`,
3380
+ {
3381
+ changeId,
3382
+ suggestion:
3383
+ 'converge() is only meaningful for a changeId the divergent() revset reports',
3384
+ }
3385
+ );
3386
+ }
3387
+
3388
+ // The primary copy is whichever one getChange() resolves (stored
3389
+ // under the plain changeId key); every other one is a divergent copy
3390
+ // added via addDivergentCopy(). No cap on how many — see the doc
3391
+ // comment above.
3392
+ const primary = /** @type {any} */ (await graph.getChange(changeId));
3393
+ const others = siblings.filter((/** @type {any} */ s) => s.commitId !== primary.commitId);
3394
+
3395
+ // Base for the N-way merge: what this change looked like before it
3396
+ // diverged. This package doesn't retain historical snapshots (only
3397
+ // current content), so — same pragmatic choice rebase() makes for
3398
+ // "old base" — use the shared parent's current content; divergent
3399
+ // copies realistically share the same parent, since they're all
3400
+ // rewrites of one prior position in history, not unrelated commits
3401
+ // that happen to collide. Applied uniformly to every copy, not
3402
+ // per-copy — the same simplification the original two-way version
3403
+ // made, just not newly introduced by going N-way.
3404
+ const baseParentId = primary.parents && primary.parents[0];
3405
+ const baseParentChange = baseParentId ? await graph.getChange(baseParentId) : null;
3406
+ const baseFiles = new Map(
3407
+ Object.entries((baseParentChange && baseParentChange.fileSnapshot) || {})
3408
+ );
3409
+
3410
+ // Every copy (primary + others), each as its own file map, paired
3411
+ // with its commitId for conflict reporting.
3412
+ const copies = [primary, ...others].map((/** @type {any} */ c) => ({
3413
+ commitId: c.commitId,
3414
+ files: new Map(Object.entries(c.fileSnapshot || {})),
3415
+ }));
3416
+
3417
+ await conflicts.load();
3418
+ const conflictsSnapshot = {
3419
+ conflicts: Object.fromEntries(conflicts.conflicts),
3420
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
3421
+ };
3422
+
3423
+ const allPaths = new Set([
3424
+ ...baseFiles.keys(),
3425
+ ...copies.flatMap((c) => [...c.files.keys()]),
3426
+ ]);
3427
+
3428
+ /** @type {Record<string, string>} */
3429
+ const mergedSnapshot = {};
3430
+ /** @type {any[]} */
3431
+ const detectedConflicts = [];
3432
+
3433
+ for (const filePath of allPaths) {
3434
+ const baseValue = baseFiles.get(filePath);
3435
+ // Every DISTINCT value across all copies that differs from base,
3436
+ // each paired with (one of) the commitId(s) that has it — mirrors
3437
+ // ConflictModel._detectPathConflict's two-way "no conflict" rules,
3438
+ // generalized: 0 distinct changed values means nobody touched it
3439
+ // (base wins, i.e. nothing to write), 1 means only one side (or
3440
+ // several copies agreeing) changed it (that value wins), 2+ is a
3441
+ // genuine conflict.
3442
+ const changed = new Map();
3443
+ for (const copy of copies) {
3444
+ const value = copy.files.get(filePath);
3445
+ if (value !== baseValue && !changed.has(value)) {
3446
+ changed.set(value, copy.commitId);
3447
+ }
3448
+ }
3449
+
3450
+ if (changed.size === 0) {
3451
+ if (baseValue !== undefined) mergedSnapshot[filePath] = baseValue;
3452
+ } else if (changed.size === 1) {
3453
+ const [[value]] = changed;
3454
+ if (value !== undefined) mergedSnapshot[filePath] = value;
3455
+ } else {
3456
+ const versions = [...changed.entries()].map(([content, commitId]) => ({
3457
+ commitId,
3458
+ content,
3459
+ }));
3460
+ detectedConflicts.push(conflicts.createNWayConflict(filePath, baseValue, versions));
3461
+ }
3462
+ }
3463
+
3464
+ if (detectedConflicts.length > 0) {
3465
+ // Matches real jj: non-interactive mode aborts automatic
3466
+ // resolution rather than guessing — but per this package's own
3467
+ // merge() convention, that's reported back as data, not thrown.
3468
+ for (const conflict of detectedConflicts) {
3469
+ await conflicts.addConflict(conflict);
3470
+ }
3471
+ await oplog.recordOperation({
3472
+ timestamp: new Date().toISOString(),
3473
+ user: await getUserOplogInfo(),
3474
+ description: `converge ${changeId.slice(0, 8)} (unresolved)`,
3475
+ parents: [],
3476
+ conflictsSnapshot,
3477
+ view: {
3478
+ bookmarks: {},
3479
+ remoteBookmarks: {},
3480
+ heads: [],
3481
+ workingCopy: workingCopy.getCurrentChangeId(),
3482
+ },
3483
+ });
3484
+ return { changeId, resolved: false, conflicts: detectedConflicts };
3485
+ }
3486
+
3487
+ const user = /** @type {any} */ (userConfig.getUser());
3488
+ const changeSnapshot = { [changeId]: structuredClone(primary) };
3489
+ const otherIds = others.map((/** @type {any} */ o) => o.commitId.slice(0, 8)).join(', ');
3490
+
3491
+ const convergedChange = {
3492
+ ...primary,
3493
+ commitId: randomHex(20),
3494
+ fileSnapshot: mergedSnapshot,
3495
+ description: primary.description + `\n\n(converged divergent copies: ${otherIds})`,
3496
+ timestamp: new Date().toISOString(),
3497
+ committer: {
3498
+ name: user.name,
3499
+ email: user.email,
3500
+ timestamp: new Date().toISOString(),
3501
+ },
3502
+ divergent: false,
3503
+ };
3504
+ await graph.updateChange(convergedChange);
3505
+ for (const other of others) {
3506
+ await graph.deleteDivergentCopy(changeId, other.commitId);
3507
+ }
3508
+
3509
+ await oplog.recordOperation({
3510
+ timestamp: new Date().toISOString(),
3511
+ user: await getUserOplogInfo(),
3512
+ description: `converge ${changeId.slice(0, 8)}`,
3513
+ parents: [],
3514
+ changeSnapshot,
3515
+ conflictsSnapshot,
3516
+ view: {
3517
+ bookmarks: {},
3518
+ remoteBookmarks: {},
3519
+ heads: [changeId],
3520
+ workingCopy: workingCopy.getCurrentChangeId(),
3521
+ },
3522
+ });
3523
+
3524
+ return { changeId, resolved: true, conflicts: [], commitId: convergedChange.commitId };
3525
+ },
3526
+
2833
3527
  /**
2834
3528
  * Backout a change - create a new change that reverses the effect of the specified change
2835
3529
  *
@@ -4669,8 +5363,14 @@ export async function createJJ(options) {
4669
5363
  * @returns {Promise<Object>} Repository root information
4670
5364
  */
4671
5365
  async root() {
4672
- // Return the directory containing .git
4673
- const gitDir = path.join(dir, '.git');
5366
+ // `dir` is a real host filesystem path (unlike the repo-relative
5367
+ // git/jj paths `path` — posix-path.js — is for), so it needs the
5368
+ // host's own separator here, not always '/': on Windows Node's real
5369
+ // `fs` still resolves a forward-slash-joined path fine, but this
5370
+ // method hands the string back to the caller as data, and jj's own
5371
+ // `git root` returns a platform-native path there.
5372
+ const isWin32 = typeof process !== 'undefined' && process.platform === 'win32';
5373
+ const gitDir = isWin32 ? `${dir.replace(/[\\/]+$/, '')}\\.git` : path.join(dir, '.git');
4674
5374
 
4675
5375
  try {
4676
5376
  await fs.promises.access(gitDir);
@@ -4749,7 +5449,7 @@ export async function createJJ(options) {
4749
5449
  const wcFiles = await workingCopy.listFiles();
4750
5450
  for (const file of wcFiles) {
4751
5451
  try {
4752
- const content = await fs.promises.readFile(path.join(dir, file), 'utf-8');
5452
+ const content = await fs.promises.readFile(path.join(dir, file), 'utf8');
4753
5453
  leftFiles.set(file, content);
4754
5454
  } catch (error) {
4755
5455
  // File might be deleted or binary - throw error for explicit handling
@@ -4892,7 +5592,7 @@ export async function createJJ(options) {
4892
5592
  }
4893
5593
 
4894
5594
  // Write resolved content to file
4895
- await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf-8');
5595
+ await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf8');
4896
5596
 
4897
5597
  // Mark conflict as resolved
4898
5598
  await conflicts.resolveConflict(args.conflictId, 'manual');
@@ -4935,7 +5635,7 @@ export async function createJJ(options) {
4935
5635
  const resolvedContent = _resolveWithStrategy(conflict, args.strategy);
4936
5636
 
4937
5637
  // Write resolved content
4938
- await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf-8');
5638
+ await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf8');
4939
5639
 
4940
5640
  // Mark as resolved
4941
5641
  await conflicts.resolveConflict(conflict.conflictId, args.strategy);
@@ -4968,11 +5668,18 @@ export async function createJJ(options) {
4968
5668
  throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
4969
5669
  }
4970
5670
 
4971
- // Generate standard conflict markers
5671
+ // Generate standard conflict markers. Force a newline before each
5672
+ // boundary marker whenever there's non-empty content that doesn't
5673
+ // already end in one — otherwise the content fuses onto the
5674
+ // `=======`/`>>>>>>> theirs` marker, producing a corrupted/unparseable
5675
+ // line (see issue #15). Empty sides (e.g. a modify-delete conflict)
5676
+ // intentionally get no extra blank line before the marker.
4972
5677
  const ours = conflict.sides.left || '';
4973
5678
  const theirs = conflict.sides.right || '';
5679
+ const oursSep = ours && !ours.endsWith('\n') ? '\n' : '';
5680
+ const theirsSep = theirs && !theirs.endsWith('\n') ? '\n' : '';
4974
5681
 
4975
- return `<<<<<<< ours\n${ours}=======\n${theirs}>>>>>>> theirs`;
5682
+ return `<<<<<<< ours\n${ours}${oursSep}=======\n${theirs}${theirsSep}>>>>>>> theirs`;
4976
5683
  },
4977
5684
 
4978
5685
  /**
@@ -5059,10 +5766,17 @@ export async function createJJ(options) {
5059
5766
  * Searches the tracked files of a change (defaults to the working copy)
5060
5767
  * for lines matching a pattern. Following jj v0.41, the pattern is treated
5061
5768
  * as a regular expression by default; pass `{ kind: 'substring' }` for a
5062
- * literal search.
5769
+ * literal search. isomorphic-jj has always returned the matched line
5770
+ * (and its 1-based `lineNumber`) alongside the path, which is what jj
5771
+ * v0.44 made the CLI's new default (`jj file search` used to print only
5772
+ * file paths). Pass `{ nameOnly: true }` for the pre-v0.44 CLI
5773
+ * behavior — a deduplicated list of matching file paths, matching the
5774
+ * new `--name-only` flag.
5063
5775
  *
5064
- * @param {Record<string, any>} args - Arguments
5065
- * @returns {Promise<Array<{path: string, lineNumber: number, line: string}>>}
5776
+ * @param {Record<string, any>} args - Arguments. Pass `nameOnly: true`
5777
+ * to return only unique matching file paths instead of per-line
5778
+ * matches (jj v0.44 `--name-only`)
5779
+ * @returns {Promise<Array<{path: string, lineNumber: number, line: string}>|Array<string>>}
5066
5780
  */
5067
5781
  async search(args) {
5068
5782
  if (!args || !args.pattern) {
@@ -5113,6 +5827,10 @@ export async function createJJ(options) {
5113
5827
  }
5114
5828
  });
5115
5829
  }
5830
+
5831
+ if (args.nameOnly) {
5832
+ return Array.from(new Set(results.map((r) => r.path)));
5833
+ }
5116
5834
  return results;
5117
5835
  },
5118
5836
 
@@ -5357,7 +6075,7 @@ export async function createJJ(options) {
5357
6075
  if (pathParts.length > 1) {
5358
6076
  const dirPath = pathParts.slice(0, -1).join('/');
5359
6077
  const fullDirPath = path.join(args.path, dirPath);
5360
- await fs.promises.mkdir(fullDirPath, { recursive: true });
6078
+ await mkdirp(fs, fullDirPath);
5361
6079
  }
5362
6080
 
5363
6081
  // Write file content
@@ -6194,6 +6912,10 @@ export async function createJJ(options) {
6194
6912
  view: {
6195
6913
  bookmarks: {},
6196
6914
  remoteBookmarks: {},
6915
+ // Tag delta for this operation (mirrors how `bookmarks` records
6916
+ // its delta above), so operations.revert() can diff it — see
6917
+ // issue #12.
6918
+ tags: { [args.name]: changeId },
6197
6919
  heads: [],
6198
6920
  workingCopy: workingCopy.getCurrentChangeId(),
6199
6921
  },
@@ -6239,6 +6961,7 @@ export async function createJJ(options) {
6239
6961
  view: {
6240
6962
  bookmarks: {},
6241
6963
  remoteBookmarks: {},
6964
+ tags: { [args.name]: changeId },
6242
6965
  heads: [],
6243
6966
  workingCopy: workingCopy.getCurrentChangeId(),
6244
6967
  },
@@ -6248,10 +6971,11 @@ export async function createJJ(options) {
6248
6971
  },
6249
6972
 
6250
6973
  /**
6251
- * List all tags
6974
+ * List all tags. Each entry also carries a `tracking: { remote, ref }`
6975
+ * field when the tag is tracking a remote (jj v0.44 `jj tag track`).
6252
6976
  *
6253
6977
  * @param {Record<string, any>} [args] - Optional filter arguments
6254
- * @returns {Promise<Array<{name: string, changeId: string}>>} List of tags
6978
+ * @returns {Promise<Array<Record<string, any>>>} List of tags
6255
6979
  */
6256
6980
  async list(args) {
6257
6981
  const pattern = args?.pattern;
@@ -6283,6 +7007,7 @@ export async function createJJ(options) {
6283
7007
  view: {
6284
7008
  bookmarks: {},
6285
7009
  remoteBookmarks: {},
7010
+ tags: {},
6286
7011
  heads: [],
6287
7012
  workingCopy: workingCopy.getCurrentChangeId(),
6288
7013
  },
@@ -6290,6 +7015,94 @@ export async function createJJ(options) {
6290
7015
 
6291
7016
  return { deleted: args.name };
6292
7017
  },
7018
+
7019
+ /**
7020
+ * Associate a local tag with a remote tag so it is updated on fetch
7021
+ * (matches `jj tag track`, new in jj v0.44).
7022
+ *
7023
+ * jj v0.44 made tags remote-trackable just like bookmarks: fetched
7024
+ * tags are tracked by default, and `jj tag track`/`untrack` let you
7025
+ * opt individual tags in or out by hand. isomorphic-jj doesn't (yet)
7026
+ * auto-import remote tags during `git.fetch()` — see CHANGELOG — so
7027
+ * this records tracking intent/state the same way `bookmark.track()`
7028
+ * does, without changing fetch's own behavior.
7029
+ *
7030
+ * @param {Record<string, any>} args - Tag arguments: `{ name, remote }`
7031
+ * (`name` is the local tag name to track; `remote` defaults to `'origin'`)
7032
+ * @returns {Promise<{name: string, remote: string, tracking: boolean}>}
7033
+ */
7034
+ async track(args) {
7035
+ if (!args || !args.name) {
7036
+ throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
7037
+ suggestion: 'Provide tag name: { name: "v1.0.0", remote: "origin" }',
7038
+ });
7039
+ }
7040
+
7041
+ const remote = args.remote || 'origin';
7042
+ const remoteTagName = `${remote}/${args.name}`;
7043
+
7044
+ const tagMap = await tags.load();
7045
+ /** @type {Record<string, any>} */
7046
+ const tracking = tags.tracking || {};
7047
+ tracking[args.name] = { remote, remoteName: args.name };
7048
+ tags.tracking = tracking;
7049
+ await tags.save(tagMap);
7050
+
7051
+ // Record operation
7052
+ await oplog.recordOperation({
7053
+ timestamp: new Date().toISOString(),
7054
+ user: await getUserOplogInfo(),
7055
+ description: `tag track ${args.name} from ${remote}`,
7056
+ parents: [],
7057
+ view: {
7058
+ bookmarks: {},
7059
+ remoteBookmarks: { [remoteTagName]: true },
7060
+ heads: [],
7061
+ workingCopy: workingCopy.getCurrentChangeId(),
7062
+ },
7063
+ });
7064
+
7065
+ return { name: args.name, remote, tracking: true };
7066
+ },
7067
+
7068
+ /**
7069
+ * Stop tracking a remote tag (matches `jj tag untrack`, new in jj v0.44).
7070
+ *
7071
+ * @param {Record<string, any>} args - Tag arguments: `{ name }`, the
7072
+ * local tag name to stop tracking
7073
+ * @returns {Promise<{name: string, tracking: boolean, wasTracking: boolean}>}
7074
+ */
7075
+ async untrack(args) {
7076
+ if (!args || !args.name) {
7077
+ throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
7078
+ suggestion: 'Provide tag name: { name: "v1.0.0" }',
7079
+ });
7080
+ }
7081
+
7082
+ const tagMap = await tags.load();
7083
+ /** @type {Record<string, any>} */
7084
+ const tracking = tags.tracking || {};
7085
+ const wasTracking = tracking[args.name];
7086
+ delete tracking[args.name];
7087
+ tags.tracking = tracking;
7088
+ await tags.save(tagMap);
7089
+
7090
+ // Record operation
7091
+ await oplog.recordOperation({
7092
+ timestamp: new Date().toISOString(),
7093
+ user: await getUserOplogInfo(),
7094
+ description: `tag untrack ${args.name}`,
7095
+ parents: [],
7096
+ view: {
7097
+ bookmarks: {},
7098
+ remoteBookmarks: {},
7099
+ heads: [],
7100
+ workingCopy: workingCopy.getCurrentChangeId(),
7101
+ },
7102
+ });
7103
+
7104
+ return { name: args.name, tracking: false, wasTracking: !!wasTracking };
7105
+ },
6293
7106
  },
6294
7107
 
6295
7108
  /**