isomorphic-jj 1.4.3 → 1.8.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.
@@ -23,13 +23,14 @@ import path from 'path';
23
23
  * Create and initialize a JJ repository instance
24
24
  *
25
25
  * @param {Object} options - Configuration options
26
- * @param {Object} options.fs - Filesystem implementation (Node fs or LightningFS)
26
+ * @param {any} options.fs - Filesystem implementation (Node fs or LightningFS)
27
27
  * @param {string} options.dir - Repository directory path
28
- * @param {Object} [options.git] - isomorphic-git instance (enables Git backend)
29
- * @param {Object} [options.http] - HTTP client for network operations
30
- * @param {string|Object} [options.backend] - Backend name ('isomorphic-git', 'memory') or backend instance
28
+ * @param {any} [options.git] - isomorphic-git instance (enables Git backend)
29
+ * @param {any} [options.http] - HTTP client for network operations
30
+ * @param {string|any} [options.backend] - Backend name ('isomorphic-git', 'memory') or backend instance
31
+ * @param {boolean} [options.autoSnapshot] - Auto-snapshot the working dir before read/commit ops (default true)
31
32
  *
32
- * @returns {Promise<Object>} Initialized JJ instance
33
+ * @returns {Promise<Record<string, any>>} Initialized JJ instance
33
34
  */
34
35
  export async function createJJ(options) {
35
36
  if (!options) {
@@ -43,6 +44,11 @@ export async function createJJ(options) {
43
44
  const git = options.git;
44
45
  const http = options.http;
45
46
  const backend = options.backend;
47
+ // v1.6: automatically snapshot the working directory (disk-walk) before
48
+ // read/commit operations, so files created out-of-band (editor, shell, git
49
+ // checkout) are picked up — matching jj's "snapshot before every command".
50
+ // Enabled by default; pass { autoSnapshot: false } to opt out.
51
+ const autoSnapshot = options.autoSnapshot !== false;
46
52
 
47
53
  if (!fs) {
48
54
  throw new JJError('INVALID_CONFIG', 'Missing fs', {
@@ -57,7 +63,7 @@ export async function createJJ(options) {
57
63
  }
58
64
 
59
65
  // Create core components
60
- const storage = new Storage(fs, dir);
66
+ const storage = /** @type {any} */ (new Storage(fs, dir));
61
67
  const baseGraph = new ChangeGraph(storage);
62
68
  const workingCopy = new WorkingCopy(storage, fs, dir);
63
69
  const oplog = new OperationLog(storage);
@@ -74,7 +80,7 @@ export async function createJJ(options) {
74
80
  */
75
81
  const getUserOplogInfo = async () => {
76
82
  await userConfig.load();
77
- const user = userConfig.getUser();
83
+ const user = /** @type {any} */ (userConfig.getUser());
78
84
  return { name: user.name, email: user.email, hostname: 'localhost' };
79
85
  };
80
86
 
@@ -124,14 +130,58 @@ export async function createJJ(options) {
124
130
  return fileSnapshot;
125
131
  };
126
132
 
133
+ /**
134
+ * Automatic working-copy snapshot (v1.6).
135
+ *
136
+ * Walks the working directory on disk and reconciles tracked file state so
137
+ * that files created/modified/deleted outside of jj.write() are reflected in
138
+ * status/describe/diff/file.* . No-op when autoSnapshot is disabled or the
139
+ * working copy isn't initialized yet. Best-effort: never throws.
140
+ *
141
+ * @returns {Promise<{added: string[], modified: string[], deleted: string[]} | null>}
142
+ */
143
+ const autoSnapshotWorkingCopy = async () => {
144
+ if (!autoSnapshot) return null;
145
+ try {
146
+ await workingCopy.load();
147
+ } catch {
148
+ return null; // repo not initialized yet
149
+ }
150
+
151
+ let result;
152
+ try {
153
+ result = await workingCopy.snapshot();
154
+ } catch {
155
+ return null; // best-effort; don't let snapshotting break the operation
156
+ }
157
+
158
+ // If the disk changed, refresh the working-copy change's content snapshot so
159
+ // read()/file.list()/file.search()/diff() reflect out-of-band edits.
160
+ if (result && (result.added.length || result.modified.length || result.deleted.length)) {
161
+ try {
162
+ await graph.load();
163
+ const wcId = workingCopy.getCurrentChangeId();
164
+ const change = await graph.getChange(wcId);
165
+ if (change) {
166
+ change.fileSnapshot = await snapshotFilesystem();
167
+ await graph.updateChange(change);
168
+ }
169
+ } catch {
170
+ // Best-effort content refresh; tracking state is already updated.
171
+ }
172
+ }
173
+
174
+ return result;
175
+ };
176
+
127
177
  /**
128
178
  * Helper to dispatch events with async listener support and error handling
129
179
  * Store listener errors in detail so we can check them after dispatch
130
- * @param {EventTarget} eventTarget
180
+ * @param {any} eventTarget
131
181
  * @param {string} eventName
132
182
  * @param {any} detail
133
183
  * @param {{ cancelable?: boolean }} [options]
134
- * @returns {Promise<void>}
184
+ * @returns {Promise<any>}
135
185
  */
136
186
  const dispatchEventAsync = async (eventTarget, eventName, detail, options = {}) => {
137
187
  // Extend detail to track errors from listeners
@@ -159,11 +209,10 @@ export async function createJJ(options) {
159
209
 
160
210
  // Check if event was prevented (only relevant for cancelable events)
161
211
  if (!notCancelled && options.cancelable !== false) {
162
- throw new JJError(
163
- 'EVENT_CANCELLED',
164
- `Operation cancelled by ${eventName} event listener`,
165
- { eventName, detail }
166
- );
212
+ throw new JJError('EVENT_CANCELLED', `Operation cancelled by ${eventName} event listener`, {
213
+ eventName,
214
+ detail,
215
+ });
167
216
  }
168
217
 
169
218
  return event;
@@ -171,6 +220,7 @@ export async function createJJ(options) {
171
220
 
172
221
  // Helper to sync a JJ change to a Git commit
173
222
  // In JJ, every change has a corresponding Git commit
223
+ /** @param {any} change */
174
224
  const syncChangeToGit = async (change) => {
175
225
  if (!gitBackend || !gitBackend.createCommit) {
176
226
  return; // No Git backend, skip
@@ -186,8 +236,12 @@ export async function createJJ(options) {
186
236
  const parentCommitIds = [];
187
237
  await baseGraph.load();
188
238
  for (const parentChangeId of change.parents) {
189
- const parentChange = await baseGraph.getChange(parentChangeId);
190
- if (parentChange && parentChange.commitId && parentChange.commitId !== '0000000000000000000000000000000000000000') {
239
+ const parentChange = /** @type {any} */ (await baseGraph.getChange(parentChangeId));
240
+ if (
241
+ parentChange &&
242
+ parentChange.commitId &&
243
+ parentChange.commitId !== '0000000000000000000000000000000000000000'
244
+ ) {
191
245
  parentCommitIds.push(parentChange.commitId);
192
246
  }
193
247
  }
@@ -229,31 +283,31 @@ export async function createJJ(options) {
229
283
  * Create a middleware-wrapped graph that intercepts add/update operations
230
284
  * This allows pluggable backends without modifying core ChangeGraph logic
231
285
  */
232
- const createGraphWithMiddleware = (baseGraph, hooks) => {
286
+ const createGraphWithMiddleware = (/** @type {any} */ baseGraph, /** @type {any} */ hooks) => {
233
287
  return {
234
288
  // Delegate all read operations directly to base graph
235
289
  load: () => baseGraph.load(),
236
- getChange: (changeId) => baseGraph.getChange(changeId),
290
+ getChange: (/** @type {any} */ changeId) => baseGraph.getChange(changeId),
237
291
  getAllChanges: () => baseGraph.getAll(), // Alias for compatibility
238
- getAll: () => baseGraph.getAll(), // Used by revset engine
239
- getAncestors: (changeId) => baseGraph.getAncestors(changeId),
240
- getDescendants: (changeId) => baseGraph.getDescendants(changeId),
241
- getParents: (changeId) => baseGraph.getParents(changeId),
242
- getChildren: (changeId) => baseGraph.getChildren(changeId),
243
- findChangeByCommitId: async (commitId) => {
292
+ getAll: () => baseGraph.getAll(), // Used by revset engine
293
+ getAncestors: (/** @type {any} */ changeId) => baseGraph.getAncestors(changeId),
294
+ getDescendants: (/** @type {any} */ changeId) => baseGraph.getDescendants(changeId),
295
+ getParents: (/** @type {any} */ changeId) => baseGraph.getParents(changeId),
296
+ getChildren: (/** @type {any} */ changeId) => baseGraph.getChildren(changeId),
297
+ findChangeByCommitId: async (/** @type {any} */ commitId) => {
244
298
  const changeId = baseGraph.findByCommitId(commitId);
245
299
  return changeId ? await baseGraph.getChange(changeId) : null;
246
300
  },
247
301
 
248
302
  // Wrap write operations with middleware hooks
249
- async addChange(change) {
303
+ async addChange(/** @type {any} */ change) {
250
304
  await baseGraph.addChange(change);
251
305
  if (hooks.onAddChange) {
252
306
  await hooks.onAddChange(change);
253
307
  }
254
308
  },
255
309
 
256
- async updateChange(change) {
310
+ async updateChange(/** @type {any} */ change) {
257
311
  await baseGraph.updateChange(change);
258
312
  if (hooks.onUpdateChange) {
259
313
  await hooks.onUpdateChange(change);
@@ -267,6 +321,7 @@ export async function createJJ(options) {
267
321
 
268
322
  // Create Git backend if specified
269
323
  // Auto-detect: if git instance provided, use isomorphic-git backend
324
+ /** @type {any} */
270
325
  let gitBackend = null;
271
326
  if (git || backend === 'isomorphic-git' || backend === 'git') {
272
327
  gitBackend = new IsomorphicGitBackend({
@@ -282,23 +337,23 @@ export async function createJJ(options) {
282
337
  // Wrap the base graph with middleware that syncs to Git
283
338
  // This allows us to support different backends in the future
284
339
  const graph = createGraphWithMiddleware(baseGraph, {
285
- onAddChange: async (change) => {
340
+ onAddChange: async (/** @type {any} */ change) => {
286
341
  // Sync new change to Git backend
287
342
  await syncChangeToGit(change);
288
343
  },
289
- onUpdateChange: async (change) => {
344
+ onUpdateChange: async (/** @type {any} */ change) => {
290
345
  // Sync updated change to Git backend
291
346
  await syncChangeToGit(change);
292
347
  },
293
348
  });
294
349
 
295
350
  // Create revset engine with the middleware-wrapped graph (v0.4: added bookmarkStore)
296
- const revset = new RevsetEngine(graph, workingCopy, userConfig, bookmarks);
351
+ const revset = new RevsetEngine(graph, workingCopy, userConfig, bookmarks, tags);
297
352
 
298
353
  /**
299
354
  * Helper to resolve conflicts with different strategies (v0.5)
300
355
  *
301
- * @param {Object} conflict - Conflict object
356
+ * @param {any} conflict - Conflict object
302
357
  * @param {string} strategy - Resolution strategy ('ours', 'theirs', 'union')
303
358
  * @returns {string} Resolved content
304
359
  */
@@ -323,27 +378,31 @@ export async function createJJ(options) {
323
378
  }
324
379
 
325
380
  // Create JJ instance (backgroundOps will be initialized after jj object is created)
326
- const jj = {
381
+ const jj = /** @type {any} */ ({
327
382
  storage,
328
383
  graph,
329
384
  workingCopy,
330
385
  oplog,
331
386
  bookmarks,
332
387
  revset,
333
- conflicts,
388
+ tags, // TagStore instance (raw); public API is the `tag` namespace below
389
+ // NOTE: the raw ConflictModel is intentionally not exposed here because the
390
+ // public `conflicts` namespace (defined below) uses the same key. It remains
391
+ // accessible internally via the module closure.
392
+ conflictModel: conflicts, // v1.5: advanced access to the raw ConflictModel
334
393
  workspaces,
335
394
  userConfig,
336
- mergeDrivers, // v0.5: expose merge driver registry
337
- backgroundOps: null, // Will be initialized below
338
- backend: gitBackend, // Expose backend for advanced users
339
-
395
+ mergeDrivers, // v0.5: expose merge driver registry
396
+ backgroundOps: null, // Will be initialized below
397
+ backend: gitBackend, // Expose backend for advanced users
398
+
340
399
  /**
341
400
  * Initialize a new JJ repository
342
401
  *
343
402
  * NOTE: This is a convenience alias. For Git-backed repos, prefer jj.git.init()
344
403
  * to match the JJ CLI semantics.
345
404
  */
346
- async init(opts = {}) {
405
+ async init(/** @type {Record<string, any>} */ opts = {}) {
347
406
  // If Git backend is available, delegate to jj.git.init()
348
407
  if (gitBackend) {
349
408
  return await jj.git.init(opts);
@@ -367,7 +426,7 @@ export async function createJJ(options) {
367
426
 
368
427
  // Create root change
369
428
  const rootChangeId = generateChangeId();
370
- const user = userConfig.getUser();
429
+ const user = /** @type {any} */ (userConfig.getUser());
371
430
  const rootChange = {
372
431
  changeId: rootChangeId,
373
432
  commitId: '0000000000000000000000000000000000000000',
@@ -412,9 +471,7 @@ export async function createJJ(options) {
412
471
  /**
413
472
  * Write a file to the working copy
414
473
  *
415
- * @param {Object} args - Arguments
416
- * @param {string} args.path - File path relative to repo root
417
- * @param {string|Uint8Array} args.data - File contents
474
+ * @param {Record<string, any>} args - Arguments
418
475
  * @returns {Promise<Object>} File information including path, size, and mode
419
476
  */
420
477
  async write(args) {
@@ -441,9 +498,7 @@ export async function createJJ(options) {
441
498
  }
442
499
 
443
500
  // Write the file
444
- const data = typeof args.data === 'string'
445
- ? args.data
446
- : args.data;
501
+ const data = typeof args.data === 'string' ? args.data : args.data;
447
502
  await fs.promises.writeFile(fullPath, data, 'utf8');
448
503
 
449
504
  // Track the file in working copy
@@ -468,10 +523,7 @@ export async function createJJ(options) {
468
523
  /**
469
524
  * Read a file from the working copy or from a specific change
470
525
  *
471
- * @param {Object} args - Arguments
472
- * @param {string} args.path - File path relative to repo root
473
- * @param {string} [args.changeId] - Change ID to read from (defaults to working copy)
474
- * @param {string} [args.encoding='utf-8'] - File encoding ('utf-8' or 'binary')
526
+ * @param {Record<string, any>} args - Arguments
475
527
  * @returns {Promise<string|Uint8Array>} File contents
476
528
  */
477
529
  async read(args) {
@@ -485,12 +537,15 @@ export async function createJJ(options) {
485
537
 
486
538
  // Read from working copy if no changeId specified
487
539
  if (!args.changeId) {
540
+ // v1.6: reflect out-of-band changes before reading the working copy.
541
+ await autoSnapshotWorkingCopy();
488
542
  // Check sparse patterns
489
543
  await workingCopy.load();
490
544
  if (!workingCopy.matchesSparsePatterns(args.path)) {
491
545
  throw new JJError('FILE_NOT_IN_SPARSE', `File ${args.path} not in sparse checkout`, {
492
546
  path: args.path,
493
- suggestion: 'Add pattern to sparse checkout: jj.sparse.add({ patterns: ["' + args.path + '"] })',
547
+ suggestion:
548
+ 'Add pattern to sparse checkout: jj.sparse.add({ patterns: ["' + args.path + '"] })',
494
549
  });
495
550
  }
496
551
 
@@ -530,7 +585,11 @@ export async function createJJ(options) {
530
585
  }
531
586
 
532
587
  // Fallback: read from Git if we have a commitId
533
- if (gitBackend && change.commitId && change.commitId !== '0000000000000000000000000000000000000000') {
588
+ if (
589
+ gitBackend &&
590
+ change.commitId &&
591
+ change.commitId !== '0000000000000000000000000000000000000000'
592
+ ) {
534
593
  try {
535
594
  const git = (await import('isomorphic-git')).default;
536
595
  const { blob } = await git.readBlob({
@@ -545,25 +604,33 @@ export async function createJJ(options) {
545
604
  }
546
605
  return blob;
547
606
  } catch (error) {
548
- throw new JJError('FILE_NOT_FOUND', `File ${args.path} not found in change ${args.changeId}`, {
549
- path: args.path,
550
- changeId: args.changeId,
551
- originalError: error,
552
- });
607
+ throw new JJError(
608
+ 'FILE_NOT_FOUND',
609
+ `File ${args.path} not found in change ${args.changeId}`,
610
+ {
611
+ path: args.path,
612
+ changeId: args.changeId,
613
+ originalError: error,
614
+ }
615
+ );
553
616
  }
554
617
  }
555
618
 
556
- throw new JJError('FILE_NOT_FOUND', `File ${args.path} not found in change ${args.changeId}`, {
557
- path: args.path,
558
- changeId: args.changeId,
559
- suggestion: 'File not in snapshot cache and no Git commit available',
560
- });
619
+ throw new JJError(
620
+ 'FILE_NOT_FOUND',
621
+ `File ${args.path} not found in change ${args.changeId}`,
622
+ {
623
+ path: args.path,
624
+ changeId: args.changeId,
625
+ suggestion: 'File not in snapshot cache and no Git commit available',
626
+ }
627
+ );
561
628
  },
562
629
 
563
630
  /**
564
631
  * Alias for read() - matches jj CLI 'cat' command
565
632
  *
566
- * @param {Object} args - Arguments (same as read())
633
+ * @param {Record<string, any>} args - Arguments (same as read())
567
634
  * @returns {Promise<string|Uint8Array>} File contents
568
635
  */
569
636
  async cat(args) {
@@ -573,14 +640,14 @@ export async function createJJ(options) {
573
640
  /**
574
641
  * List files in the working copy or in a specific change
575
642
  *
576
- * @param {Object} [args={}] - Arguments
577
- * @param {string} [args.changeId] - Change ID to list files from (defaults to working copy)
578
- * @param {boolean} [args.recursive=false] - Include files in subdirectories (for future use)
643
+ * @param {Record<string, any>} [args={}] - Arguments
579
644
  * @returns {Promise<Array<string>>} Array of file paths
580
645
  */
581
646
  async listFiles(args = {}) {
582
647
  // List working copy files if no changeId specified
583
648
  if (!args.changeId) {
649
+ // v1.6: include files created on disk out-of-band.
650
+ await autoSnapshotWorkingCopy();
584
651
  await workingCopy.load();
585
652
  return await workingCopy.listFiles();
586
653
  }
@@ -600,7 +667,11 @@ export async function createJJ(options) {
600
667
  }
601
668
 
602
669
  // Fallback: read from Git tree
603
- if (gitBackend && change.commitId && change.commitId !== '0000000000000000000000000000000000000000') {
670
+ if (
671
+ gitBackend &&
672
+ change.commitId &&
673
+ change.commitId !== '0000000000000000000000000000000000000000'
674
+ ) {
604
675
  try {
605
676
  const git = (await import('isomorphic-git')).default;
606
677
  const { tree } = await git.readTree({
@@ -609,9 +680,7 @@ export async function createJJ(options) {
609
680
  oid: change.commitId,
610
681
  });
611
682
 
612
- return tree
613
- .filter(entry => entry.type === 'blob')
614
- .map(entry => entry.path);
683
+ return tree.filter((entry) => entry.type === 'blob').map((entry) => entry.path);
615
684
  } catch (error) {
616
685
  throw new JJError('TREE_READ_FAILED', `Failed to read tree for change ${args.changeId}`, {
617
686
  changeId: args.changeId,
@@ -628,10 +697,7 @@ export async function createJJ(options) {
628
697
  *
629
698
  * For large files, use streaming to avoid loading entire file into memory.
630
699
  *
631
- * @param {Object} args - Arguments
632
- * @param {string} args.path - File path relative to repo root
633
- * @param {string} [args.changeId] - Change ID to read from (defaults to working copy)
634
- * @param {string} [args.encoding] - Optional encoding ('utf-8', etc.)
700
+ * @param {Record<string, any>} args - Arguments
635
701
  * @returns {Promise<ReadableStream>} Readable stream
636
702
  * @throws {JJError} If not in Node.js environment or file not found
637
703
  */
@@ -653,7 +719,10 @@ export async function createJJ(options) {
653
719
  if (!args.changeId) {
654
720
  const fullPath = path.join(dir, args.path);
655
721
  try {
656
- const stream = fs.createReadStream(fullPath, args.encoding ? { encoding: args.encoding } : {});
722
+ const stream = fs.createReadStream(
723
+ fullPath,
724
+ args.encoding ? { encoding: args.encoding } : {}
725
+ );
657
726
  return stream;
658
727
  } catch (error) {
659
728
  throw new JJError('FILE_NOT_FOUND', `File ${args.path} not found in working copy`, {
@@ -665,9 +734,14 @@ export async function createJJ(options) {
665
734
 
666
735
  // Reading from a specific change - for historical data, fall back to read()
667
736
  // Streaming from Git objects requires more complex implementation
668
- throw new JJError('UNSUPPORTED_OPERATION', 'Streaming from historical changes not yet supported', {
669
- suggestion: 'Use read({ path, changeId }) for historical file access, or readStream({ path }) for working copy',
670
- });
737
+ throw new JJError(
738
+ 'UNSUPPORTED_OPERATION',
739
+ 'Streaming from historical changes not yet supported',
740
+ {
741
+ suggestion:
742
+ 'Use read({ path, changeId }) for historical file access, or readStream({ path }) for working copy',
743
+ }
744
+ );
671
745
  },
672
746
 
673
747
  /**
@@ -675,9 +749,7 @@ export async function createJJ(options) {
675
749
  *
676
750
  * For large files, use streaming to avoid loading entire file into memory.
677
751
  *
678
- * @param {Object} args - Arguments
679
- * @param {string} args.path - File path relative to repo root
680
- * @param {string} [args.encoding] - Optional encoding ('utf-8', etc.)
752
+ * @param {Record<string, any>} args - Arguments
681
753
  * @returns {Promise<WritableStream>} Writable stream
682
754
  * @throws {JJError} If not in Node.js environment
683
755
  */
@@ -706,7 +778,10 @@ export async function createJJ(options) {
706
778
  }
707
779
 
708
780
  // Create writable stream
709
- const stream = fs.createWriteStream(fullPath, args.encoding ? { encoding: args.encoding } : {});
781
+ const stream = fs.createWriteStream(
782
+ fullPath,
783
+ args.encoding ? { encoding: args.encoding } : {}
784
+ );
710
785
 
711
786
  // Track file when stream finishes
712
787
  stream.on('finish', async () => {
@@ -748,20 +823,13 @@ export async function createJJ(options) {
748
823
  * - Presence of only `from` and `to` → file operation
749
824
  * - Change IDs are 32-character hex strings; file paths are not
750
825
  *
751
- * @param {Object} args - Arguments
752
- * @param {string} [args.from] - Source path or change ID
753
- * @param {string} [args.to] - Destination path or change ID
754
- * @param {string} [args.changeId] - Change ID to move (for history operations)
755
- * @param {string} [args.newParent] - New parent change ID (for history operations)
756
- * @param {string[]} [args.paths] - Paths to move between changes (for history operations)
826
+ * @param {Record<string, any>} args - Arguments
757
827
  * @returns {Promise<Object>} Returns change object for history operations, file info for file operations
758
828
  */
759
829
  /**
760
830
  * Move a file in the working copy
761
831
  *
762
- * @param {Object} args - Move file arguments
763
- * @param {string} args.from - Source file path
764
- * @param {string} args.to - Destination file path
832
+ * @param {Record<string, any>} args - Move file arguments
765
833
  * @returns {Promise<Object>} Operation result including from, to paths and file stats
766
834
  */
767
835
  async moveFile(args) {
@@ -821,10 +889,14 @@ export async function createJJ(options) {
821
889
  try {
822
890
  await fs.promises.mkdir(toDir, { recursive: true });
823
891
  } catch (error) {
824
- throw new JJError('FILE_SYSTEM_ERROR', `Failed to create destination directory: ${error.message}`, {
825
- path: toDir,
826
- error: error.message,
827
- });
892
+ throw new JJError(
893
+ 'FILE_SYSTEM_ERROR',
894
+ `Failed to create destination directory: ${error.message}`,
895
+ {
896
+ path: toDir,
897
+ error: error.message,
898
+ }
899
+ );
828
900
  }
829
901
 
830
902
  // Move the file
@@ -860,9 +932,7 @@ export async function createJJ(options) {
860
932
  /**
861
933
  * Move (rebase) a change to a new parent
862
934
  *
863
- * @param {Object} args - Move change arguments
864
- * @param {string} args.changeId - Change ID to move (also accepts 'from' or 'source')
865
- * @param {string} args.newParent - New parent change ID (also accepts 'to' or 'destination')
935
+ * @param {Record<string, any>} args - Move change arguments (changeId/from/source, newParent/to/destination)
866
936
  * @returns {Promise<Object>} Updated change object
867
937
  */
868
938
  async moveChange(args) {
@@ -896,10 +966,14 @@ export async function createJJ(options) {
896
966
  }
897
967
 
898
968
  if (!changeIdPattern.test(newParent)) {
899
- throw new JJError('INVALID_CHANGE_ID', `Invalid new parent change ID format: ${newParent}`, {
900
- suggestion: 'Change IDs must be 32-character hex strings',
901
- provided: newParent,
902
- });
969
+ throw new JJError(
970
+ 'INVALID_CHANGE_ID',
971
+ `Invalid new parent change ID format: ${newParent}`,
972
+ {
973
+ suggestion: 'Change IDs must be 32-character hex strings',
974
+ provided: newParent,
975
+ }
976
+ );
903
977
  }
904
978
 
905
979
  const change = await graph.getChange(changeId);
@@ -955,11 +1029,13 @@ export async function createJJ(options) {
955
1029
  /**
956
1030
  * @deprecated Use moveFile() for files or moveChange() for history operations
957
1031
  * Move operation - delegates to moveFile or moveChange based on arguments
1032
+ * @param {Record<string, any>} args
958
1033
  */
959
1034
  async move(args) {
960
1035
  if (!args || typeof args !== 'object') {
961
1036
  throw new JJError('INVALID_ARGUMENT', 'Missing or invalid arguments', {
962
- suggestion: 'Use moveFile({ from, to }) for files or moveChange({ changeId, newParent }) for history',
1037
+ suggestion:
1038
+ 'Use moveFile({ from, to }) for files or moveChange({ changeId, newParent }) for history',
963
1039
  });
964
1040
  }
965
1041
 
@@ -977,7 +1053,7 @@ export async function createJJ(options) {
977
1053
  * Detect if move operation is for history (rebase) or files
978
1054
  *
979
1055
  * @private
980
- * @param {Object} args - Move arguments
1056
+ * @param {Record<string, any>} args - Move arguments
981
1057
  * @returns {boolean} True if history operation, false if file operation
982
1058
  */
983
1059
  _detectHistoryOperation(args) {
@@ -998,11 +1074,15 @@ export async function createJJ(options) {
998
1074
  // Users should use explicit { changeId, newParent } for clarity
999
1075
  if (fromLooksLikeChangeId && toLooksLikeChangeId) {
1000
1076
  // This is ambiguous - throw a helpful error
1001
- throw new JJError('AMBIGUOUS_OPERATION',
1002
- 'Ambiguous move operation: arguments look like change IDs', {
1003
- suggestion: 'Use { changeId, newParent } for history operations or ensure file paths don\'t match change ID pattern',
1004
- args,
1005
- });
1077
+ throw new JJError(
1078
+ 'AMBIGUOUS_OPERATION',
1079
+ 'Ambiguous move operation: arguments look like change IDs',
1080
+ {
1081
+ suggestion:
1082
+ "Use { changeId, newParent } for history operations or ensure file paths don't match change ID pattern",
1083
+ args,
1084
+ }
1085
+ );
1006
1086
  }
1007
1087
 
1008
1088
  return false; // Treat as file operation
@@ -1014,8 +1094,7 @@ export async function createJJ(options) {
1014
1094
  /**
1015
1095
  * Remove a file from the working copy
1016
1096
  *
1017
- * @param {Object} args - Arguments
1018
- * @param {string} args.path - File path to remove
1097
+ * @param {Record<string, any>} args - Arguments
1019
1098
  * @returns {Promise<Object>} Information about the removed file
1020
1099
  */
1021
1100
  async remove(args) {
@@ -1048,16 +1127,23 @@ export async function createJJ(options) {
1048
1127
 
1049
1128
  /**
1050
1129
  * Describe the working copy change
1130
+ * @param {Record<string, any>} [args]
1051
1131
  */
1052
1132
  async describe(args = {}) {
1053
1133
  await graph.load();
1054
1134
  await workingCopy.load();
1055
1135
  await userConfig.load();
1056
1136
 
1137
+ // v1.6: pick up files created/changed on disk out-of-band so they are
1138
+ // captured by this describe (unless a specific revision is targeted).
1139
+ if (!args.revision) {
1140
+ await autoSnapshotWorkingCopy();
1141
+ }
1142
+
1057
1143
  // Support describing specific revision (not just working copy)
1058
1144
  const targetChangeId = args.revision || workingCopy.getCurrentChangeId();
1059
1145
  const change = await graph.getChange(targetChangeId);
1060
- const user = userConfig.getUser();
1146
+ const user = /** @type {any} */ (userConfig.getUser());
1061
1147
 
1062
1148
  if (!change) {
1063
1149
  throw new JJError('CHANGE_NOT_FOUND', `Change ${targetChangeId} not found`, {
@@ -1069,6 +1155,11 @@ export async function createJJ(options) {
1069
1155
  const isWorkingCopy = targetChangeId === workingCopy.getCurrentChangeId();
1070
1156
  const fileSnapshotBefore = isWorkingCopy ? await snapshotFilesystem() : null;
1071
1157
 
1158
+ // Snapshot the change record itself from BEFORE this operation mutates
1159
+ // it in place, so undo() can restore ChangeGraph content (not just
1160
+ // on-disk files/working-copy pointer) — mirrors fileSnapshotBefore above.
1161
+ const changeSnapshotBefore = structuredClone(change);
1162
+
1072
1163
  // Dispatch change:updating event (preventable)
1073
1164
  await dispatchEventAsync(jj, 'change:updating', {
1074
1165
  operation: 'describe',
@@ -1093,6 +1184,7 @@ export async function createJJ(options) {
1093
1184
  if (isWorkingCopy && !args.preserveSnapshot) {
1094
1185
  // Snapshot current file contents for conflict detection
1095
1186
  // This allows us to load file contents during merge/rebase
1187
+ /** @type {Record<string, any>} */
1096
1188
  const fileSnapshot = {};
1097
1189
  const trackedFiles = await workingCopy.listFiles();
1098
1190
 
@@ -1124,7 +1216,11 @@ export async function createJJ(options) {
1124
1216
  throw new JJError(
1125
1217
  'SNAPSHOT_FILE_FAILED',
1126
1218
  `Could not snapshot file ${filePath}: ${error.message}`,
1127
- { filePath, originalError: error.message, suggestion: 'File may be binary, deleted, or inaccessible' }
1219
+ {
1220
+ filePath,
1221
+ originalError: error.message,
1222
+ suggestion: 'File may be binary, deleted, or inaccessible',
1223
+ }
1128
1224
  );
1129
1225
  }
1130
1226
  }
@@ -1144,6 +1240,10 @@ export async function createJJ(options) {
1144
1240
  },
1145
1241
  description: `describe change ${targetChangeId.slice(0, 8)}`,
1146
1242
  parents: [],
1243
+ // Snapshot of the affected ChangeGraph node from BEFORE this
1244
+ // operation ran, so undo() can restore in-place content mutations
1245
+ // (e.g. the description change below) — not just files/pointers.
1246
+ changeSnapshot: { [targetChangeId]: changeSnapshotBefore },
1147
1247
  view: {
1148
1248
  bookmarks: {},
1149
1249
  remoteBookmarks: {},
@@ -1154,12 +1254,17 @@ export async function createJJ(options) {
1154
1254
  });
1155
1255
 
1156
1256
  // Dispatch change:updated event (informational)
1157
- await dispatchEventAsync(jj, 'change:updated', {
1158
- operation: 'describe',
1159
- changeId: targetChangeId,
1160
- change,
1161
- timestamp: new Date().toISOString(),
1162
- }, { cancelable: false });
1257
+ await dispatchEventAsync(
1258
+ jj,
1259
+ 'change:updated',
1260
+ {
1261
+ operation: 'describe',
1262
+ changeId: targetChangeId,
1263
+ change,
1264
+ timestamp: new Date().toISOString(),
1265
+ },
1266
+ { cancelable: false }
1267
+ );
1163
1268
 
1164
1269
  return change;
1165
1270
  },
@@ -1170,18 +1275,7 @@ export async function createJJ(options) {
1170
1275
  * Allows updating author, committer, description, and optionally regenerating the change ID.
1171
1276
  * The file content and snapshot remain unchanged.
1172
1277
  *
1173
- * @param {Object} [args={}] - Arguments
1174
- * @param {string} [args.revision] - Change ID to edit (defaults to working copy @)
1175
- * @param {string} [args.change] - Alias for revision
1176
- * @param {string} [args.description] - Update the change description
1177
- * @param {string} [args.message] - Alias for description
1178
- * @param {Object} [args.author] - Author information to update
1179
- * @param {string} [args.author.name] - Author name
1180
- * @param {string} [args.author.email] - Author email
1181
- * @param {Object} [args.committer] - Committer information to update
1182
- * @param {string} [args.committer.name] - Committer name
1183
- * @param {string} [args.committer.email] - Committer email
1184
- * @param {boolean} [args.resetChangeId=false] - Generate new change ID
1278
+ * @param {Record<string, any>} [args={}] - Arguments
1185
1279
  * @returns {Promise<Object>} Updated change object
1186
1280
  */
1187
1281
  async metaedit(args = {}) {
@@ -1202,7 +1296,7 @@ export async function createJJ(options) {
1202
1296
  // Determine which change to edit
1203
1297
  const changeId = args.revision || workingCopy.getCurrentChangeId();
1204
1298
  const change = await graph.getChange(changeId);
1205
- const user = userConfig.getUser();
1299
+ const user = /** @type {any} */ (userConfig.getUser());
1206
1300
 
1207
1301
  if (!change) {
1208
1302
  throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
@@ -1215,17 +1309,13 @@ export async function createJJ(options) {
1215
1309
  const hasDescription = args.description !== undefined;
1216
1310
 
1217
1311
  if (!hasAuthor && !hasCommitter && !hasResetChangeId && !hasDescription) {
1218
- throw new JJError(
1219
- 'INVALID_ARGUMENT',
1220
- 'No metadata provided to update',
1221
- {
1222
- suggestion: 'Provide author, committer, description, or resetChangeId: true',
1223
- }
1224
- );
1312
+ throw new JJError('INVALID_ARGUMENT', 'No metadata provided to update', {
1313
+ suggestion: 'Provide author, committer, description, or resetChangeId: true',
1314
+ });
1225
1315
  }
1226
1316
 
1227
1317
  // Store old change ID for potential regeneration
1228
- const oldChangeId = change.changeId;
1318
+ const _oldChangeId = change.changeId;
1229
1319
 
1230
1320
  // Update author if provided
1231
1321
  if (args.author) {
@@ -1276,15 +1366,11 @@ export async function createJJ(options) {
1276
1366
  // - Updating all children to point to new change ID
1277
1367
  // - Updating all bookmarks pointing to old change ID
1278
1368
  // - Updating operation log references
1279
- throw new JJError(
1280
- 'UNSUPPORTED_OPERATION',
1281
- 'resetChangeId is not yet implemented',
1282
- {
1283
- feature: 'metaedit resetChangeId',
1284
- reason: 'Requires complex graph manipulation to update all references',
1285
- suggestion: 'Create a new change with desired metadata instead',
1286
- }
1287
- );
1369
+ throw new JJError('UNSUPPORTED_OPERATION', 'resetChangeId is not yet implemented', {
1370
+ feature: 'metaedit resetChangeId',
1371
+ reason: 'Requires complex graph manipulation to update all references',
1372
+ suggestion: 'Create a new change with desired metadata instead',
1373
+ });
1288
1374
  }
1289
1375
 
1290
1376
  // Update the existing change
@@ -1310,12 +1396,7 @@ export async function createJJ(options) {
1310
1396
  /**
1311
1397
  * Create a new change
1312
1398
  *
1313
- * @param {Object} [args={}] - Arguments
1314
- * @param {string} [args.message] - Initial description for the new change
1315
- * @param {string|string[]} [args.parents] - Parent change ID(s), supports merge commits
1316
- * @param {string} [args.from] - Single parent (backward compat)
1317
- * @param {string} [args.insertAfter] - Insert after this change
1318
- * @param {string} [args.insertBefore] - Insert before this change
1399
+ * @param {Record<string, any>} [args={}] - Arguments
1319
1400
  * @returns {Promise<Object>} The new change object including changeId, description, parents, author, and timestamp
1320
1401
  */
1321
1402
  async new(args = {}) {
@@ -1357,7 +1438,7 @@ export async function createJJ(options) {
1357
1438
  }
1358
1439
 
1359
1440
  const newChangeId = generateChangeId();
1360
- const user = userConfig.getUser();
1441
+ const user = /** @type {any} */ (userConfig.getUser());
1361
1442
 
1362
1443
  // Initialize with parent's file snapshot
1363
1444
  let initialSnapshot = {};
@@ -1398,7 +1479,7 @@ export async function createJJ(options) {
1398
1479
  timestamp: new Date().toISOString(),
1399
1480
  });
1400
1481
 
1401
- await graph.addChange(newChange); // Middleware will sync to Git
1482
+ await graph.addChange(newChange); // Middleware will sync to Git
1402
1483
 
1403
1484
  // Handle insertBefore: rebase target to have new change as parent
1404
1485
  if (args.insertBefore) {
@@ -1429,17 +1510,22 @@ export async function createJJ(options) {
1429
1510
  });
1430
1511
 
1431
1512
  // Dispatch change:created event (informational)
1432
- await dispatchEventAsync(jj, 'change:created', {
1433
- operation: 'new',
1434
- changeId: newChangeId,
1435
- parents: parents,
1436
- change: newChange,
1437
- timestamp: new Date().toISOString(),
1438
- }, { cancelable: false });
1513
+ await dispatchEventAsync(
1514
+ jj,
1515
+ 'change:created',
1516
+ {
1517
+ operation: 'new',
1518
+ changeId: newChangeId,
1519
+ parents: parents,
1520
+ change: newChange,
1521
+ timestamp: new Date().toISOString(),
1522
+ },
1523
+ { cancelable: false }
1524
+ );
1439
1525
 
1440
1526
  return newChange;
1441
1527
  },
1442
-
1528
+
1443
1529
  /**
1444
1530
  * Get working copy status
1445
1531
  */
@@ -1448,21 +1534,57 @@ export async function createJJ(options) {
1448
1534
  await workingCopy.load();
1449
1535
  await conflicts.load();
1450
1536
 
1537
+ // Dirty tracked files (computed before reconciling so modifications to
1538
+ // already-tracked files are still reported).
1539
+ const modified = await workingCopy.getModifiedFiles();
1540
+
1541
+ // v1.6: snapshot the working directory so files added/removed on disk
1542
+ // out-of-band show up in status.
1543
+ const snap = await autoSnapshotWorkingCopy();
1544
+
1451
1545
  const currentChangeId = workingCopy.getCurrentChangeId();
1452
1546
  const change = await graph.getChange(currentChangeId);
1453
- const modified = await workingCopy.getModifiedFiles();
1454
1547
  // Only show unresolved conflicts in status
1455
1548
  const activeConflicts = conflicts.listConflicts({ resolved: false });
1456
1549
 
1457
1550
  return {
1458
1551
  workingCopy: change,
1459
1552
  modified,
1460
- added: [],
1461
- removed: [],
1553
+ added: snap ? snap.added : [],
1554
+ removed: snap ? snap.deleted : [],
1462
1555
  conflicts: activeConflicts,
1463
1556
  };
1464
1557
  },
1465
1558
 
1559
+ /**
1560
+ * Explicitly snapshot the working directory (v1.6).
1561
+ *
1562
+ * Walks the working copy on disk and reconciles tracked file state and the
1563
+ * working-copy change's content, picking up files created/modified/deleted
1564
+ * outside of jj.write() (editor, shell, git checkout). This runs
1565
+ * automatically before status/describe/diff/read/file.* unless the repo was
1566
+ * created with `{ autoSnapshot: false }`; call this to trigger it manually.
1567
+ *
1568
+ * @returns {Promise<{added: string[], modified: string[], deleted: string[]}>}
1569
+ */
1570
+ async snapshot() {
1571
+ await graph.load();
1572
+ await workingCopy.load();
1573
+ const result = await workingCopy.snapshot();
1574
+ // Refresh the working-copy change's content snapshot from disk.
1575
+ try {
1576
+ const wcId = workingCopy.getCurrentChangeId();
1577
+ const change = await graph.getChange(wcId);
1578
+ if (change) {
1579
+ change.fileSnapshot = await snapshotFilesystem();
1580
+ await graph.updateChange(change);
1581
+ }
1582
+ } catch {
1583
+ // Best-effort content refresh.
1584
+ }
1585
+ return result;
1586
+ },
1587
+
1466
1588
  /**
1467
1589
  * Get repository statistics and analytics
1468
1590
  *
@@ -1477,9 +1599,10 @@ export async function createJJ(options) {
1477
1599
 
1478
1600
  const all = graph.getAll();
1479
1601
  const ops = await oplog.list();
1480
- const currentUser = userConfig.getUser();
1602
+ const currentUser = /** @type {any} */ (userConfig.getUser());
1481
1603
 
1482
1604
  // Count by author
1605
+ /** @type {Record<string, any>} */
1483
1606
  const authorCounts = {};
1484
1607
  for (const change of all) {
1485
1608
  if (!change.abandoned && change.author) {
@@ -1499,26 +1622,30 @@ export async function createJJ(options) {
1499
1622
  }
1500
1623
 
1501
1624
  // Count merge commits
1502
- const mergeCommits = all.filter(c => c.parents && c.parents.length > 1).length;
1625
+ const mergeCommits = all.filter(
1626
+ (/** @type {any} */ c) => c.parents && c.parents.length > 1
1627
+ ).length;
1503
1628
 
1504
1629
  // Count empty commits
1505
- const emptyCommits = all.filter(c =>
1506
- c.tree === '0000000000000000000000000000000000000000'
1630
+ const emptyCommits = all.filter(
1631
+ (/** @type {any} */ c) => c.tree === '0000000000000000000000000000000000000000'
1507
1632
  ).length;
1508
1633
 
1509
1634
  // Count abandoned
1510
- const abandonedCount = all.filter(c => c.abandoned).length;
1635
+ const abandonedCount = all.filter((/** @type {any} */ c) => c.abandoned).length;
1511
1636
 
1512
1637
  // My commits
1513
- const myCommits = all.filter(c =>
1514
- c.author && !c.abandoned &&
1515
- (c.author.email === currentUser.email || c.author.name === currentUser.name)
1638
+ const myCommits = all.filter(
1639
+ (/** @type {any} */ c) =>
1640
+ c.author &&
1641
+ !c.abandoned &&
1642
+ (c.author.email === currentUser.email || c.author.name === currentUser.name)
1516
1643
  ).length;
1517
1644
 
1518
1645
  // Get bookmark statistics
1519
1646
  const allBookmarks = await bookmarks.list();
1520
- const localBookmarks = allBookmarks.filter(b => !b.remote);
1521
- const remoteBookmarks = allBookmarks.filter(b => b.remote);
1647
+ const localBookmarks = allBookmarks.filter((b) => !b.remote);
1648
+ const remoteBookmarks = allBookmarks.filter((b) => b.remote);
1522
1649
 
1523
1650
  return {
1524
1651
  changes: {
@@ -1537,7 +1664,7 @@ export async function createJJ(options) {
1537
1664
  files: {
1538
1665
  total: allFiles.size,
1539
1666
  list: Array.from(allFiles).sort(),
1540
- byExtension: {}, // Add file extension breakdown
1667
+ byExtension: {}, // Add file extension breakdown
1541
1668
  },
1542
1669
  bookmarks: {
1543
1670
  total: allBookmarks.length,
@@ -1558,11 +1685,8 @@ export async function createJJ(options) {
1558
1685
  /**
1559
1686
  * View change history
1560
1687
  *
1561
- * @param {Object} args - Arguments
1562
- * @param {string} [args.revset='all()'] - Revset expression to filter changes
1563
- * @param {number} [args.limit] - Maximum number of changes to return
1564
- * @param {boolean} [args.count=false] - If true, return count instead of changes (v0.36.0)
1565
- * @returns {Promise<Array|number>} Array of changes or count if args.count is true
1688
+ * @param {Record<string, any>} args - Arguments
1689
+ * @returns {Promise<Array<any>|number>} Array of changes or count if args.count is true
1566
1690
  */
1567
1691
  async log(args = {}) {
1568
1692
  await graph.load();
@@ -1591,7 +1715,7 @@ export async function createJJ(options) {
1591
1715
  const aIsParentOfB = b.parents && b.parents.includes(a.changeId);
1592
1716
  const bIsParentOfA = a.parents && a.parents.includes(b.changeId);
1593
1717
 
1594
- if (aIsParentOfB) return 1; // a is parent of b, so b comes first
1718
+ if (aIsParentOfB) return 1; // a is parent of b, so b comes first
1595
1719
  if (bIsParentOfA) return -1; // b is parent of a, so a comes first
1596
1720
 
1597
1721
  // Secondary: Sort by timestamp descending (newest first)
@@ -1611,14 +1735,13 @@ export async function createJJ(options) {
1611
1735
  /**
1612
1736
  * Show detailed information about a specific change
1613
1737
  *
1614
- * @param {Object} args - Arguments
1615
- * @param {string} args.change - Change ID or revset to show
1738
+ * @param {Record<string, any>} args - Arguments
1616
1739
  * @returns {Promise<Object>} Detailed change information
1617
1740
  */
1618
1741
  async show(args) {
1619
1742
  if (!args || !args.change) {
1620
1743
  throw new JJError('INVALID_ARGUMENT', 'Missing change argument', {
1621
- suggestion: 'Provide { change: changeId }'
1744
+ suggestion: 'Provide { change: changeId }',
1622
1745
  });
1623
1746
  }
1624
1747
 
@@ -1633,11 +1756,28 @@ export async function createJJ(options) {
1633
1756
  // Try to resolve as revset
1634
1757
  try {
1635
1758
  const changes = await revset.evaluate(args.change);
1759
+ if (changes.length > 1) {
1760
+ // An id-prefix (or other) revset that matches more than one
1761
+ // change must not silently pick a "winner" — see issue #13.
1762
+ throw new JJError(
1763
+ 'AMBIGUOUS_ID',
1764
+ `"${args.change}" is ambiguous: it resolves to ${changes.length} changes (${changes.join(', ')})`,
1765
+ {
1766
+ expression: args.change,
1767
+ matches: changes,
1768
+ suggestion: 'Use a longer prefix or an exact change ID to disambiguate',
1769
+ }
1770
+ );
1771
+ }
1636
1772
  if (changes.length > 0) {
1637
1773
  changeId = changes[0];
1638
1774
  }
1639
- } catch {
1640
- // Use as-is
1775
+ } catch (error) {
1776
+ if (error instanceof JJError && error.code === 'AMBIGUOUS_ID') {
1777
+ throw error;
1778
+ }
1779
+ // Not a valid revset expression - use args.change as-is (e.g. a
1780
+ // literal change ID that revset parsing rejected).
1641
1781
  }
1642
1782
  }
1643
1783
 
@@ -1654,23 +1794,23 @@ export async function createJJ(options) {
1654
1794
  // Find bookmarks pointing to this change
1655
1795
  const allBookmarks = await bookmarks.list();
1656
1796
  const changeBookmarks = allBookmarks
1657
- .filter(b => b.target === change.changeId)
1658
- .map(b => b.name);
1797
+ .filter((b) => b.changeId === change.changeId)
1798
+ .map((b) => b.name);
1659
1799
 
1660
1800
  // Find operations that modified this change
1661
1801
  const ops = await oplog.list();
1662
- const relatedOps = ops.filter(op =>
1663
- op.view && (
1664
- op.view.workingCopy === change.changeId ||
1665
- (op.view.heads && op.view.heads.includes(change.changeId))
1666
- )
1802
+ const relatedOps = ops.filter(
1803
+ (op) =>
1804
+ op.view &&
1805
+ (op.view.workingCopy === change.changeId ||
1806
+ (op.view.heads && op.view.heads.includes(change.changeId)))
1667
1807
  );
1668
1808
 
1669
1809
  return {
1670
1810
  ...change,
1671
1811
  children,
1672
1812
  bookmarks: changeBookmarks,
1673
- operations: relatedOps.map(op => ({
1813
+ operations: relatedOps.map((op) => ({
1674
1814
  id: op.id,
1675
1815
  timestamp: op.timestamp,
1676
1816
  description: op.description,
@@ -1681,8 +1821,7 @@ export async function createJJ(options) {
1681
1821
  /**
1682
1822
  * Amend the working copy change
1683
1823
  *
1684
- * @param {Object} args - Arguments
1685
- * @param {string} [args.message] - New description message
1824
+ * @param {Record<string, any>} args - Arguments
1686
1825
  * @returns {Promise<Object>} Updated change
1687
1826
  */
1688
1827
  async amend(args = {}) {
@@ -1693,10 +1832,7 @@ export async function createJJ(options) {
1693
1832
  /**
1694
1833
  * Convenience: describe current change and create new one (jj commit)
1695
1834
  *
1696
- * @param {Object} [args={}] - Arguments
1697
- * @param {string} [args.message] - Description for current change
1698
- * @param {Object} [args.author] - Author for current change
1699
- * @param {Object} [args.metadata] - Metadata to attach to the change
1835
+ * @param {Record<string, any>} [args={}] - Arguments
1700
1836
  * @returns {Promise<Object>} The newly created change
1701
1837
  */
1702
1838
  async commit(args = {}) {
@@ -1704,19 +1840,18 @@ export async function createJJ(options) {
1704
1840
  message: args.message,
1705
1841
  author: args.author,
1706
1842
  metadata: args.metadata,
1707
- preserveSnapshot: args.preserveSnapshot
1843
+ preserveSnapshot: args.preserveSnapshot,
1708
1844
  });
1709
1845
  return await this.new({
1710
1846
  message: args.nextMessage,
1711
- preserveSnapshot: args.preserveSnapshot
1847
+ preserveSnapshot: args.preserveSnapshot,
1712
1848
  });
1713
1849
  },
1714
1850
 
1715
1851
  /**
1716
1852
  * Edit a specific change (make it the working copy)
1717
1853
  *
1718
- * @param {Object} args - Arguments
1719
- * @param {string} args.changeId - Change ID to edit
1854
+ * @param {Record<string, any>} args - Arguments
1720
1855
  * @returns {Promise<Object>} Information about the edited change including changeId, description, and file count
1721
1856
  */
1722
1857
  async edit(args) {
@@ -1736,7 +1871,7 @@ export async function createJJ(options) {
1736
1871
  await userConfig.load();
1737
1872
 
1738
1873
  const change = await graph.getChange(args.changeId);
1739
- const user = userConfig.getUser();
1874
+ const user = /** @type {any} */ (userConfig.getUser());
1740
1875
  if (!change) {
1741
1876
  throw new JJError('CHANGE_NOT_FOUND', `Change ${args.changeId} not found`, {
1742
1877
  changeId: args.changeId,
@@ -1841,13 +1976,18 @@ export async function createJJ(options) {
1841
1976
  });
1842
1977
 
1843
1978
  // Dispatch workingcopy:switched event (informational)
1844
- await dispatchEventAsync(jj, 'workingcopy:switched', {
1845
- operation: 'edit',
1846
- fromChangeId: previousChangeId,
1847
- toChangeId: args.changeId,
1848
- change,
1849
- timestamp: new Date().toISOString(),
1850
- }, { cancelable: false });
1979
+ await dispatchEventAsync(
1980
+ jj,
1981
+ 'workingcopy:switched',
1982
+ {
1983
+ operation: 'edit',
1984
+ fromChangeId: previousChangeId,
1985
+ toChangeId: args.changeId,
1986
+ change,
1987
+ timestamp: new Date().toISOString(),
1988
+ },
1989
+ { cancelable: false }
1990
+ );
1851
1991
 
1852
1992
  // Return information about the edited change
1853
1993
  return {
@@ -1858,7 +1998,7 @@ export async function createJJ(options) {
1858
1998
  timestamp: change.timestamp,
1859
1999
  };
1860
2000
  },
1861
-
2001
+
1862
2002
  /**
1863
2003
  * Undo last operation
1864
2004
  *
@@ -1875,11 +2015,26 @@ export async function createJJ(options) {
1875
2015
  // Get the current operation (the one being undone) to restore its pre-state
1876
2016
  const currentOp = ops[ops.length - 1];
1877
2017
 
1878
- const previousView = await oplog.undo();
2018
+ const previousView = /** @type {any} */ (await oplog.undo());
1879
2019
 
1880
2020
  // Restore state from previous view
1881
2021
  await workingCopy.setCurrentChange(previousView.workingCopy);
1882
2022
 
2023
+ // Restore ChangeGraph content mutated in place by the operation being
2024
+ // undone (e.g. describe() editing change.description) — see issue #12.
2025
+ // currentOp.changeSnapshot holds the affected change record(s) as they
2026
+ // were BEFORE currentOp ran, captured the same way fileSnapshot is.
2027
+ await graph.load();
2028
+ if (currentOp && currentOp.changeSnapshot) {
2029
+ for (const snapshot of Object.values(
2030
+ /** @type {Record<string, any>} */ (currentOp.changeSnapshot)
2031
+ )) {
2032
+ if (snapshot && (await graph.getChange(snapshot.changeId))) {
2033
+ await graph.updateChange(snapshot);
2034
+ }
2035
+ }
2036
+ }
2037
+
1883
2038
  // Restore filesystem from previous operation's snapshot (taken BEFORE that operation)
1884
2039
  // This is how JJ snapshots the working copy before every command
1885
2040
  if (previousView.fileSnapshot) {
@@ -1913,18 +2068,25 @@ export async function createJJ(options) {
1913
2068
  if (currentOp && currentOp.conflictsSnapshot) {
1914
2069
  // Restore conflicts from the operation we're undoing (its pre-state)
1915
2070
  conflicts.conflicts = new Map(Object.entries(currentOp.conflictsSnapshot.conflicts || {}));
1916
- conflicts.fileConflicts = new Map(Object.entries(currentOp.conflictsSnapshot.fileConflicts || {}));
2071
+ conflicts.fileConflicts = new Map(
2072
+ Object.entries(currentOp.conflictsSnapshot.fileConflicts || {})
2073
+ );
1917
2074
  await conflicts.save();
1918
2075
  } else {
1919
2076
  // No conflicts snapshot - clear conflicts
1920
2077
  await conflicts.clear();
1921
2078
  }
1922
2079
 
1923
- // Record undo operation
2080
+ // Record undo operation. Tag it with the view it undid so that redo()
2081
+ // can re-apply it (jj's progressive undo/redo, added in jj v0.33).
1924
2082
  await oplog.recordOperation({
1925
2083
  timestamp: new Date().toISOString(),
1926
2084
  user: await getUserOplogInfo(),
1927
2085
  description: 'undo operation',
2086
+ eventType: 'undo',
2087
+ undoneOpId: currentOp.id,
2088
+ undoneView: currentOp.view,
2089
+ undoneConflictsSnapshot: currentOp.conflictsSnapshot || null,
1928
2090
  parents: [],
1929
2091
  view: previousView,
1930
2092
  });
@@ -1944,13 +2106,110 @@ export async function createJJ(options) {
1944
2106
  };
1945
2107
  },
1946
2108
 
2109
+ /**
2110
+ * Redo an operation previously undone with undo() (v1.5)
2111
+ *
2112
+ * Mirrors `jj redo` (jj v0.33): progressively re-applies the operations that
2113
+ * `undo()` reverted, most-recent-undo first. Each redo consumes exactly one
2114
+ * pending undo.
2115
+ *
2116
+ * @returns {Promise<Object>} Information about the redone operation
2117
+ */
2118
+ async redo() {
2119
+ await oplog.load();
2120
+ const ops = await oplog.list();
2121
+
2122
+ // Operations already consumed by a prior redo.
2123
+ const alreadyRedone = new Set();
2124
+ for (const op of ops) {
2125
+ if (op.eventType === 'redo' && op.redoOf) {
2126
+ alreadyRedone.add(op.redoOf);
2127
+ }
2128
+ }
2129
+
2130
+ // Find the most recent undo operation that has not yet been redone.
2131
+ let undoOp = null;
2132
+ for (let i = ops.length - 1; i >= 0; i--) {
2133
+ if (ops[i].eventType === 'undo' && !alreadyRedone.has(ops[i].id)) {
2134
+ undoOp = ops[i];
2135
+ break;
2136
+ }
2137
+ }
2138
+
2139
+ if (!undoOp) {
2140
+ throw new JJError('NOTHING_TO_REDO', 'No undone operations to redo', {
2141
+ suggestion: 'redo() only reverses a preceding undo()',
2142
+ });
2143
+ }
2144
+
2145
+ const redoneView = undoOp.undoneView || {};
2146
+
2147
+ // Restore the working-copy pointer.
2148
+ if (redoneView.workingCopy) {
2149
+ await workingCopy.setCurrentChange(redoneView.workingCopy);
2150
+ }
2151
+
2152
+ // Restore the working-copy files captured in the undone view.
2153
+ if (redoneView.fileSnapshot) {
2154
+ for (const [filePath, content] of Object.entries(redoneView.fileSnapshot)) {
2155
+ try {
2156
+ const fullPath = path.join(dir, filePath);
2157
+ const pathParts = filePath.split('/');
2158
+ if (pathParts.length > 1) {
2159
+ const fullDirPath = path.join(dir, pathParts.slice(0, -1).join('/'));
2160
+ await fs.promises.mkdir(fullDirPath, { recursive: true });
2161
+ }
2162
+ await fs.promises.writeFile(fullPath, content, 'utf8');
2163
+ } catch (error) {
2164
+ throw new JJError(
2165
+ 'REDO_FILE_RESTORE_FAILED',
2166
+ `Failed to restore file ${filePath} during redo: ${error.message}`,
2167
+ { filePath, originalError: error.message }
2168
+ );
2169
+ }
2170
+ }
2171
+ }
2172
+
2173
+ // Restore conflicts to the state that existed after the redone operation.
2174
+ await conflicts.load();
2175
+ if (undoOp.undoneConflictsSnapshot) {
2176
+ conflicts.conflicts = new Map(
2177
+ Object.entries(undoOp.undoneConflictsSnapshot.conflicts || {})
2178
+ );
2179
+ conflicts.fileConflicts = new Map(
2180
+ Object.entries(undoOp.undoneConflictsSnapshot.fileConflicts || {})
2181
+ );
2182
+ await conflicts.save();
2183
+ }
2184
+
2185
+ await oplog.recordOperation({
2186
+ timestamp: new Date().toISOString(),
2187
+ user: await getUserOplogInfo(),
2188
+ description: 'redo operation',
2189
+ eventType: 'redo',
2190
+ redoOf: undoOp.id,
2191
+ parents: [],
2192
+ view: redoneView,
2193
+ });
2194
+
2195
+ return {
2196
+ redoneOperation: {
2197
+ description: undoOp.description,
2198
+ undoneOpId: undoOp.undoneOpId,
2199
+ },
2200
+ restoredState: {
2201
+ workingCopy: redoneView.workingCopy,
2202
+ heads: redoneView.heads,
2203
+ fileCount: redoneView.fileSnapshot ? Object.keys(redoneView.fileSnapshot).length : 0,
2204
+ },
2205
+ };
2206
+ },
2207
+
1947
2208
  /**
1948
2209
  * View operation log (operation history)
1949
2210
  *
1950
- * @param {Object} [opts] - Options
1951
- * @param {number} [opts.limit] - Maximum number of operations to return
1952
- * @param {string} [opts.change] - Show operations for specific change
1953
- * @returns {Promise<Array>} Array of operations
2211
+ * @param {Record<string, any>} [opts] - Options
2212
+ * @returns {Promise<Array<any>>} Array of operations
1954
2213
  */
1955
2214
  async obslog(opts = {}) {
1956
2215
  await oplog.load();
@@ -1961,11 +2220,11 @@ export async function createJJ(options) {
1961
2220
 
1962
2221
  // Filter by change if specified
1963
2222
  if (changeId) {
1964
- operations = operations.filter(op =>
1965
- op.view && (
1966
- op.view.workingCopy === changeId ||
1967
- (op.view.heads && op.view.heads.includes(changeId))
1968
- )
2223
+ operations = operations.filter(
2224
+ (op) =>
2225
+ op.view &&
2226
+ (op.view.workingCopy === changeId ||
2227
+ (op.view.heads && op.view.heads.includes(changeId)))
1969
2228
  );
1970
2229
  }
1971
2230
 
@@ -1975,10 +2234,10 @@ export async function createJJ(options) {
1975
2234
  }
1976
2235
 
1977
2236
  // Transform to evolution events with operation field
1978
- const events = operations.map(op => ({
2237
+ const events = operations.map((op) => ({
1979
2238
  eventType: op.eventType || 'modify',
1980
2239
  description: op.description,
1981
- operation: op.id, // Add operation ID
2240
+ operation: op.id, // Add operation ID
1982
2241
  timestamp: op.timestamp,
1983
2242
  user: op.user,
1984
2243
  }));
@@ -1994,9 +2253,8 @@ export async function createJJ(options) {
1994
2253
  /**
1995
2254
  * List all operations
1996
2255
  *
1997
- * @param {Object} [opts] - Options
1998
- * @param {number} [opts.limit] - Maximum number to return
1999
- * @returns {Promise<Array>} Array of operations
2256
+ * @param {Record<string, any>} [opts] - Options
2257
+ * @returns {Promise<Array<any>>} Array of operations
2000
2258
  */
2001
2259
  async list(opts = {}) {
2002
2260
  await oplog.load();
@@ -2012,20 +2270,19 @@ export async function createJJ(options) {
2012
2270
  /**
2013
2271
  * View repository at a specific operation (time travel)
2014
2272
  *
2015
- * @param {Object} args - Arguments
2016
- * @param {string} args.operation - Operation ID to view at
2273
+ * @param {Record<string, any>} args - Arguments
2017
2274
  * @returns {Promise<Object>} Read-only repository view at that operation
2018
2275
  */
2019
2276
  async at(args) {
2020
2277
  if (!args || !args.operation) {
2021
2278
  throw new JJError('INVALID_ARGUMENT', 'Missing operation ID', {
2022
- suggestion: 'Provide { operation: operationId }'
2279
+ suggestion: 'Provide { operation: operationId }',
2023
2280
  });
2024
2281
  }
2025
2282
 
2026
2283
  await oplog.load();
2027
2284
  const ops = await oplog.list();
2028
- const targetOp = ops.find(op => op.id === args.operation);
2285
+ const targetOp = ops.find((op) => op.id === args.operation);
2029
2286
 
2030
2287
  if (!targetOp) {
2031
2288
  throw new JJError('OPERATION_NOT_FOUND', `Operation ${args.operation} not found`);
@@ -2033,17 +2290,17 @@ export async function createJJ(options) {
2033
2290
 
2034
2291
  // Create a read-only view at this operation
2035
2292
  return {
2036
- async log(logArgs = {}) {
2293
+ async log(_logArgs = {}) {
2037
2294
  // Get changes that existed at this operation
2038
2295
  await graph.load();
2039
2296
  const allChanges = await graph.getAllChanges();
2040
2297
 
2041
2298
  // Filter to changes that existed before or at this operation
2042
2299
  const opIndex = ops.indexOf(targetOp);
2043
- const validChanges = allChanges.filter(change => {
2300
+ const validChanges = allChanges.filter((/** @type {any} */ change) => {
2044
2301
  // Check if this change was created before this operation
2045
- const changeOps = ops.filter(op =>
2046
- op.view && op.view.heads && op.view.heads.includes(change.changeId)
2302
+ const changeOps = ops.filter(
2303
+ (op) => op.view && op.view.heads && op.view.heads.includes(change.changeId)
2047
2304
  );
2048
2305
  return changeOps.length > 0 && ops.indexOf(changeOps[0]) <= opIndex;
2049
2306
  });
@@ -2067,8 +2324,7 @@ export async function createJJ(options) {
2067
2324
  /**
2068
2325
  * Show changes in a specific operation (matches `jj operation show`)
2069
2326
  *
2070
- * @param {Object} args - Arguments
2071
- * @param {string} args.operation - Operation ID
2327
+ * @param {Record<string, any>} args - Arguments
2072
2328
  * @returns {Promise<Object>} Operation details with changes made
2073
2329
  */
2074
2330
  async show(args) {
@@ -2080,7 +2336,7 @@ export async function createJJ(options) {
2080
2336
 
2081
2337
  await oplog.load();
2082
2338
  const ops = await oplog.list();
2083
- const op = ops.find(o => o.id === args.operation);
2339
+ const op = ops.find((o) => o.id === args.operation);
2084
2340
 
2085
2341
  if (!op) {
2086
2342
  throw new JJError('OPERATION_NOT_FOUND', `Operation ${args.operation} not found`);
@@ -2112,9 +2368,7 @@ export async function createJJ(options) {
2112
2368
  /**
2113
2369
  * Compare repository state between two operations (matches `jj operation diff`)
2114
2370
  *
2115
- * @param {Object} args - Arguments
2116
- * @param {string} args.from - Source operation ID
2117
- * @param {string} args.to - Target operation ID
2371
+ * @param {Record<string, any>} args - Arguments
2118
2372
  * @returns {Promise<Object>} Differences between operations
2119
2373
  */
2120
2374
  async diff(args) {
@@ -2126,8 +2380,8 @@ export async function createJJ(options) {
2126
2380
 
2127
2381
  await oplog.load();
2128
2382
  const ops = await oplog.list();
2129
- const fromOp = ops.find(o => o.id === args.from);
2130
- const toOp = ops.find(o => o.id === args.to);
2383
+ const fromOp = ops.find((o) => o.id === args.from);
2384
+ const toOp = ops.find((o) => o.id === args.to);
2131
2385
 
2132
2386
  if (!fromOp) {
2133
2387
  throw new JJError('OPERATION_NOT_FOUND', `Operation ${args.from} not found`);
@@ -2140,13 +2394,14 @@ export async function createJJ(options) {
2140
2394
  const fromHeads = new Set(fromOp.view?.heads || []);
2141
2395
  const toHeads = new Set(toOp.view?.heads || []);
2142
2396
 
2143
- const addedHeads = [...toHeads].filter(h => !fromHeads.has(h));
2144
- const removedHeads = [...fromHeads].filter(h => !toHeads.has(h));
2397
+ const addedHeads = [...toHeads].filter((h) => !fromHeads.has(h));
2398
+ const removedHeads = [...fromHeads].filter((h) => !toHeads.has(h));
2145
2399
 
2146
2400
  // Compare bookmarks
2147
2401
  const fromBookmarks = fromOp.view?.bookmarks || {};
2148
2402
  const toBookmarks = toOp.view?.bookmarks || {};
2149
2403
 
2404
+ /** @type {Record<string, any>} */
2150
2405
  const bookmarkChanges = {};
2151
2406
  for (const name of new Set([...Object.keys(fromBookmarks), ...Object.keys(toBookmarks)])) {
2152
2407
  if (fromBookmarks[name] !== toBookmarks[name]) {
@@ -2172,8 +2427,7 @@ export async function createJJ(options) {
2172
2427
  /**
2173
2428
  * Restore repository to a specific operation (matches `jj operation restore`)
2174
2429
  *
2175
- * @param {Object} args - Arguments
2176
- * @param {string} args.operation - Operation ID to restore to
2430
+ * @param {Record<string, any>} args - Arguments
2177
2431
  * @returns {Promise<Object>} Restore result
2178
2432
  */
2179
2433
  async restore(args) {
@@ -2185,7 +2439,7 @@ export async function createJJ(options) {
2185
2439
 
2186
2440
  await oplog.load();
2187
2441
  const ops = await oplog.list();
2188
- const targetOp = ops.find(o => o.id === args.operation);
2442
+ const targetOp = ops.find((o) => o.id === args.operation);
2189
2443
 
2190
2444
  if (!targetOp) {
2191
2445
  throw new JJError('OPERATION_NOT_FOUND', `Operation ${args.operation} not found`);
@@ -2234,8 +2488,7 @@ export async function createJJ(options) {
2234
2488
  * This creates a new operation that undoes the effects of the specified operation,
2235
2489
  * without affecting any operations that came after it.
2236
2490
  *
2237
- * @param {Object} args - Arguments
2238
- * @param {string} args.operation - Operation ID to revert
2491
+ * @param {Record<string, any>} args - Arguments
2239
2492
  * @returns {Promise<Object>} Revert result
2240
2493
  */
2241
2494
  async revert(args) {
@@ -2247,7 +2500,7 @@ export async function createJJ(options) {
2247
2500
 
2248
2501
  await oplog.load();
2249
2502
  const ops = await oplog.list();
2250
- const targetOp = ops.find(o => o.id === args.operation);
2503
+ const targetOp = ops.find((o) => o.id === args.operation);
2251
2504
 
2252
2505
  if (!targetOp) {
2253
2506
  throw new JJError('OPERATION_NOT_FOUND', `Operation ${args.operation} not found`);
@@ -2264,10 +2517,11 @@ export async function createJJ(options) {
2264
2517
  const previousOp = ops[targetIndex - 1];
2265
2518
 
2266
2519
  // Compute the inverse changes
2267
- const inversChanges = {
2520
+ const inversChanges = /** @type {Record<string, any>} */ ({
2268
2521
  bookmarks: {},
2522
+ tags: {},
2269
2523
  heads: [],
2270
- };
2524
+ });
2271
2525
 
2272
2526
  // Revert bookmark changes
2273
2527
  const prevBookmarks = previousOp.view?.bookmarks || {};
@@ -2280,8 +2534,9 @@ export async function createJJ(options) {
2280
2534
  await bookmarks.delete(name);
2281
2535
  inversChanges.bookmarks[name] = { action: 'deleted' };
2282
2536
  } else if (prevBookmarks[name] !== targetBookmarks[name]) {
2283
- // Bookmark was moved - move it back
2284
- await bookmarks.set(name, prevBookmarks[name]);
2537
+ // Bookmark was moved - move it back (the bookmark still exists, so
2538
+ // use move() rather than set(), which is create-only).
2539
+ await bookmarks.move(name, prevBookmarks[name]);
2285
2540
  inversChanges.bookmarks[name] = {
2286
2541
  action: 'moved',
2287
2542
  from: targetBookmarks[name],
@@ -2304,6 +2559,46 @@ export async function createJJ(options) {
2304
2559
 
2305
2560
  await bookmarks.save();
2306
2561
 
2562
+ // Revert tag changes (mirrors the bookmark diff above — issue #12).
2563
+ // Unlike bookmarks, TagStore has no "move" primitive (tags are meant
2564
+ // to be immutable once created), so a moved tag is reverted via
2565
+ // delete+recreate, matching how tag.set() itself updates a tag.
2566
+ const prevTags = previousOp.view?.tags || {};
2567
+ const targetTags = targetOp.view?.tags || {};
2568
+
2569
+ for (const name of Object.keys(targetTags)) {
2570
+ if (!prevTags[name]) {
2571
+ // Tag was added - delete it
2572
+ if (await tags.exists(name)) {
2573
+ await tags.delete(name);
2574
+ }
2575
+ inversChanges.tags[name] = { action: 'deleted' };
2576
+ } else if (prevTags[name] !== targetTags[name]) {
2577
+ // Tag was moved - restore old target
2578
+ if (await tags.exists(name)) {
2579
+ await tags.delete(name);
2580
+ }
2581
+ await tags.create(name, prevTags[name]);
2582
+ inversChanges.tags[name] = {
2583
+ action: 'moved',
2584
+ from: targetTags[name],
2585
+ to: prevTags[name],
2586
+ };
2587
+ }
2588
+ }
2589
+
2590
+ // Check for deleted tags (existed before, don't exist after)
2591
+ for (const name of Object.keys(prevTags)) {
2592
+ if (!targetTags[name] && !(await tags.exists(name))) {
2593
+ // Tag was deleted - restore it
2594
+ await tags.create(name, prevTags[name]);
2595
+ inversChanges.tags[name] = {
2596
+ action: 'restored',
2597
+ to: prevTags[name],
2598
+ };
2599
+ }
2600
+ }
2601
+
2307
2602
  // Revert working copy change if it changed
2308
2603
  if (previousOp.view?.workingCopy !== targetOp.view?.workingCopy) {
2309
2604
  await workingCopy.load();
@@ -2318,6 +2613,7 @@ export async function createJJ(options) {
2318
2613
  parents: [],
2319
2614
  view: {
2320
2615
  bookmarks: prevBookmarks,
2616
+ tags: prevTags,
2321
2617
  remoteBookmarks: previousOp.view?.remoteBookmarks || {},
2322
2618
  heads: previousOp.view?.heads || [],
2323
2619
  workingCopy: previousOp.view?.workingCopy,
@@ -2341,8 +2637,7 @@ export async function createJJ(options) {
2341
2637
  * WARNING: This is a destructive operation that cannot be undone. Use with caution.
2342
2638
  * In most cases, you should use undo(), restore(), or revert() instead.
2343
2639
  *
2344
- * @param {Object} args - Arguments
2345
- * @param {string} args.operation - Operation ID to abandon
2640
+ * @param {Record<string, any>} args - Arguments
2346
2641
  * @returns {Promise<Object>} Abandon result with relinked children
2347
2642
  */
2348
2643
  async abandon(args) {
@@ -2355,7 +2650,7 @@ export async function createJJ(options) {
2355
2650
  await oplog.load();
2356
2651
 
2357
2652
  // Call the OperationLog abandon method
2358
- const result = await oplog.abandon(args.operation);
2653
+ const result = /** @type {any} */ (await oplog.abandon(args.operation));
2359
2654
 
2360
2655
  // Record a new operation about the abandonment
2361
2656
  // Note: This creates a meta-record that an operation was abandoned
@@ -2372,7 +2667,7 @@ export async function createJJ(options) {
2372
2667
  },
2373
2668
  metadata: {
2374
2669
  abandonedOperation: args.operation,
2375
- relinkedChildren: result.relinkedChildren.map(c => c.operationId),
2670
+ relinkedChildren: result.relinkedChildren.map((/** @type {any} */ c) => c.operationId),
2376
2671
  },
2377
2672
  });
2378
2673
 
@@ -2388,14 +2683,11 @@ export async function createJJ(options) {
2388
2683
  // ========================================
2389
2684
  // v0.2 FEATURES: History Editing
2390
2685
  // ========================================
2391
-
2686
+
2392
2687
  /**
2393
2688
  * Squash source change into destination change
2394
2689
  *
2395
- * @param {Object} args - Arguments
2396
- * @param {string} [args.source] - Source change ID to squash (defaults to @ - working copy)
2397
- * @param {string} [args.dest] - Destination change ID (if source is @, defaults to parent)
2398
- * @param {string} [args.into] - Alias for dest (matches JJ CLI)
2690
+ * @param {Record<string, any>} args - Arguments
2399
2691
  */
2400
2692
  async squash(args = {}) {
2401
2693
  // Check for interactive mode (not supported)
@@ -2426,7 +2718,11 @@ export async function createJJ(options) {
2426
2718
  let destChangeId = dest;
2427
2719
  if (!destChangeId && source === workingCopy.getCurrentChangeId()) {
2428
2720
  const workingCopyChange = await graph.getChange(source);
2429
- if (workingCopyChange && workingCopyChange.parents && workingCopyChange.parents.length > 0) {
2721
+ if (
2722
+ workingCopyChange &&
2723
+ workingCopyChange.parents &&
2724
+ workingCopyChange.parents.length > 0
2725
+ ) {
2430
2726
  destChangeId = workingCopyChange.parents[0];
2431
2727
  } else {
2432
2728
  throw new JJError('INVALID_ARGUMENTS', 'Cannot squash working copy: no parent found');
@@ -2473,7 +2769,7 @@ export async function createJJ(options) {
2473
2769
 
2474
2770
  // If squashing the working copy, create a new empty working copy on top of dest
2475
2771
  if (isSquashingWorkingCopy) {
2476
- const user = userConfig.getUser();
2772
+ const user = /** @type {any} */ (userConfig.getUser());
2477
2773
  const newChangeId = generateChangeId();
2478
2774
  const newChange = {
2479
2775
  changeId: newChangeId,
@@ -2495,7 +2791,7 @@ export async function createJJ(options) {
2495
2791
  fileSnapshot: {},
2496
2792
  };
2497
2793
 
2498
- await graph.addChange(newChange); // Middleware will sync to Git
2794
+ await graph.addChange(newChange); // Middleware will sync to Git
2499
2795
  await workingCopy.setCurrentChange(newChangeId);
2500
2796
  newWorkingCopyId = newChangeId;
2501
2797
  }
@@ -2515,27 +2811,29 @@ export async function createJJ(options) {
2515
2811
  });
2516
2812
 
2517
2813
  // Dispatch change:squashed event (informational)
2518
- await dispatchEventAsync(jj, 'change:squashed', {
2519
- operation: 'squash',
2520
- sourceChangeId: source,
2521
- destChangeId: destChangeId,
2522
- sourceChange,
2523
- destChange,
2524
- timestamp: new Date().toISOString(),
2525
- }, { cancelable: false });
2814
+ await dispatchEventAsync(
2815
+ jj,
2816
+ 'change:squashed',
2817
+ {
2818
+ operation: 'squash',
2819
+ sourceChangeId: source,
2820
+ destChangeId: destChangeId,
2821
+ sourceChange,
2822
+ destChange,
2823
+ timestamp: new Date().toISOString(),
2824
+ },
2825
+ { cancelable: false }
2826
+ );
2526
2827
 
2527
2828
  return destChange;
2528
2829
  },
2529
-
2830
+
2530
2831
  /**
2531
2832
  * Rebase a change to a new parent (matches `jj rebase`)
2532
2833
  *
2533
2834
  * This is the proper JJ CLI semantic for moving changes in history.
2534
2835
  *
2535
- * @param {Object} args - Arguments
2536
- * @param {string} args.changeId - Change ID to rebase (or use `from` for compatibility)
2537
- * @param {string} args.newParent - New parent change ID (or use `to` for compatibility)
2538
- * @param {Array<string>} [args.paths] - Optional: only rebase changes to specific paths
2836
+ * @param {Record<string, any>} args - Arguments
2539
2837
  * @returns {Promise<Object>} Updated change object
2540
2838
  *
2541
2839
  * @example
@@ -2556,8 +2854,7 @@ export async function createJJ(options) {
2556
2854
  /**
2557
2855
  * Abandon a change
2558
2856
  *
2559
- * @param {Object} [args] - Arguments
2560
- * @param {string} [args.changeId] - Change ID to abandon (defaults to @ - working copy)
2857
+ * @param {Record<string, any>} [args] - Arguments
2561
2858
  * @returns {Promise<Object>} The abandoned change object including changeId, description, and abandoned flag
2562
2859
  */
2563
2860
  async abandon(args = {}) {
@@ -2569,7 +2866,7 @@ export async function createJJ(options) {
2569
2866
  const changeId = args.changeId || workingCopy.getCurrentChangeId();
2570
2867
 
2571
2868
  const change = await graph.getChange(changeId);
2572
- const user = userConfig.getUser();
2869
+ const user = /** @type {any} */ (userConfig.getUser());
2573
2870
 
2574
2871
  if (!change) {
2575
2872
  throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
@@ -2601,12 +2898,17 @@ export async function createJJ(options) {
2601
2898
  });
2602
2899
 
2603
2900
  // Dispatch change:abandoned event (informational)
2604
- await dispatchEventAsync(jj, 'change:abandoned', {
2605
- operation: 'abandon',
2606
- changeId: changeId,
2607
- change,
2608
- timestamp: new Date().toISOString(),
2609
- }, { cancelable: false });
2901
+ await dispatchEventAsync(
2902
+ jj,
2903
+ 'change:abandoned',
2904
+ {
2905
+ operation: 'abandon',
2906
+ changeId: changeId,
2907
+ change,
2908
+ timestamp: new Date().toISOString(),
2909
+ },
2910
+ { cancelable: false }
2911
+ );
2610
2912
 
2611
2913
  return change;
2612
2914
  },
@@ -2617,9 +2919,7 @@ export async function createJJ(options) {
2617
2919
  * Similar to `git revert`, this creates a new change that undoes the changes made
2618
2920
  * by the specified revision without modifying the original change.
2619
2921
  *
2620
- * @param {Object} args - Arguments
2621
- * @param {string} args.revision - Change ID to back out
2622
- * @param {string} [args.message] - Custom description for the backout change
2922
+ * @param {Record<string, any>} args - Arguments
2623
2923
  * @returns {Promise<Object>} The new backout change including changeId, description, and backedOut field
2624
2924
  */
2625
2925
  async backout(args) {
@@ -2634,7 +2934,8 @@ export async function createJJ(options) {
2634
2934
 
2635
2935
  if (!args || !args.revision) {
2636
2936
  throw new JJError('INVALID_ARGUMENT', 'Missing revision argument', {
2637
- suggestion: 'Provide the change ID to back out: { revision: "abc123..." } (or use change/changeId/target)',
2937
+ suggestion:
2938
+ 'Provide the change ID to back out: { revision: "abc123..." } (or use change/changeId/target)',
2638
2939
  });
2639
2940
  }
2640
2941
 
@@ -2643,7 +2944,7 @@ export async function createJJ(options) {
2643
2944
  await userConfig.load();
2644
2945
 
2645
2946
  const originalChange = await graph.getChange(args.revision);
2646
- const user = userConfig.getUser();
2947
+ const user = /** @type {any} */ (userConfig.getUser());
2647
2948
 
2648
2949
  if (!originalChange) {
2649
2950
  throw new JJError('CHANGE_NOT_FOUND', `Change ${args.revision} not found`);
@@ -2713,6 +3014,112 @@ export async function createJJ(options) {
2713
3014
  };
2714
3015
  },
2715
3016
 
3017
+ /**
3018
+ * Revert a change by creating a new change that undoes it (v1.5)
3019
+ *
3020
+ * This is the canonical name in modern Jujutsu (`jj revert`), which fully
3021
+ * replaced `jj backout` in jj v0.35. `backout()` remains as a deprecated
3022
+ * alias for backward compatibility.
3023
+ *
3024
+ * @param {Record<string, any>} args - Revert arguments
3025
+ * @returns {Promise<Object>} Result including `revertedFrom`
3026
+ */
3027
+ async revert(args) {
3028
+ const result = await jj.backout(args);
3029
+ return {
3030
+ changeId: result.changeId,
3031
+ description: result.description,
3032
+ revertedFrom: result.backedOut,
3033
+ // Keep the legacy field so existing callers keep working.
3034
+ backedOut: result.backedOut,
3035
+ fileSnapshot: result.fileSnapshot,
3036
+ };
3037
+ },
3038
+
3039
+ /**
3040
+ * Sign a change (matches `jj sign`, jj v0.27)
3041
+ *
3042
+ * A pure-JS library cannot verify GPG/SSH keys the way the jj CLI does, so
3043
+ * this records signature *metadata* on the change (backend/key/status). This
3044
+ * makes changes discoverable via the `signed()` revset and lets tooling
3045
+ * round-trip signature intent. Pass a real signer via `args.backend`/`args.key`.
3046
+ *
3047
+ * @param {Record<string, any>} [args] - Arguments
3048
+ * @returns {Promise<Object>} { changeId, signed, signature }
3049
+ */
3050
+ async sign(args = {}) {
3051
+ await graph.load();
3052
+ await workingCopy.load();
3053
+
3054
+ const revision =
3055
+ args.revision || args.change || args.changeId || workingCopy.getCurrentChangeId();
3056
+ const change = await graph.getChange(revision);
3057
+ if (!change) {
3058
+ throw new JJError('CHANGE_NOT_FOUND', `Change ${revision} not found`);
3059
+ }
3060
+
3061
+ const signature = {
3062
+ status: 'good',
3063
+ backend: args.backend || 'none',
3064
+ key: args.key || null,
3065
+ timestamp: new Date().toISOString(),
3066
+ };
3067
+
3068
+ await graph.updateChange({ ...change, signed: true, signature });
3069
+
3070
+ await oplog.recordOperation({
3071
+ timestamp: new Date().toISOString(),
3072
+ user: await getUserOplogInfo(),
3073
+ description: `sign ${revision.slice(0, 8)}`,
3074
+ parents: [],
3075
+ view: {
3076
+ bookmarks: {},
3077
+ remoteBookmarks: {},
3078
+ heads: [revision],
3079
+ workingCopy: workingCopy.getCurrentChangeId(),
3080
+ },
3081
+ });
3082
+
3083
+ return { changeId: revision, signed: true, signature };
3084
+ },
3085
+
3086
+ /**
3087
+ * Remove a signature from a change (matches `jj unsign`, jj v0.27)
3088
+ *
3089
+ * @param {Record<string, any>} [args] - Arguments
3090
+ * @returns {Promise<Object>} { changeId, signed }
3091
+ */
3092
+ async unsign(args = {}) {
3093
+ await graph.load();
3094
+ await workingCopy.load();
3095
+
3096
+ const revision =
3097
+ args.revision || args.change || args.changeId || workingCopy.getCurrentChangeId();
3098
+ const change = await graph.getChange(revision);
3099
+ if (!change) {
3100
+ throw new JJError('CHANGE_NOT_FOUND', `Change ${revision} not found`);
3101
+ }
3102
+
3103
+ const updated = { ...change, signed: false };
3104
+ delete updated.signature;
3105
+ await graph.updateChange(updated);
3106
+
3107
+ await oplog.recordOperation({
3108
+ timestamp: new Date().toISOString(),
3109
+ user: await getUserOplogInfo(),
3110
+ description: `unsign ${revision.slice(0, 8)}`,
3111
+ parents: [],
3112
+ view: {
3113
+ bookmarks: {},
3114
+ remoteBookmarks: {},
3115
+ heads: [revision],
3116
+ workingCopy: workingCopy.getCurrentChangeId(),
3117
+ },
3118
+ });
3119
+
3120
+ return { changeId: revision, signed: false };
3121
+ },
3122
+
2716
3123
  /**
2717
3124
  * Absorb working copy changes into ancestor changes
2718
3125
  *
@@ -2727,8 +3134,7 @@ export async function createJJ(options) {
2727
3134
  * 4. Update each ancestor with its modifications
2728
3135
  * 5. Remove absorbed changes from working copy
2729
3136
  *
2730
- * @param {Object} [args] - Optional arguments
2731
- * @param {boolean} [args.dryRun] - Preview without making changes
3137
+ * @param {Record<string, any>} [args] - Optional arguments
2732
3138
  * @returns {Promise<Object>} Result with affected changes
2733
3139
  */
2734
3140
  async absorb(args = {}) {
@@ -2738,7 +3144,7 @@ export async function createJJ(options) {
2738
3144
 
2739
3145
  const workingChangeId = workingCopy.getCurrentChangeId();
2740
3146
  const workingChange = await graph.getChange(workingChangeId);
2741
- const user = userConfig.getUser();
3147
+ const user = /** @type {any} */ (userConfig.getUser());
2742
3148
 
2743
3149
  if (!workingChange) {
2744
3150
  // No working copy
@@ -2767,6 +3173,7 @@ export async function createJJ(options) {
2767
3173
  await graph.updateChange(workingChange);
2768
3174
 
2769
3175
  // Get parent snapshot to determine what's modified
3176
+ /** @type {Record<string, any>} */
2770
3177
  let parentSnapshot = {};
2771
3178
  if (workingChange.parents && workingChange.parents.length > 0) {
2772
3179
  const parent = await graph.getChange(workingChange.parents[0]);
@@ -2776,6 +3183,7 @@ export async function createJJ(options) {
2776
3183
  }
2777
3184
 
2778
3185
  // Find modified files (existed in parent AND content is different)
3186
+ /** @type {Record<string, any>} */
2779
3187
  const modifiedFiles = {};
2780
3188
  for (const [filePath, content] of Object.entries(currentSnapshot)) {
2781
3189
  // Only count as modified if file existed in parent (not a new file)
@@ -2792,6 +3200,7 @@ export async function createJJ(options) {
2792
3200
  }
2793
3201
 
2794
3202
  // Check if there are new files (not in parent)
3203
+ /** @type {Record<string, any>} */
2795
3204
  const newFiles = {};
2796
3205
  for (const [filePath, content] of Object.entries(currentSnapshot)) {
2797
3206
  if (parentSnapshot[filePath] === undefined) {
@@ -2816,6 +3225,7 @@ export async function createJJ(options) {
2816
3225
 
2817
3226
  // For each modified file, use line-level tracking to find which ancestor
2818
3227
  // modified each line, then group modifications by ancestor
3228
+ /** @type {Record<string, any>} */
2819
3229
  const ancestorModifications = {}; // ancestorId -> { filePath: content }
2820
3230
 
2821
3231
  for (const filePath of Object.keys(modifiedFiles)) {
@@ -2943,9 +3353,8 @@ export async function createJJ(options) {
2943
3353
  // Update working copy to reflect absorbed changes
2944
3354
  // Working copy should now match parent's new state (after absorption) plus any new files
2945
3355
  const updatedParent = await graph.getChange(workingChange.parents[0]);
2946
- const newWorkingSnapshot = updatedParent && updatedParent.fileSnapshot
2947
- ? { ...updatedParent.fileSnapshot }
2948
- : {};
3356
+ const newWorkingSnapshot =
3357
+ updatedParent && updatedParent.fileSnapshot ? { ...updatedParent.fileSnapshot } : {};
2949
3358
 
2950
3359
  // Add new files that weren't absorbed
2951
3360
  for (const [filePath, content] of Object.entries(newFiles)) {
@@ -3049,7 +3458,7 @@ export async function createJJ(options) {
3049
3458
  * @param {string} parentContent - Content from parent change
3050
3459
  * @param {string} currentContent - Current working copy content
3051
3460
  * @param {string[]} startingPoints - Change IDs to start search from (parent changeIds)
3052
- * @returns {Promise<Map>} Map of ancestorId -> { lines: Map(lineNum -> content), originalContent }
3461
+ * @returns {Promise<Map<any, any>>} Map of ancestorId -> { lines: Map(lineNum -> content), originalContent }
3053
3462
  */
3054
3463
  async _findLineAncestors(filePath, parentContent, currentContent, startingPoints) {
3055
3464
  if (!startingPoints || startingPoints.length === 0) {
@@ -3203,7 +3612,11 @@ export async function createJJ(options) {
3203
3612
  continue;
3204
3613
  }
3205
3614
 
3206
- if (change.parents && change.parents.includes(currentId) && !visited.has(change.changeId)) {
3615
+ if (
3616
+ change.parents &&
3617
+ change.parents.includes(currentId) &&
3618
+ !visited.has(change.changeId)
3619
+ ) {
3207
3620
  visited.add(change.changeId);
3208
3621
  queue.push(change.changeId);
3209
3622
  descendants.push(change.changeId);
@@ -3219,11 +3632,16 @@ export async function createJJ(options) {
3219
3632
  *
3220
3633
  * @private
3221
3634
  * @param {string[]} descendantIds - List of descendant IDs to rebuild
3222
- * @param {Map} originalStates - Map of changeId -> original state before absorb
3635
+ * @param {Map<any, any>} originalStates - Map of changeId -> original state before absorb
3223
3636
  * @param {string} stopAtChangeId - Don't rebuild this change
3224
- * @param {Set} directlyModified - Set of changeIds that were directly modified by absorb
3637
+ * @param {Set<any>} directlyModified - Set of changeIds that were directly modified by absorb
3225
3638
  */
3226
- async _rebuildDescendantsWithStates(descendantIds, originalStates, stopAtChangeId, directlyModified = new Set()) {
3639
+ async _rebuildDescendantsWithStates(
3640
+ descendantIds,
3641
+ originalStates,
3642
+ stopAtChangeId,
3643
+ directlyModified = new Set()
3644
+ ) {
3227
3645
  // Sort descendants by depth (parents before children)
3228
3646
  const sorted = await this._topologicalSort(descendantIds);
3229
3647
 
@@ -3269,6 +3687,7 @@ export async function createJJ(options) {
3269
3687
 
3270
3688
  // For each file, track which lines the descendant modified from original parent
3271
3689
  // then reapply those modifications to the updated parent
3690
+ /** @type {Record<string, any>} */
3272
3691
  const newSnapshot = {};
3273
3692
 
3274
3693
  const allFiles = new Set([
@@ -3290,9 +3709,15 @@ export async function createJJ(options) {
3290
3709
  const resultLines = [...updatedParentLines];
3291
3710
 
3292
3711
  // Find lines that descendant modified
3293
- for (let i = 0; i < Math.max(originalDescendantLines.length, originalParentLines.length); i++) {
3294
- const originalDescendantLine = i < originalDescendantLines.length ? originalDescendantLines[i] : undefined;
3295
- const originalParentLine = i < originalParentLines.length ? originalParentLines[i] : undefined;
3712
+ for (
3713
+ let i = 0;
3714
+ i < Math.max(originalDescendantLines.length, originalParentLines.length);
3715
+ i++
3716
+ ) {
3717
+ const originalDescendantLine =
3718
+ i < originalDescendantLines.length ? originalDescendantLines[i] : undefined;
3719
+ const originalParentLine =
3720
+ i < originalParentLines.length ? originalParentLines[i] : undefined;
3296
3721
 
3297
3722
  // If descendant modified this line from original parent, apply that modification
3298
3723
  if (originalDescendantLine !== originalParentLine) {
@@ -3356,7 +3781,11 @@ export async function createJJ(options) {
3356
3781
  continue;
3357
3782
  }
3358
3783
 
3359
- if (change.parents && change.parents.includes(currentId) && !visited.has(change.changeId)) {
3784
+ if (
3785
+ change.parents &&
3786
+ change.parents.includes(currentId) &&
3787
+ !visited.has(change.changeId)
3788
+ ) {
3360
3789
  visited.add(change.changeId);
3361
3790
  queue.push(change.changeId);
3362
3791
  descendants.push(change.changeId);
@@ -3394,6 +3823,7 @@ export async function createJJ(options) {
3394
3823
 
3395
3824
  // For each file, track which lines the descendant modified from original parent
3396
3825
  // then reapply those modifications to the updated parent
3826
+ /** @type {Record<string, any>} */
3397
3827
  const newSnapshot = {};
3398
3828
 
3399
3829
  const allFiles = new Set([
@@ -3417,7 +3847,8 @@ export async function createJJ(options) {
3417
3847
  // Find lines that descendant modified
3418
3848
  for (let i = 0; i < Math.max(descendantLines.length, originalParentLines.length); i++) {
3419
3849
  const descendantLine = i < descendantLines.length ? descendantLines[i] : undefined;
3420
- const originalParentLine = i < originalParentLines.length ? originalParentLines[i] : undefined;
3850
+ const originalParentLine =
3851
+ i < originalParentLines.length ? originalParentLines[i] : undefined;
3421
3852
 
3422
3853
  // If descendant modified this line from original parent, apply that modification
3423
3854
  if (descendantLine !== originalParentLine) {
@@ -3451,15 +3882,14 @@ export async function createJJ(options) {
3451
3882
  * @returns {Promise<string[]>} Sorted change IDs
3452
3883
  */
3453
3884
  async _topologicalSort(changeIds) {
3454
- const changes = await Promise.all(
3455
- changeIds.map(id => graph.getChange(id))
3456
- );
3885
+ const changes = await Promise.all(changeIds.map((id) => graph.getChange(id)));
3457
3886
 
3887
+ /** @type {any[]} */
3458
3888
  const sorted = [];
3459
3889
  const visited = new Set();
3460
3890
  const inProgress = new Set();
3461
3891
 
3462
- const visit = async (change) => {
3892
+ const visit = async (/** @type {any} */ change) => {
3463
3893
  if (!change || visited.has(change.changeId)) {
3464
3894
  return;
3465
3895
  }
@@ -3499,8 +3929,7 @@ export async function createJJ(options) {
3499
3929
  * A parent is redundant if it's already an ancestor through another parent.
3500
3930
  * This operation optimizes the graph structure without changing the content.
3501
3931
  *
3502
- * @param {Object} args - Arguments
3503
- * @param {string} args.revision - Change ID to simplify
3932
+ * @param {Record<string, any>} args - Arguments
3504
3933
  * @returns {Promise<{changeId: string, simplified: boolean, removedParents?: string[]}>}
3505
3934
  */
3506
3935
  async simplifyParents(args) {
@@ -3514,7 +3943,7 @@ export async function createJJ(options) {
3514
3943
  await userConfig.load();
3515
3944
 
3516
3945
  const change = await graph.getChange(args.revision);
3517
- const user = userConfig.getUser();
3946
+ const user = /** @type {any} */ (userConfig.getUser());
3518
3947
 
3519
3948
  if (!change) {
3520
3949
  throw new JJError('CHANGE_NOT_FOUND', `Change ${args.revision} not found`);
@@ -3530,6 +3959,7 @@ export async function createJJ(options) {
3530
3959
 
3531
3960
  // Find redundant parents
3532
3961
  // A parent P is redundant if there exists another parent Q such that P is an ancestor of Q
3962
+ /** @type {any[]} */
3533
3963
  const redundantParents = [];
3534
3964
 
3535
3965
  for (let i = 0; i < change.parents.length; i++) {
@@ -3543,7 +3973,7 @@ export async function createJJ(options) {
3543
3973
 
3544
3974
  // Get all ancestors of parentQ
3545
3975
  const ancestorsQ = await this.log({ revisions: [parentQ] });
3546
- const ancestorIds = ancestorsQ.map(c => c.changeId);
3976
+ const ancestorIds = ancestorsQ.map((/** @type {any} */ c) => c.changeId);
3547
3977
 
3548
3978
  // If parentP is in the ancestors of parentQ, then parentP is redundant
3549
3979
  if (ancestorIds.includes(parentP)) {
@@ -3564,7 +3994,9 @@ export async function createJJ(options) {
3564
3994
  }
3565
3995
 
3566
3996
  // Remove redundant parents
3567
- change.parents = change.parents.filter(p => !redundantParents.includes(p));
3997
+ change.parents = change.parents.filter(
3998
+ (/** @type {any} */ p) => !redundantParents.includes(p)
3999
+ );
3568
4000
  await graph.updateChange(change);
3569
4001
 
3570
4002
  // Record operation
@@ -3591,8 +4023,7 @@ export async function createJJ(options) {
3591
4023
  /**
3592
4024
  * Un-abandon a change (restore from abandoned state)
3593
4025
  *
3594
- * @param {Object} args - Arguments
3595
- * @param {string} args.changeId - Change ID to un-abandon
4026
+ * @param {Record<string, any>} args - Arguments
3596
4027
  * @returns {Promise<Object>} The un-abandoned change object including changeId, description, and abandoned flag (now false)
3597
4028
  */
3598
4029
  async unabandon(args) {
@@ -3617,7 +4048,7 @@ export async function createJJ(options) {
3617
4048
  await userConfig.load();
3618
4049
 
3619
4050
  const change = await graph.getChange(args.changeId);
3620
- const user = userConfig.getUser();
4051
+ const user = /** @type {any} */ (userConfig.getUser());
3621
4052
 
3622
4053
  if (!change) {
3623
4054
  throw new JJError('CHANGE_NOT_FOUND', `Change ${args.changeId} not found`);
@@ -3649,24 +4080,25 @@ export async function createJJ(options) {
3649
4080
  });
3650
4081
 
3651
4082
  // Dispatch change:unabandoned event (informational)
3652
- await dispatchEventAsync(jj, 'change:unabandoned', {
3653
- operation: 'unabandon',
3654
- changeId: args.changeId,
3655
- change,
3656
- timestamp: new Date().toISOString(),
3657
- }, { cancelable: false });
4083
+ await dispatchEventAsync(
4084
+ jj,
4085
+ 'change:unabandoned',
4086
+ {
4087
+ operation: 'unabandon',
4088
+ changeId: args.changeId,
4089
+ change,
4090
+ timestamp: new Date().toISOString(),
4091
+ },
4092
+ { cancelable: false }
4093
+ );
3658
4094
 
3659
4095
  return change;
3660
4096
  },
3661
-
4097
+
3662
4098
  /**
3663
4099
  * Split a change into multiple changes (simplified v0.2 implementation)
3664
4100
  *
3665
- * @param {Object} args - Arguments
3666
- * @param {string} args.changeId - Change ID to split
3667
- * @param {string} args.description1 - Description for first part
3668
- * @param {string} args.description2 - Description for second part
3669
- * @param {string[]} [args.paths] - Specific paths to include in first split (full implementation planned for future)
4101
+ * @param {Record<string, any>} args - Arguments
3670
4102
  */
3671
4103
  async split(args) {
3672
4104
  // Normalize: accept common parameter aliases
@@ -3746,7 +4178,7 @@ export async function createJJ(options) {
3746
4178
 
3747
4179
  return { original: originalChange, new: newChange };
3748
4180
  },
3749
-
4181
+
3750
4182
  // ========================================
3751
4183
  // v0.3 FEATURES: Git Integration
3752
4184
  // ========================================
@@ -3759,18 +4191,13 @@ export async function createJJ(options) {
3759
4191
  * Initialize a new Git-backed JJ repository
3760
4192
  * Matches `jj git init` CLI command
3761
4193
  *
3762
- * @param {Object} opts - Initialization options
3763
- * @param {string} [opts.userName] - User name for commits
3764
- * @param {string} [opts.userEmail] - User email for commits
3765
- * @param {string} [opts.defaultBranch] - Default branch name (default: 'main')
4194
+ * @param {Record<string, any>} opts - Initialization options
3766
4195
  */
3767
4196
  async init(opts = {}) {
3768
4197
  if (!gitBackend) {
3769
- throw new JJError(
3770
- 'BACKEND_NOT_AVAILABLE',
3771
- 'Git backend not configured',
3772
- { suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })' }
3773
- );
4198
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4199
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4200
+ });
3774
4201
  }
3775
4202
 
3776
4203
  // Initialize Git backend
@@ -3795,7 +4222,7 @@ export async function createJJ(options) {
3795
4222
 
3796
4223
  // Create root change
3797
4224
  const rootChangeId = generateChangeId();
3798
- const user = userConfig.getUser();
4225
+ const user = /** @type {any} */ (userConfig.getUser());
3799
4226
  const rootChange = {
3800
4227
  changeId: rootChangeId,
3801
4228
  commitId: '0000000000000000000000000000000000000000',
@@ -3840,32 +4267,22 @@ export async function createJJ(options) {
3840
4267
  /**
3841
4268
  * Fetch changes from remote Git repository
3842
4269
  *
3843
- * @param {Object} args - Fetch arguments
3844
- * @param {string} args.remote - Remote name or URL
3845
- * @param {string[]} [args.refs] - Refs to fetch (default: all)
3846
- * @param {number} [args.depth] - Create shallow clone with history truncated to depth (v0.4)
3847
- * @param {boolean} [args.relative] - Depth measured from current shallow depth (v0.4)
3848
- * @param {boolean} [args.singleBranch] - Only fetch single branch (v0.4)
3849
- * @param {boolean} [args.noTags] - Don't fetch tags (v0.4)
3850
- * @param {Function} [args.onProgress] - Progress callback
3851
- * @param {Function} [args.onAuth] - Authentication callback
4270
+ * @param {Record<string, any>} args - Fetch arguments
3852
4271
  */
3853
4272
  async fetch(args) {
3854
4273
  if (!gitBackend) {
3855
- throw new JJError(
3856
- 'BACKEND_NOT_AVAILABLE',
3857
- 'Git backend not configured',
3858
- { suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })' }
3859
- );
4274
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4275
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4276
+ });
3860
4277
  }
3861
4278
 
3862
4279
  const result = await gitBackend.fetch({
3863
4280
  remote: args.remote,
3864
4281
  refs: args.refs,
3865
- depth: args.depth, // v0.4: Shallow clone support
3866
- relative: args.relative, // v0.4: Relative depth
4282
+ depth: args.depth, // v0.4: Shallow clone support
4283
+ relative: args.relative, // v0.4: Relative depth
3867
4284
  singleBranch: args.singleBranch, // v0.4: Single branch only
3868
- noTags: args.noTags, // v0.4: Skip tags
4285
+ noTags: args.noTags, // v0.4: Skip tags
3869
4286
  onProgress: args.onProgress,
3870
4287
  onAuth: args.onAuth,
3871
4288
  });
@@ -3890,20 +4307,13 @@ export async function createJJ(options) {
3890
4307
  /**
3891
4308
  * Push changes to remote Git repository
3892
4309
  *
3893
- * @param {Object} args - Push arguments
3894
- * @param {string} args.remote - Remote name or URL
3895
- * @param {string[]} [args.refs] - Refs to push (default: current bookmarks)
3896
- * @param {boolean} [args.force] - Allow non-fast-forward
3897
- * @param {Function} [args.onProgress] - Progress callback
3898
- * @param {Function} [args.onAuth] - Authentication callback
4310
+ * @param {Record<string, any>} args - Push arguments
3899
4311
  */
3900
4312
  async push(args) {
3901
4313
  if (!gitBackend) {
3902
- throw new JJError(
3903
- 'BACKEND_NOT_AVAILABLE',
3904
- 'Git backend not configured',
3905
- { suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })' }
3906
- );
4314
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4315
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4316
+ });
3907
4317
  }
3908
4318
 
3909
4319
  const result = await gitBackend.push({
@@ -3934,24 +4344,14 @@ export async function createJJ(options) {
3934
4344
  /**
3935
4345
  * Clone a Git repository (matches `jj git clone`)
3936
4346
  *
3937
- * @param {Object} args - Clone arguments
3938
- * @param {string} args.url - Repository URL to clone from
3939
- * @param {string} [args.dir] - Directory to clone into (defaults to repo name from URL)
3940
- * @param {number} [args.depth] - Create shallow clone with history truncated to depth
3941
- * @param {boolean} [args.singleBranch] - Only clone single branch
3942
- * @param {boolean} [args.noTags] - Don't clone tags
3943
- * @param {string} [args.ref] - Specific ref/branch to clone
3944
- * @param {Function} [args.onProgress] - Progress callback
3945
- * @param {Function} [args.onAuth] - Authentication callback
4347
+ * @param {Record<string, any>} args - Clone arguments
3946
4348
  * @returns {Promise<Object>} Clone result with directory path
3947
4349
  */
3948
4350
  async clone(args) {
3949
4351
  if (!gitBackend) {
3950
- throw new JJError(
3951
- 'BACKEND_NOT_AVAILABLE',
3952
- 'Git backend not configured',
3953
- { suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })' }
3954
- );
4352
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4353
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4354
+ });
3955
4355
  }
3956
4356
 
3957
4357
  if (!args || !args.url) {
@@ -3968,7 +4368,12 @@ export async function createJJ(options) {
3968
4368
 
3969
4369
  // Determine clone directory
3970
4370
  const git = (await import('isomorphic-git')).default;
3971
- const cloneDir = args.dir || args.url.split('/').pop().replace(/\.git$/, '');
4371
+ const cloneDir =
4372
+ args.dir ||
4373
+ args.url
4374
+ .split('/')
4375
+ .pop()
4376
+ .replace(/\.git$/, '');
3972
4377
  const fullCloneDir = path.join(dir, cloneDir);
3973
4378
 
3974
4379
  // Clone using isomorphic-git
@@ -3986,7 +4391,9 @@ export async function createJJ(options) {
3986
4391
  });
3987
4392
 
3988
4393
  // Initialize JJ repository structure in cloned directory
3989
- const clonedBackend = new IsomorphicGitBackend({ fs, http, dir: fullCloneDir });
4394
+ const clonedBackend = /** @type {any} */ (
4395
+ new IsomorphicGitBackend({ fs, http, dir: fullCloneDir })
4396
+ );
3990
4397
  await clonedBackend._createJJRepoStructure();
3991
4398
 
3992
4399
  // Record operation
@@ -4017,11 +4424,9 @@ export async function createJJ(options) {
4017
4424
  */
4018
4425
  async import() {
4019
4426
  if (!gitBackend) {
4020
- throw new JJError(
4021
- 'BACKEND_NOT_AVAILABLE',
4022
- 'Git backend not configured',
4023
- { suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })' }
4024
- );
4427
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4428
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4429
+ });
4025
4430
  }
4026
4431
 
4027
4432
  await graph.load();
@@ -4039,14 +4444,18 @@ export async function createJJ(options) {
4039
4444
  if (!change) {
4040
4445
  // Create a new change for this commit
4041
4446
  const changeId = generateChangeId();
4042
- const user = userConfig.getUser();
4447
+ const user = /** @type {any} */ (userConfig.getUser());
4043
4448
  change = {
4044
4449
  changeId,
4045
4450
  commitId: ref.oid,
4046
4451
  parents: [],
4047
4452
  tree: ref.oid,
4048
4453
  author: { name: user.name, email: user.email, timestamp: new Date().toISOString() },
4049
- committer: { name: user.name, email: user.email, timestamp: new Date().toISOString() },
4454
+ committer: {
4455
+ name: user.name,
4456
+ email: user.email,
4457
+ timestamp: new Date().toISOString(),
4458
+ },
4050
4459
  description: `Imported from Git ref ${ref.name}`,
4051
4460
  timestamp: new Date().toISOString(),
4052
4461
  };
@@ -4090,11 +4499,9 @@ export async function createJJ(options) {
4090
4499
  */
4091
4500
  async export() {
4092
4501
  if (!gitBackend) {
4093
- throw new JJError(
4094
- 'BACKEND_NOT_AVAILABLE',
4095
- 'Git backend not configured',
4096
- { suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })' }
4097
- );
4502
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4503
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4504
+ });
4098
4505
  }
4099
4506
 
4100
4507
  await graph.load();
@@ -4139,37 +4546,31 @@ export async function createJJ(options) {
4139
4546
  /**
4140
4547
  * List Git remotes (matches `jj git remote list`)
4141
4548
  *
4142
- * @returns {Promise<Array>} List of remotes with names and URLs
4549
+ * @returns {Promise<Array<any>>} List of remotes with names and URLs
4143
4550
  */
4144
4551
  async list() {
4145
4552
  if (!gitBackend) {
4146
- throw new JJError(
4147
- 'BACKEND_NOT_AVAILABLE',
4148
- 'Git backend not configured',
4149
- { suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })' }
4150
- );
4553
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4554
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4555
+ });
4151
4556
  }
4152
4557
 
4153
4558
  const git = (await import('isomorphic-git')).default;
4154
4559
  const remotes = await git.listRemotes({ fs, dir });
4155
- return remotes.map(r => ({ name: r.remote, url: r.url }));
4560
+ return remotes.map((r) => ({ name: r.remote, url: r.url }));
4156
4561
  },
4157
4562
 
4158
4563
  /**
4159
4564
  * Add a Git remote (matches `jj git remote add`)
4160
4565
  *
4161
- * @param {Object} args - Remote arguments
4162
- * @param {string} args.name - Remote name
4163
- * @param {string} args.url - Remote URL
4566
+ * @param {Record<string, any>} args - Remote arguments
4164
4567
  * @returns {Promise<Object>} Added remote info
4165
4568
  */
4166
4569
  async add(args) {
4167
4570
  if (!gitBackend) {
4168
- throw new JJError(
4169
- 'BACKEND_NOT_AVAILABLE',
4170
- 'Git backend not configured',
4171
- { suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })' }
4172
- );
4571
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4572
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4573
+ });
4173
4574
  }
4174
4575
 
4175
4576
  if (!args || !args.name || !args.url) {
@@ -4206,17 +4607,14 @@ export async function createJJ(options) {
4206
4607
  /**
4207
4608
  * Remove a Git remote (matches `jj git remote remove`)
4208
4609
  *
4209
- * @param {Object} args - Remote arguments
4210
- * @param {string} args.name - Remote name to remove
4610
+ * @param {Record<string, any>} args - Remote arguments
4211
4611
  * @returns {Promise<Object>} Removal result
4212
4612
  */
4213
4613
  async remove(args) {
4214
4614
  if (!gitBackend) {
4215
- throw new JJError(
4216
- 'BACKEND_NOT_AVAILABLE',
4217
- 'Git backend not configured',
4218
- { suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })' }
4219
- );
4615
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4616
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4617
+ });
4220
4618
  }
4221
4619
 
4222
4620
  if (!args || !args.name) {
@@ -4252,18 +4650,14 @@ export async function createJJ(options) {
4252
4650
  /**
4253
4651
  * Rename a Git remote (matches `jj git remote rename`)
4254
4652
  *
4255
- * @param {Object} args - Remote arguments
4256
- * @param {string} args.oldName - Current remote name
4257
- * @param {string} args.newName - New remote name
4653
+ * @param {Record<string, any>} args - Remote arguments
4258
4654
  * @returns {Promise<Object>} Rename result
4259
4655
  */
4260
4656
  async rename(args) {
4261
4657
  if (!gitBackend) {
4262
- throw new JJError(
4263
- 'BACKEND_NOT_AVAILABLE',
4264
- 'Git backend not configured',
4265
- { suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })' }
4266
- );
4658
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4659
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4660
+ });
4267
4661
  }
4268
4662
 
4269
4663
  if (!args || !args.oldName || !args.newName) {
@@ -4276,7 +4670,7 @@ export async function createJJ(options) {
4276
4670
 
4277
4671
  // Get current URL
4278
4672
  const remotes = await git.listRemotes({ fs, dir });
4279
- const remote = remotes.find(r => r.remote === args.oldName);
4673
+ const remote = remotes.find((r) => r.remote === args.oldName);
4280
4674
  if (!remote) {
4281
4675
  throw new JJError('NOT_FOUND', `Remote ${args.oldName} not found`);
4282
4676
  }
@@ -4305,18 +4699,14 @@ export async function createJJ(options) {
4305
4699
  /**
4306
4700
  * Set URL for a Git remote (matches `jj git remote set-url`)
4307
4701
  *
4308
- * @param {Object} args - Remote arguments
4309
- * @param {string} args.name - Remote name
4310
- * @param {string} args.url - New URL
4702
+ * @param {Record<string, any>} args - Remote arguments
4311
4703
  * @returns {Promise<Object>} Updated remote info
4312
4704
  */
4313
4705
  async setUrl(args) {
4314
4706
  if (!gitBackend) {
4315
- throw new JJError(
4316
- 'BACKEND_NOT_AVAILABLE',
4317
- 'Git backend not configured',
4318
- { suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })' }
4319
- );
4707
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4708
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4709
+ });
4320
4710
  }
4321
4711
 
4322
4712
  if (!args || !args.name || !args.url) {
@@ -4329,7 +4719,7 @@ export async function createJJ(options) {
4329
4719
 
4330
4720
  // Check if remote exists
4331
4721
  const remotes = await git.listRemotes({ fs, dir });
4332
- const remote = remotes.find(r => r.remote === args.name);
4722
+ const remote = remotes.find((r) => r.remote === args.name);
4333
4723
  if (!remote) {
4334
4724
  throw new JJError('NOT_FOUND', `Remote ${args.name} not found`);
4335
4725
  }
@@ -4383,8 +4773,7 @@ export async function createJJ(options) {
4383
4773
  /**
4384
4774
  * Merge changes from another change, detecting conflicts
4385
4775
  *
4386
- * @param {Object} args - Merge arguments
4387
- * @param {string} args.source - Source change ID to merge
4776
+ * @param {Record<string, any>} args - Merge arguments
4388
4777
  * @returns {Promise<Object>} Merge result with conflicts
4389
4778
  */
4390
4779
  async merge(args) {
@@ -4402,7 +4791,7 @@ export async function createJJ(options) {
4402
4791
  };
4403
4792
 
4404
4793
  const currentChangeId = workingCopy.getCurrentChangeId();
4405
- const currentChange = await graph.getChange(currentChangeId);
4794
+ const _currentChange = await graph.getChange(currentChangeId);
4406
4795
  const sourceChange = await graph.getChange(args.source);
4407
4796
 
4408
4797
  if (!sourceChange) {
@@ -4467,9 +4856,9 @@ export async function createJJ(options) {
4467
4856
  baseFiles,
4468
4857
  leftFiles,
4469
4858
  rightFiles,
4470
- drivers: args.drivers || {}, // v0.5: custom merge drivers
4471
- workingCopyDir: args.dryRun ? null : dir, // v0.5: skip file writes in dry-run
4472
- baseChange: baseChangeId, // v0.5: metadata for drivers
4859
+ drivers: args.drivers || {}, // v0.5: custom merge drivers
4860
+ workingCopyDir: /** @type {any} */ (args.dryRun ? null : dir), // v0.5: skip file writes in dry-run
4861
+ baseChange: baseChangeId, // v0.5: metadata for drivers
4473
4862
  leftChange: currentChangeId,
4474
4863
  rightChange: args.source,
4475
4864
  });
@@ -4522,8 +4911,7 @@ export async function createJJ(options) {
4522
4911
  /**
4523
4912
  * List active (unresolved) conflicts
4524
4913
  *
4525
- * @param {Object} opts - List options
4526
- * @param {boolean} [opts.includeResolved=false] - Include resolved conflicts
4914
+ * @param {Record<string, any>} opts - List options
4527
4915
  */
4528
4916
  async list(opts = {}) {
4529
4917
  await conflicts.load();
@@ -4535,11 +4923,7 @@ export async function createJJ(options) {
4535
4923
  /**
4536
4924
  * Resolve a conflict (v0.5 enhanced)
4537
4925
  *
4538
- * @param {Object} args - Resolution arguments
4539
- * @param {string} args.conflictId - Conflict ID
4540
- * @param {string} [args.resolution] - Manual resolution content
4541
- * @param {string} [args.driver] - Merge driver to use
4542
- * @param {string} [args.strategy] - Resolution strategy ('ours', 'theirs', 'union')
4926
+ * @param {Record<string, any>} args - Resolution arguments
4543
4927
  */
4544
4928
  async resolve(args) {
4545
4929
  if (!args || !args.conflictId) {
@@ -4569,12 +4953,12 @@ export async function createJJ(options) {
4569
4953
 
4570
4954
  const driverResult = await mergeDrivers.executeDriver(
4571
4955
  driver,
4572
- {
4956
+ /** @type {any} */ ({
4573
4957
  path: conflict.path,
4574
4958
  base: conflict.sides.base,
4575
4959
  ours: conflict.sides.left,
4576
4960
  theirs: conflict.sides.right,
4577
- },
4961
+ }),
4578
4962
  mergeDrivers.isBinaryFile(conflict.path, conflict.sides.left)
4579
4963
  );
4580
4964
 
@@ -4603,10 +4987,7 @@ export async function createJJ(options) {
4603
4987
  /**
4604
4988
  * Resolve all conflicts (v0.5)
4605
4989
  *
4606
- * @param {Object} args - Resolution arguments
4607
- * @param {string} args.strategy - Resolution strategy ('ours', 'theirs', 'union')
4608
- * @param {Object} [args.filter] - Optional filter
4609
- * @param {string} [args.filter.path] - Path pattern to filter
4990
+ * @param {Record<string, any>} args - Resolution arguments
4610
4991
  */
4611
4992
  async resolveAll(args) {
4612
4993
  if (!args || !args.strategy) {
@@ -4620,7 +5001,7 @@ export async function createJJ(options) {
4620
5001
  let toResolve = allConflicts;
4621
5002
  if (args.filter && args.filter.path) {
4622
5003
  const pattern = args.filter.path;
4623
- toResolve = allConflicts.filter(c => {
5004
+ toResolve = allConflicts.filter((c) => {
4624
5005
  // Simple pattern matching - exact match or glob
4625
5006
  if (pattern.includes('*')) {
4626
5007
  const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
@@ -4655,8 +5036,7 @@ export async function createJJ(options) {
4655
5036
  /**
4656
5037
  * Get conflict markers for a conflict (v0.5)
4657
5038
  *
4658
- * @param {Object} args - Arguments
4659
- * @param {string} args.conflictId - Conflict ID
5039
+ * @param {Record<string, any>} args - Arguments
4660
5040
  * @returns {Promise<string>} Conflict markers in standard format
4661
5041
  */
4662
5042
  async markers(args) {
@@ -4671,17 +5051,24 @@ export async function createJJ(options) {
4671
5051
  throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
4672
5052
  }
4673
5053
 
4674
- // Generate standard conflict markers
5054
+ // Generate standard conflict markers. Force a newline before each
5055
+ // boundary marker whenever there's non-empty content that doesn't
5056
+ // already end in one — otherwise the content fuses onto the
5057
+ // `=======`/`>>>>>>> theirs` marker, producing a corrupted/unparseable
5058
+ // line (see issue #15). Empty sides (e.g. a modify-delete conflict)
5059
+ // intentionally get no extra blank line before the marker.
4675
5060
  const ours = conflict.sides.left || '';
4676
5061
  const theirs = conflict.sides.right || '';
5062
+ const oursSep = ours && !ours.endsWith('\n') ? '\n' : '';
5063
+ const theirsSep = theirs && !theirs.endsWith('\n') ? '\n' : '';
4677
5064
 
4678
- return `<<<<<<< ours\n${ours}=======\n${theirs}>>>>>>> theirs`;
5065
+ return `<<<<<<< ours\n${ours}${oursSep}=======\n${theirs}${theirsSep}>>>>>>> theirs`;
4679
5066
  },
4680
5067
 
4681
5068
  /**
4682
5069
  * Mark conflict as resolved
4683
5070
  */
4684
- async markResolved(args) {
5071
+ async markResolved(/** @type {Record<string, any>} */ args) {
4685
5072
  if (!args || !args.conflictId) {
4686
5073
  throw new JJError('INVALID_ARGUMENT', 'Missing conflictId');
4687
5074
  }
@@ -4702,19 +5089,15 @@ export async function createJJ(options) {
4702
5089
  *
4703
5090
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
4704
5091
  */
4705
- async resolve(args = {}) {
5092
+ async resolve(/** @type {Record<string, any>} */ args = {}) {
4706
5093
  // Check for external merge tool usage
4707
5094
  if (args.tool) {
4708
- throw new JJError(
4709
- 'UNSUPPORTED_OPERATION',
4710
- 'External merge tools not supported',
4711
- {
4712
- feature: 'resolve --tool',
4713
- reason: 'External merge tools require system integration not available in library API',
4714
- alternative: 'Use jj.conflicts.resolve() with programmatic resolution',
4715
- suggestion: 'For conflict resolution, use the programmatic API or resolve files manually',
4716
- }
4717
- );
5095
+ throw new JJError('UNSUPPORTED_OPERATION', 'External merge tools not supported', {
5096
+ feature: 'resolve --tool',
5097
+ reason: 'External merge tools require system integration not available in library API',
5098
+ alternative: 'Use jj.conflicts.resolve() with programmatic resolution',
5099
+ suggestion: 'For conflict resolution, use the programmatic API or resolve files manually',
5100
+ });
4718
5101
  }
4719
5102
 
4720
5103
  throw new JJError(
@@ -4743,10 +5126,7 @@ export async function createJJ(options) {
4743
5126
  /**
4744
5127
  * Show file content (matches `jj file show`)
4745
5128
  *
4746
- * @param {Object} args - Arguments
4747
- * @param {string} args.path - File path to read
4748
- * @param {string} [args.changeId] - Change ID (defaults to working copy)
4749
- * @param {string} [args.encoding='utf-8'] - Encoding ('utf-8' or 'binary')
5129
+ * @param {Record<string, any>} args - Arguments
4750
5130
  * @returns {Promise<string|Uint8Array>} File contents
4751
5131
  */
4752
5132
  async show(args) {
@@ -4756,20 +5136,91 @@ export async function createJJ(options) {
4756
5136
  /**
4757
5137
  * List files (matches `jj file list`)
4758
5138
  *
4759
- * @param {Object} [args={}] - Arguments
4760
- * @param {string} [args.changeId] - Change ID (defaults to working copy)
5139
+ * @param {Record<string, any>} [args={}] - Arguments
4761
5140
  * @returns {Promise<Array<string>>} Array of file paths
4762
5141
  */
4763
5142
  async list(args = {}) {
4764
5143
  return await jj.listFiles(args);
4765
5144
  },
4766
5145
 
5146
+ /**
5147
+ * Search file contents (matches `jj file search`, jj v0.37).
5148
+ *
5149
+ * Searches the tracked files of a change (defaults to the working copy)
5150
+ * for lines matching a pattern. Following jj v0.41, the pattern is treated
5151
+ * as a regular expression by default; pass `{ kind: 'substring' }` for a
5152
+ * literal search. isomorphic-jj has always returned the matched line
5153
+ * (and its 1-based `lineNumber`) alongside the path, which is what jj
5154
+ * v0.44 made the CLI's new default (`jj file search` used to print only
5155
+ * file paths). Pass `{ nameOnly: true }` for the pre-v0.44 CLI
5156
+ * behavior — a deduplicated list of matching file paths, matching the
5157
+ * new `--name-only` flag.
5158
+ *
5159
+ * @param {Record<string, any>} args - Arguments. Pass `nameOnly: true`
5160
+ * to return only unique matching file paths instead of per-line
5161
+ * matches (jj v0.44 `--name-only`)
5162
+ * @returns {Promise<Array<{path: string, lineNumber: number, line: string}>|Array<string>>}
5163
+ */
5164
+ async search(args) {
5165
+ if (!args || !args.pattern) {
5166
+ throw new JJError('INVALID_ARGUMENT', 'Missing pattern argument', {
5167
+ suggestion: 'Provide a search pattern: { pattern: "TODO" }',
5168
+ });
5169
+ }
5170
+
5171
+ // v1.6: when searching the working copy, snapshot the disk first so
5172
+ // out-of-band files are searchable.
5173
+ if (!args.changeId && !args.change) {
5174
+ await autoSnapshotWorkingCopy();
5175
+ }
5176
+
5177
+ await graph.load();
5178
+ const changeId = args.changeId || args.change || workingCopy.getCurrentChangeId();
5179
+ const change = await graph.getChange(changeId);
5180
+ if (!change) {
5181
+ throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
5182
+ }
5183
+
5184
+ const kind = args.kind || 'regex';
5185
+ let matcher;
5186
+ if (kind === 'substring') {
5187
+ matcher = (/** @type {any} */ line) => line.includes(args.pattern);
5188
+ } else {
5189
+ let regex;
5190
+ try {
5191
+ regex = new RegExp(args.pattern);
5192
+ } catch (error) {
5193
+ throw new JJError('INVALID_PATTERN', `Invalid regex pattern: ${error.message}`, {
5194
+ pattern: args.pattern,
5195
+ });
5196
+ }
5197
+ matcher = (/** @type {any} */ line) => regex.test(line);
5198
+ }
5199
+
5200
+ const snapshot = change.fileSnapshot || {};
5201
+ /** @type {any[]} */
5202
+ const results = [];
5203
+ for (const [filePath, content] of Object.entries(snapshot)) {
5204
+ if (args.path && filePath !== args.path) continue;
5205
+ if (typeof content !== 'string') continue; // skip binary
5206
+ const lines = content.split('\n');
5207
+ lines.forEach((line, index) => {
5208
+ if (matcher(line)) {
5209
+ results.push({ path: filePath, lineNumber: index + 1, line });
5210
+ }
5211
+ });
5212
+ }
5213
+
5214
+ if (args.nameOnly) {
5215
+ return Array.from(new Set(results.map((r) => r.path)));
5216
+ }
5217
+ return results;
5218
+ },
5219
+
4767
5220
  /**
4768
5221
  * Write file content (organized file operation)
4769
5222
  *
4770
- * @param {Object} args - Arguments
4771
- * @param {string} args.path - File path to write
4772
- * @param {string|Uint8Array} args.data - File content
5223
+ * @param {Record<string, any>} args - Arguments
4773
5224
  * @returns {Promise<Object>} Write result with file info
4774
5225
  */
4775
5226
  async write(args) {
@@ -4779,9 +5230,7 @@ export async function createJJ(options) {
4779
5230
  /**
4780
5231
  * Move/rename file (organized file operation)
4781
5232
  *
4782
- * @param {Object} args - Arguments
4783
- * @param {string} args.from - Source path
4784
- * @param {string} args.to - Destination path
5233
+ * @param {Record<string, any>} args - Arguments
4785
5234
  * @returns {Promise<Object>} Move result
4786
5235
  */
4787
5236
  async move(args) {
@@ -4791,8 +5240,7 @@ export async function createJJ(options) {
4791
5240
  /**
4792
5241
  * Remove file (organized file operation)
4793
5242
  *
4794
- * @param {Object} args - Arguments
4795
- * @param {string} args.path - File path to remove
5243
+ * @param {Record<string, any>} args - Arguments
4796
5244
  * @returns {Promise<Object>} Remove result with file info
4797
5245
  */
4798
5246
  async remove(args) {
@@ -4802,10 +5250,8 @@ export async function createJJ(options) {
4802
5250
  /**
4803
5251
  * Show which revision modified each line (matches `jj file annotate` / git blame)
4804
5252
  *
4805
- * @param {Object} args - Arguments
4806
- * @param {string} args.path - File path to annotate
4807
- * @param {string} [args.changeId] - Change ID (defaults to working copy)
4808
- * @returns {Promise<Array>} Array of line annotations with changeId, author, timestamp, and content
5253
+ * @param {Record<string, any>} args - Arguments
5254
+ * @returns {Promise<Array<any>>} Array of line annotations with changeId, author, timestamp, and content
4809
5255
  */
4810
5256
  async annotate(args) {
4811
5257
  if (!args || !args.path) {
@@ -4847,7 +5293,8 @@ export async function createJJ(options) {
4847
5293
  }
4848
5294
 
4849
5295
  // Move to parent (simplified - takes first parent)
4850
- currentChangeId = change.parents && change.parents.length > 0 ? change.parents[0] : null;
5296
+ currentChangeId =
5297
+ change.parents && change.parents.length > 0 ? change.parents[0] : null;
4851
5298
  }
4852
5299
 
4853
5300
  annotations.push({
@@ -4866,9 +5313,7 @@ export async function createJJ(options) {
4866
5313
  * Change file permissions (matches `jj file chmod`)
4867
5314
  * Note: Only works in Node.js, not in browsers
4868
5315
  *
4869
- * @param {Object} args - Arguments
4870
- * @param {string} args.path - File path
4871
- * @param {number|string} args.mode - File mode (e.g., 0o755 or '755')
5316
+ * @param {Record<string, any>} args - Arguments
4872
5317
  * @returns {Promise<Object>} chmod result
4873
5318
  */
4874
5319
  async chmod(args) {
@@ -4886,9 +5331,13 @@ export async function createJJ(options) {
4886
5331
 
4887
5332
  // Check if we're in a browser environment
4888
5333
  if (typeof process === 'undefined' || !fs.promises.chmod) {
4889
- throw new JJError('UNSUPPORTED_OPERATION', 'chmod is not supported in browser environments', {
4890
- suggestion: 'chmod only works in Node.js',
4891
- });
5334
+ throw new JJError(
5335
+ 'UNSUPPORTED_OPERATION',
5336
+ 'chmod is not supported in browser environments',
5337
+ {
5338
+ suggestion: 'chmod only works in Node.js',
5339
+ }
5340
+ );
4892
5341
  }
4893
5342
 
4894
5343
  const fullPath = path.join(dir, args.path);
@@ -4914,11 +5363,10 @@ export async function createJJ(options) {
4914
5363
  * This is a stub method that throws an error explaining why explicit
4915
5364
  * file tracking is not needed in JavaScript environments.
4916
5365
  *
4917
- * @param {Object} args - Arguments
4918
- * @param {string} args.path - File path to track
5366
+ * @param {Record<string, any>} _args - Arguments
4919
5367
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
4920
5368
  */
4921
- async track(args) {
5369
+ async track(_args) {
4922
5370
  throw new JJError(
4923
5371
  'UNSUPPORTED_OPERATION',
4924
5372
  'Explicit file tracking (jj file track) is not needed in JavaScript environments',
@@ -4945,11 +5393,10 @@ export async function createJJ(options) {
4945
5393
  * This is a stub method that throws an error explaining why explicit
4946
5394
  * file untracking is not needed in JavaScript environments.
4947
5395
  *
4948
- * @param {Object} args - Arguments
4949
- * @param {string} args.path - File path to untrack
5396
+ * @param {Record<string, any>} _args - Arguments
4950
5397
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
4951
5398
  */
4952
- async untrack(args) {
5399
+ async untrack(_args) {
4953
5400
  throw new JJError(
4954
5401
  'UNSUPPORTED_OPERATION',
4955
5402
  'Explicit file untracking (jj file untrack) is not needed in JavaScript environments',
@@ -4979,10 +5426,7 @@ export async function createJJ(options) {
4979
5426
  /**
4980
5427
  * Add a new workspace
4981
5428
  *
4982
- * @param {Object} args - Workspace arguments
4983
- * @param {string} args.path - Path for the new workspace
4984
- * @param {string} [args.name] - Optional name
4985
- * @param {string} [args.changeId] - Change to check out
5429
+ * @param {Record<string, any>} args - Workspace arguments
4986
5430
  */
4987
5431
  async add(args) {
4988
5432
  await workspaces.load();
@@ -4993,7 +5437,7 @@ export async function createJJ(options) {
4993
5437
  args = { ...args, changeId: currentChangeId };
4994
5438
  }
4995
5439
 
4996
- const workspace = await workspaces.add(args);
5440
+ const workspace = /** @type {any} */ (await workspaces.add(/** @type {any} */ (args)));
4997
5441
 
4998
5442
  // If a change was specified, check it out in the new workspace
4999
5443
  if (args.changeId) {
@@ -5050,9 +5494,7 @@ export async function createJJ(options) {
5050
5494
  /**
5051
5495
  * Remove a workspace
5052
5496
  *
5053
- * @param {Object} args - Remove arguments
5054
- * @param {string} args.id - Workspace ID
5055
- * @param {boolean} [args.force=false] - Force removal
5497
+ * @param {Record<string, any>} args - Remove arguments
5056
5498
  */
5057
5499
  async remove(args) {
5058
5500
  if (!args || !args.id) {
@@ -5082,8 +5524,7 @@ export async function createJJ(options) {
5082
5524
  /**
5083
5525
  * Forget a workspace without removing files (matches `jj workspace forget`)
5084
5526
  *
5085
- * @param {Object} args - Forget arguments
5086
- * @param {string} args.id - Workspace ID
5527
+ * @param {Record<string, any>} args - Forget arguments
5087
5528
  */
5088
5529
  async forget(args) {
5089
5530
  if (!args || !args.id) {
@@ -5115,7 +5556,7 @@ export async function createJJ(options) {
5115
5556
  */
5116
5557
  async list() {
5117
5558
  await workspaces.load();
5118
- return workspaces.list();
5559
+ return /** @type {any[]} */ (workspaces.list());
5119
5560
  },
5120
5561
 
5121
5562
  /**
@@ -5125,7 +5566,7 @@ export async function createJJ(options) {
5125
5566
  */
5126
5567
  async get(id) {
5127
5568
  await workspaces.load();
5128
- const workspace = workspaces.get(id);
5569
+ const workspace = /** @type {any} */ (workspaces.get(id));
5129
5570
  if (!workspace) {
5130
5571
  throw new JJError('WORKSPACE_NOT_FOUND', `Workspace ${id} not found`);
5131
5572
  }
@@ -5135,9 +5576,7 @@ export async function createJJ(options) {
5135
5576
  /**
5136
5577
  * Rename a workspace (matches `jj workspace rename`)
5137
5578
  *
5138
- * @param {Object} args - Rename arguments
5139
- * @param {string} args.workspace - Current workspace name/id
5140
- * @param {string} args.newName - New name for the workspace
5579
+ * @param {Record<string, any>} args - Rename arguments
5141
5580
  * @returns {Promise<Object>} Updated workspace
5142
5581
  */
5143
5582
  async rename(args) {
@@ -5146,7 +5585,7 @@ export async function createJJ(options) {
5146
5585
  }
5147
5586
 
5148
5587
  await workspaces.load();
5149
- const workspace = workspaces.get(args.workspace);
5588
+ const workspace = /** @type {any} */ (workspaces.get(args.workspace));
5150
5589
  if (!workspace) {
5151
5590
  throw new JJError('WORKSPACE_NOT_FOUND', `Workspace ${args.workspace} not found`);
5152
5591
  }
@@ -5174,8 +5613,7 @@ export async function createJJ(options) {
5174
5613
  /**
5175
5614
  * Get workspace root directory (matches `jj workspace root`)
5176
5615
  *
5177
- * @param {Object} [args={}] - Arguments
5178
- * @param {string} [args.workspace] - Workspace name/id (defaults to current)
5616
+ * @param {Record<string, any>} [args={}] - Arguments
5179
5617
  * @returns {Promise<string>} Root directory path
5180
5618
  */
5181
5619
  async root(args = {}) {
@@ -5187,10 +5625,10 @@ export async function createJJ(options) {
5187
5625
  await workspaces.load();
5188
5626
 
5189
5627
  // Try to get by ID first, then by name
5190
- let workspace = workspaces.get(args.workspace);
5628
+ let workspace = /** @type {any} */ (workspaces.get(args.workspace));
5191
5629
  if (!workspace) {
5192
5630
  // Search by name
5193
- const allWorkspaces = workspaces.list();
5631
+ const allWorkspaces = /** @type {any[]} */ (workspaces.list());
5194
5632
  workspace = allWorkspaces.find((ws) => ws.name === args.workspace);
5195
5633
  }
5196
5634
 
@@ -5204,15 +5642,14 @@ export async function createJJ(options) {
5204
5642
  /**
5205
5643
  * Update stale workspaces (matches `jj workspace update-stale`)
5206
5644
  *
5207
- * @param {Object} [args={}] - Arguments
5208
- * @param {string} [args.workspace] - Specific workspace to update (defaults to all stale)
5645
+ * @param {Record<string, any>} [args={}] - Arguments
5209
5646
  * @returns {Promise<Object>} Update result
5210
5647
  */
5211
5648
  async updateStale(args = {}) {
5212
5649
  await workspaces.load();
5213
5650
  await graph.load();
5214
5651
 
5215
- const allWorkspaces = workspaces.list();
5652
+ const allWorkspaces = /** @type {any[]} */ (workspaces.list());
5216
5653
  const staleWorkspaces = [];
5217
5654
 
5218
5655
  for (const ws of allWorkspaces) {
@@ -5235,9 +5672,9 @@ export async function createJJ(options) {
5235
5672
 
5236
5673
  if (args.workspace && toUpdate.length === 0) {
5237
5674
  // Look up the workspace to get its name for a better error message
5238
- let workspace = workspaces.get(args.workspace);
5675
+ let workspace = /** @type {any} */ (workspaces.get(args.workspace));
5239
5676
  if (!workspace) {
5240
- const allWorkspaces = workspaces.list();
5677
+ const allWorkspaces = /** @type {any[]} */ (workspaces.list());
5241
5678
  workspace = allWorkspaces.find((ws) => ws.name === args.workspace);
5242
5679
  }
5243
5680
  const workspaceName = workspace ? workspace.name : args.workspace;
@@ -5305,7 +5742,7 @@ export async function createJJ(options) {
5305
5742
  * Queue a background operation
5306
5743
  *
5307
5744
  * @param {Function} operation - Async operation
5308
- * @param {Object} [opts] - Options
5745
+ * @param {Record<string, any>} [opts] - Options
5309
5746
  */
5310
5747
  async queue(operation, opts) {
5311
5748
  if (!jj.backgroundOps) {
@@ -5319,7 +5756,7 @@ export async function createJJ(options) {
5319
5756
  /**
5320
5757
  * List background operations
5321
5758
  *
5322
- * @param {Object} [opts] - Filter options
5759
+ * @param {Record<string, any>} [opts] - Filter options
5323
5760
  */
5324
5761
  listOperations(opts) {
5325
5762
  if (!jj.backgroundOps) {
@@ -5331,7 +5768,7 @@ export async function createJJ(options) {
5331
5768
  /**
5332
5769
  * Enable auto-snapshot on file changes
5333
5770
  *
5334
- * @param {Object} [opts] - Options
5771
+ * @param {Record<string, any>} [opts] - Options
5335
5772
  */
5336
5773
  async enableAutoSnapshot(opts) {
5337
5774
  if (!jj.backgroundOps) {
@@ -5373,7 +5810,7 @@ export async function createJJ(options) {
5373
5810
  /**
5374
5811
  * List all bookmarks
5375
5812
  *
5376
- * @returns {Promise<Array>} Array of bookmarks with name and changeId
5813
+ * @returns {Promise<Array<any>>} Array of bookmarks with name and changeId
5377
5814
  */
5378
5815
  async list() {
5379
5816
  await bookmarks.load();
@@ -5383,9 +5820,7 @@ export async function createJJ(options) {
5383
5820
  /**
5384
5821
  * Set/create a bookmark (matches `jj bookmark set`)
5385
5822
  *
5386
- * @param {Object} args - Bookmark arguments
5387
- * @param {string} args.name - Bookmark name
5388
- * @param {string} args.changeId - Change ID to point to
5823
+ * @param {Record<string, any>} args - Bookmark arguments
5389
5824
  * @returns {Promise<Object>} Bookmark info
5390
5825
  */
5391
5826
  async set(args) {
@@ -5403,7 +5838,8 @@ export async function createJJ(options) {
5403
5838
  if (!args || !args.name || !args.changeId) {
5404
5839
  throw new JJError('INVALID_ARGUMENT', 'Missing name or changeId', {
5405
5840
  args,
5406
- suggestion: 'Provide both: { name: "main", changeId: "abc123..." } (or use target/change/revision instead of changeId)',
5841
+ suggestion:
5842
+ 'Provide both: { name: "main", changeId: "abc123..." } (or use target/change/revision instead of changeId)',
5407
5843
  });
5408
5844
  }
5409
5845
 
@@ -5433,9 +5869,7 @@ export async function createJJ(options) {
5433
5869
  *
5434
5870
  * Unlike `set()`, this will fail if the bookmark already exists.
5435
5871
  *
5436
- * @param {Object} args - Bookmark arguments
5437
- * @param {string} args.name - Bookmark name
5438
- * @param {string} [args.changeId] - Change ID to point to (default: working copy)
5872
+ * @param {Record<string, any>} args - Bookmark arguments
5439
5873
  * @returns {Promise<Object>} Bookmark info
5440
5874
  */
5441
5875
  async create(args) {
@@ -5452,7 +5886,8 @@ export async function createJJ(options) {
5452
5886
 
5453
5887
  if (!args || !args.name) {
5454
5888
  throw new JJError('INVALID_ARGUMENT', 'Missing name argument', {
5455
- suggestion: 'Provide: { name: "main", changeId: "abc123..." } or { name: "main" } for working copy',
5889
+ suggestion:
5890
+ 'Provide: { name: "main", changeId: "abc123..." } or { name: "main" } for working copy',
5456
5891
  });
5457
5892
  }
5458
5893
 
@@ -5469,7 +5904,8 @@ export async function createJJ(options) {
5469
5904
  const existingBookmark = await bookmarks.get(args.name);
5470
5905
  if (existingBookmark) {
5471
5906
  throw new JJError('BOOKMARK_EXISTS', `Bookmark ${args.name} already exists`, {
5472
- suggestion: 'Use bookmark.set() to update an existing bookmark, or bookmark.move() to move it',
5907
+ suggestion:
5908
+ 'Use bookmark.set() to update an existing bookmark, or bookmark.move() to move it',
5473
5909
  });
5474
5910
  }
5475
5911
 
@@ -5496,9 +5932,7 @@ export async function createJJ(options) {
5496
5932
  /**
5497
5933
  * Move a bookmark to a different change (matches `jj bookmark move`)
5498
5934
  *
5499
- * @param {Object} args - Bookmark arguments
5500
- * @param {string} args.name - Bookmark name
5501
- * @param {string} args.to - Target change ID
5935
+ * @param {Record<string, any>} args - Bookmark arguments
5502
5936
  * @returns {Promise<Object>} Move result
5503
5937
  */
5504
5938
  async move(args) {
@@ -5518,7 +5952,8 @@ export async function createJJ(options) {
5518
5952
  if (!args || !args.name || !args.to) {
5519
5953
  throw new JJError('INVALID_ARGUMENT', 'Missing name or to', {
5520
5954
  args,
5521
- suggestion: 'Provide both: { name: "main", to: "abc123..." } (or use target/changeId/change/revision instead of to)',
5955
+ suggestion:
5956
+ 'Provide both: { name: "main", to: "abc123..." } (or use target/changeId/change/revision instead of to)',
5522
5957
  });
5523
5958
  }
5524
5959
 
@@ -5547,11 +5982,68 @@ export async function createJJ(options) {
5547
5982
  return { name: args.name, from: bookmarkChangeId, to: args.to };
5548
5983
  },
5549
5984
 
5985
+ /**
5986
+ * Advance a bookmark to a descendant change (matches `jj bookmark advance`,
5987
+ * jj v0.39). Unlike move(), advance() only moves the bookmark *forward*
5988
+ * along the graph — the target must be a descendant of the bookmark's
5989
+ * current position (defaults to the working copy).
5990
+ *
5991
+ * @param {Record<string, any>} args - Arguments
5992
+ * @returns {Promise<Object>} { name, from, to }
5993
+ */
5994
+ async advance(args) {
5995
+ if (args) {
5996
+ const alias = args.to || args.target || args.changeId || args.change || args.revision;
5997
+ if (alias) args = { ...args, to: alias };
5998
+ }
5999
+ if (!args || !args.name) {
6000
+ throw new JJError('INVALID_ARGUMENT', 'Missing bookmark name', {
6001
+ suggestion: 'Provide the bookmark name: { name: "main", to: "abc123..." }',
6002
+ });
6003
+ }
6004
+
6005
+ await bookmarks.load();
6006
+ await graph.load();
6007
+
6008
+ const from = await bookmarks.get(args.name);
6009
+ if (!from) {
6010
+ throw new JJError('NOT_FOUND', `Bookmark ${args.name} not found`);
6011
+ }
6012
+
6013
+ const to = args.to || workingCopy.getCurrentChangeId();
6014
+
6015
+ // The target must be a descendant of (or equal to) the current position.
6016
+ const descendants = new Set(await revset.getDescendants(from));
6017
+ if (to !== from && !descendants.has(to)) {
6018
+ throw new JJError(
6019
+ 'BOOKMARK_NOT_ADVANCEABLE',
6020
+ `Cannot advance bookmark ${args.name}: ${to.slice(0, 8)} is not a descendant of ${from.slice(0, 8)}`,
6021
+ { suggestion: 'Use bookmark.move() to move a bookmark backward or sideways' }
6022
+ );
6023
+ }
6024
+
6025
+ await bookmarks.move(args.name, to);
6026
+
6027
+ await oplog.recordOperation({
6028
+ timestamp: new Date().toISOString(),
6029
+ user: await getUserOplogInfo(),
6030
+ description: `bookmark advance ${args.name}`,
6031
+ parents: [],
6032
+ view: {
6033
+ bookmarks: { [args.name]: to },
6034
+ remoteBookmarks: {},
6035
+ heads: [],
6036
+ workingCopy: workingCopy.getCurrentChangeId(),
6037
+ },
6038
+ });
6039
+
6040
+ return { name: args.name, from, to };
6041
+ },
6042
+
5550
6043
  /**
5551
6044
  * Delete a bookmark (matches `jj bookmark delete`)
5552
6045
  *
5553
- * @param {Object} args - Bookmark arguments
5554
- * @param {string} args.name - Bookmark name to delete
6046
+ * @param {Record<string, any>} args - Bookmark arguments
5555
6047
  * @returns {Promise<Object>} Deletion result
5556
6048
  */
5557
6049
  async delete(args) {
@@ -5562,7 +6054,7 @@ export async function createJJ(options) {
5562
6054
  }
5563
6055
 
5564
6056
  await bookmarks.load();
5565
- const bookmark = bookmarks.get(args.name);
6057
+ const bookmark = await bookmarks.get(args.name);
5566
6058
  if (!bookmark) {
5567
6059
  throw new JJError('NOT_FOUND', `Bookmark ${args.name} not found`);
5568
6060
  }
@@ -5590,9 +6082,7 @@ export async function createJJ(options) {
5590
6082
  /**
5591
6083
  * Rename a bookmark (matches `jj bookmark rename`)
5592
6084
  *
5593
- * @param {Object} args - Bookmark arguments
5594
- * @param {string} args.oldName - Current bookmark name
5595
- * @param {string} args.newName - New bookmark name
6085
+ * @param {Record<string, any>} args - Bookmark arguments
5596
6086
  * @returns {Promise<Object>} Rename result
5597
6087
  */
5598
6088
  async rename(args) {
@@ -5633,9 +6123,7 @@ export async function createJJ(options) {
5633
6123
  /**
5634
6124
  * Track a remote bookmark (matches `jj bookmark track`)
5635
6125
  *
5636
- * @param {Object} args - Bookmark arguments
5637
- * @param {string} args.name - Bookmark name to track
5638
- * @param {string} [args.remote='origin'] - Remote name
6126
+ * @param {Record<string, any>} args - Bookmark arguments
5639
6127
  * @returns {Promise<Object>} Track result
5640
6128
  */
5641
6129
  async track(args) {
@@ -5650,6 +6138,7 @@ export async function createJJ(options) {
5650
6138
 
5651
6139
  // Store tracking info in bookmarks metadata
5652
6140
  await bookmarks.load();
6141
+ /** @type {Record<string, any>} */
5653
6142
  const tracking = bookmarks.tracking || {};
5654
6143
  tracking[args.name] = { remote, remoteName: args.name };
5655
6144
  bookmarks.tracking = tracking;
@@ -5675,8 +6164,7 @@ export async function createJJ(options) {
5675
6164
  /**
5676
6165
  * Untrack a remote bookmark (matches `jj bookmark untrack`)
5677
6166
  *
5678
- * @param {Object} args - Bookmark arguments
5679
- * @param {string} args.name - Bookmark name to untrack
6167
+ * @param {Record<string, any>} args - Bookmark arguments
5680
6168
  * @returns {Promise<Object>} Untrack result
5681
6169
  */
5682
6170
  async untrack(args) {
@@ -5688,6 +6176,7 @@ export async function createJJ(options) {
5688
6176
 
5689
6177
  // Remove tracking info
5690
6178
  await bookmarks.load();
6179
+ /** @type {Record<string, any>} */
5691
6180
  const tracking = bookmarks.tracking || {};
5692
6181
  const wasTracking = tracking[args.name];
5693
6182
  delete tracking[args.name];
@@ -5714,9 +6203,7 @@ export async function createJJ(options) {
5714
6203
  /**
5715
6204
  * Forget a remote bookmark (matches `jj bookmark forget`)
5716
6205
  *
5717
- * @param {Object} args - Bookmark arguments
5718
- * @param {string} args.name - Bookmark name to forget
5719
- * @param {string} [args.remote='origin'] - Remote name
6206
+ * @param {Record<string, any>} args - Bookmark arguments
5720
6207
  * @returns {Promise<Object>} Forget result
5721
6208
  */
5722
6209
  async forget(args) {
@@ -5730,6 +6217,7 @@ export async function createJJ(options) {
5730
6217
 
5731
6218
  // Untrack and remove local reference
5732
6219
  await bookmarks.load();
6220
+ /** @type {Record<string, any>} */
5733
6221
  const tracking = bookmarks.tracking || {};
5734
6222
  delete tracking[args.name];
5735
6223
  bookmarks.tracking = tracking;
@@ -5776,16 +6264,15 @@ export async function createJJ(options) {
5776
6264
  * Tags are immutable references that cannot be moved once created.
5777
6265
  * Unlike bookmarks, tags are permanent markers typically used for releases.
5778
6266
  *
5779
- * @param {Object} args - Tag arguments
5780
- * @param {string} args.name - Tag name
5781
- * @param {string} [args.changeId] - Change ID to tag (default: working copy)
6267
+ * @param {Record<string, any>} args - Tag arguments
5782
6268
  * @returns {Promise<{name: string, changeId: string}>} Created tag
5783
6269
  * @throws {JJError} If tag already exists or name is invalid
5784
6270
  */
5785
6271
  async create(args) {
5786
6272
  if (!args || args.name === undefined || args.name === null) {
5787
6273
  throw new JJError('INVALID_ARGUMENT', 'Missing name argument', {
5788
- suggestion: 'Provide: { name: "v1.0.0", changeId: "abc123..." } or { name: "v1.0.0" } for working copy',
6274
+ suggestion:
6275
+ 'Provide: { name: "v1.0.0", changeId: "abc123..." } or { name: "v1.0.0" } for working copy',
5789
6276
  });
5790
6277
  }
5791
6278
 
@@ -5808,6 +6295,10 @@ export async function createJJ(options) {
5808
6295
  view: {
5809
6296
  bookmarks: {},
5810
6297
  remoteBookmarks: {},
6298
+ // Tag delta for this operation (mirrors how `bookmarks` records
6299
+ // its delta above), so operations.revert() can diff it — see
6300
+ // issue #12.
6301
+ tags: { [args.name]: changeId },
5811
6302
  heads: [],
5812
6303
  workingCopy: workingCopy.getCurrentChangeId(),
5813
6304
  },
@@ -5817,11 +6308,57 @@ export async function createJJ(options) {
5817
6308
  },
5818
6309
 
5819
6310
  /**
5820
- * List all tags
6311
+ * Create or update (move) a tag (matches `jj tag set`, jj v0.35).
6312
+ *
6313
+ * Unlike create(), set() is an upsert: it will point an existing tag at a
6314
+ * new change instead of erroring.
6315
+ *
6316
+ * @param {Record<string, any>} args - Tag arguments
6317
+ * @returns {Promise<{name: string, changeId: string, updated: boolean}>}
6318
+ */
6319
+ async set(args) {
6320
+ if (!args || args.name === undefined || args.name === null) {
6321
+ throw new JJError('INVALID_ARGUMENT', 'Missing name argument', {
6322
+ suggestion: 'Provide: { name: "v1.0.0", changeId: "abc123..." }',
6323
+ });
6324
+ }
6325
+
6326
+ let changeId = args.changeId || args.change || args.revision;
6327
+ if (!changeId) {
6328
+ await workingCopy.load();
6329
+ changeId = workingCopy.getCurrentChangeId();
6330
+ }
6331
+
6332
+ await tags.load();
6333
+ const existed = await tags.exists(args.name);
6334
+ if (existed) {
6335
+ await tags.delete(args.name);
6336
+ }
6337
+ const result = await tags.create(args.name, changeId);
6338
+
6339
+ await oplog.recordOperation({
6340
+ timestamp: new Date().toISOString(),
6341
+ user: await getUserOplogInfo(),
6342
+ description: `tag set ${args.name}`,
6343
+ parents: [],
6344
+ view: {
6345
+ bookmarks: {},
6346
+ remoteBookmarks: {},
6347
+ tags: { [args.name]: changeId },
6348
+ heads: [],
6349
+ workingCopy: workingCopy.getCurrentChangeId(),
6350
+ },
6351
+ });
6352
+
6353
+ return { ...result, updated: existed };
6354
+ },
6355
+
6356
+ /**
6357
+ * List all tags. Each entry also carries a `tracking: { remote, ref }`
6358
+ * field when the tag is tracking a remote (jj v0.44 `jj tag track`).
5821
6359
  *
5822
- * @param {Object} [args] - Optional filter arguments
5823
- * @param {string} [args.pattern] - Optional glob pattern (e.g., "v1*")
5824
- * @returns {Promise<Array<{name: string, changeId: string}>>} List of tags
6360
+ * @param {Record<string, any>} [args] - Optional filter arguments
6361
+ * @returns {Promise<Array<Record<string, any>>>} List of tags
5825
6362
  */
5826
6363
  async list(args) {
5827
6364
  const pattern = args?.pattern;
@@ -5831,8 +6368,7 @@ export async function createJJ(options) {
5831
6368
  /**
5832
6369
  * Delete a tag
5833
6370
  *
5834
- * @param {Object} args - Tag arguments
5835
- * @param {string} args.name - Tag name to delete
6371
+ * @param {Record<string, any>} args - Tag arguments
5836
6372
  * @returns {Promise<{deleted: string}>} Deletion result
5837
6373
  * @throws {JJError} If tag doesn't exist
5838
6374
  */
@@ -5854,6 +6390,7 @@ export async function createJJ(options) {
5854
6390
  view: {
5855
6391
  bookmarks: {},
5856
6392
  remoteBookmarks: {},
6393
+ tags: {},
5857
6394
  heads: [],
5858
6395
  workingCopy: workingCopy.getCurrentChangeId(),
5859
6396
  },
@@ -5861,6 +6398,94 @@ export async function createJJ(options) {
5861
6398
 
5862
6399
  return { deleted: args.name };
5863
6400
  },
6401
+
6402
+ /**
6403
+ * Associate a local tag with a remote tag so it is updated on fetch
6404
+ * (matches `jj tag track`, new in jj v0.44).
6405
+ *
6406
+ * jj v0.44 made tags remote-trackable just like bookmarks: fetched
6407
+ * tags are tracked by default, and `jj tag track`/`untrack` let you
6408
+ * opt individual tags in or out by hand. isomorphic-jj doesn't (yet)
6409
+ * auto-import remote tags during `git.fetch()` — see CHANGELOG — so
6410
+ * this records tracking intent/state the same way `bookmark.track()`
6411
+ * does, without changing fetch's own behavior.
6412
+ *
6413
+ * @param {Record<string, any>} args - Tag arguments: `{ name, remote }`
6414
+ * (`name` is the local tag name to track; `remote` defaults to `'origin'`)
6415
+ * @returns {Promise<{name: string, remote: string, tracking: boolean}>}
6416
+ */
6417
+ async track(args) {
6418
+ if (!args || !args.name) {
6419
+ throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
6420
+ suggestion: 'Provide tag name: { name: "v1.0.0", remote: "origin" }',
6421
+ });
6422
+ }
6423
+
6424
+ const remote = args.remote || 'origin';
6425
+ const remoteTagName = `${remote}/${args.name}`;
6426
+
6427
+ const tagMap = await tags.load();
6428
+ /** @type {Record<string, any>} */
6429
+ const tracking = tags.tracking || {};
6430
+ tracking[args.name] = { remote, remoteName: args.name };
6431
+ tags.tracking = tracking;
6432
+ await tags.save(tagMap);
6433
+
6434
+ // Record operation
6435
+ await oplog.recordOperation({
6436
+ timestamp: new Date().toISOString(),
6437
+ user: await getUserOplogInfo(),
6438
+ description: `tag track ${args.name} from ${remote}`,
6439
+ parents: [],
6440
+ view: {
6441
+ bookmarks: {},
6442
+ remoteBookmarks: { [remoteTagName]: true },
6443
+ heads: [],
6444
+ workingCopy: workingCopy.getCurrentChangeId(),
6445
+ },
6446
+ });
6447
+
6448
+ return { name: args.name, remote, tracking: true };
6449
+ },
6450
+
6451
+ /**
6452
+ * Stop tracking a remote tag (matches `jj tag untrack`, new in jj v0.44).
6453
+ *
6454
+ * @param {Record<string, any>} args - Tag arguments: `{ name }`, the
6455
+ * local tag name to stop tracking
6456
+ * @returns {Promise<{name: string, tracking: boolean, wasTracking: boolean}>}
6457
+ */
6458
+ async untrack(args) {
6459
+ if (!args || !args.name) {
6460
+ throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
6461
+ suggestion: 'Provide tag name: { name: "v1.0.0" }',
6462
+ });
6463
+ }
6464
+
6465
+ const tagMap = await tags.load();
6466
+ /** @type {Record<string, any>} */
6467
+ const tracking = tags.tracking || {};
6468
+ const wasTracking = tracking[args.name];
6469
+ delete tracking[args.name];
6470
+ tags.tracking = tracking;
6471
+ await tags.save(tagMap);
6472
+
6473
+ // Record operation
6474
+ await oplog.recordOperation({
6475
+ timestamp: new Date().toISOString(),
6476
+ user: await getUserOplogInfo(),
6477
+ description: `tag untrack ${args.name}`,
6478
+ parents: [],
6479
+ view: {
6480
+ bookmarks: {},
6481
+ remoteBookmarks: {},
6482
+ heads: [],
6483
+ workingCopy: workingCopy.getCurrentChangeId(),
6484
+ },
6485
+ });
6486
+
6487
+ return { name: args.name, tracking: false, wasTracking: !!wasTracking };
6488
+ },
5864
6489
  },
5865
6490
 
5866
6491
  /**
@@ -5870,7 +6495,7 @@ export async function createJJ(options) {
5870
6495
  /**
5871
6496
  * Fetch from remote (alias to jj.git.fetch for convenience)
5872
6497
  *
5873
- * @param {Object} args - Fetch arguments
6498
+ * @param {Record<string, any>} args - Fetch arguments
5874
6499
  * @returns {Promise<Object>} Fetch result
5875
6500
  */
5876
6501
  async fetch(args) {
@@ -5880,7 +6505,7 @@ export async function createJJ(options) {
5880
6505
  /**
5881
6506
  * Push to remote (alias to jj.git.push for convenience)
5882
6507
  *
5883
- * @param {Object} args - Push arguments
6508
+ * @param {Record<string, any>} args - Push arguments
5884
6509
  * @returns {Promise<Object>} Push result
5885
6510
  */
5886
6511
  async push(args) {
@@ -5890,7 +6515,7 @@ export async function createJJ(options) {
5890
6515
  /**
5891
6516
  * Add a remote (alias to jj.git.remote.add for convenience)
5892
6517
  *
5893
- * @param {Object} args - Remote arguments
6518
+ * @param {Record<string, any>} args - Remote arguments
5894
6519
  * @returns {Promise<Object>} Added remote info
5895
6520
  */
5896
6521
  async add(args) {
@@ -5905,8 +6530,7 @@ export async function createJJ(options) {
5905
6530
  /**
5906
6531
  * Get a configuration value (matches `jj config get`)
5907
6532
  *
5908
- * @param {Object} args - Config arguments
5909
- * @param {string} args.name - Config key (e.g., 'user.name')
6533
+ * @param {Record<string, any>} args - Config arguments
5910
6534
  * @returns {Promise<any>} Config value
5911
6535
  */
5912
6536
  async get(args) {
@@ -5931,9 +6555,7 @@ export async function createJJ(options) {
5931
6555
  /**
5932
6556
  * Set a configuration value (matches `jj config set`)
5933
6557
  *
5934
- * @param {Object} args - Config arguments
5935
- * @param {string} args.name - Config key (e.g., 'user.name')
5936
- * @param {any} args.value - Config value
6558
+ * @param {Record<string, any>} args - Config arguments
5937
6559
  * @returns {Promise<Object>} Set result
5938
6560
  */
5939
6561
  async set(args) {
@@ -5980,9 +6602,7 @@ export async function createJJ(options) {
5980
6602
  * v0.35.0: Loads workspace-config.json if present
5981
6603
  * v0.36.0: Accepts optional config object for programmatic configuration
5982
6604
  *
5983
- * @param {Object} [opts] - Optional config options
5984
- * @param {Object} [opts.workspace] - Workspace-specific config to merge (highest priority)
5985
- * @param {Object} [opts.override] - Config object to merge over loaded config
6605
+ * @param {Record<string, any>} [opts] - Optional config options
5986
6606
  * @returns {Promise<void>}
5987
6607
  *
5988
6608
  * @example
@@ -6077,8 +6697,7 @@ export async function createJJ(options) {
6077
6697
  /**
6078
6698
  * Set sparse patterns (replaces all existing patterns)
6079
6699
  *
6080
- * @param {Object} args - Arguments
6081
- * @param {string[]} args.patterns - Array of glob patterns
6700
+ * @param {Record<string, any>} args - Arguments
6082
6701
  * @returns {Promise<{patterns: string[]}>} Result with current patterns
6083
6702
  */
6084
6703
  async set(args) {
@@ -6112,8 +6731,7 @@ export async function createJJ(options) {
6112
6731
  /**
6113
6732
  * Add patterns to sparse checkout
6114
6733
  *
6115
- * @param {Object} args - Arguments
6116
- * @param {string[]} args.patterns - Array of glob patterns to add
6734
+ * @param {Record<string, any>} args - Arguments
6117
6735
  * @returns {Promise<{patterns: string[]}>} Result with current patterns
6118
6736
  */
6119
6737
  async add(args) {
@@ -6157,8 +6775,7 @@ export async function createJJ(options) {
6157
6775
  /**
6158
6776
  * Remove patterns from sparse checkout
6159
6777
  *
6160
- * @param {Object} args - Arguments
6161
- * @param {string[]} args.patterns - Array of glob patterns to remove
6778
+ * @param {Record<string, any>} args - Arguments
6162
6779
  * @returns {Promise<{patterns: string[]}>} Result with current patterns
6163
6780
  */
6164
6781
  async remove(args) {
@@ -6172,7 +6789,7 @@ export async function createJJ(options) {
6172
6789
  const currentPatterns = workingCopy.getSparsePatterns();
6173
6790
 
6174
6791
  // Remove specified patterns
6175
- const updatedPatterns = currentPatterns.filter(p => !args.patterns.includes(p));
6792
+ const updatedPatterns = currentPatterns.filter((p) => !args.patterns.includes(p));
6176
6793
 
6177
6794
  await workingCopy.setSparsePatterns(updatedPatterns);
6178
6795
  await workingCopy.save();
@@ -6241,9 +6858,7 @@ export async function createJJ(options) {
6241
6858
  /**
6242
6859
  * Start bisect session
6243
6860
  *
6244
- * @param {Object} args - Bisect arguments
6245
- * @param {string} args.good - Known good change ID
6246
- * @param {string} args.bad - Known bad change ID
6861
+ * @param {Record<string, any>} args - Bisect arguments
6247
6862
  * @returns {Promise<Object>} Bisect state
6248
6863
  */
6249
6864
  async start(args) {
@@ -6256,7 +6871,7 @@ export async function createJJ(options) {
6256
6871
  await graph.load();
6257
6872
 
6258
6873
  // Check if bisect is already active
6259
- const existingState = await storage.read('bisect/state.json');
6874
+ const existingState = /** @type {any} */ (await storage.read('bisect/state.json'));
6260
6875
  if (existingState && existingState.active) {
6261
6876
  throw new JJError('BISECT_ALREADY_ACTIVE', 'Bisect session already in progress', {
6262
6877
  suggestion: 'Use bisect.reset() to end current session first',
@@ -6279,9 +6894,8 @@ export async function createJJ(options) {
6279
6894
 
6280
6895
  // Pick the middle change
6281
6896
  // If no candidates between good and bad, test bad itself
6282
- const current = candidates.length > 0
6283
- ? candidates[Math.floor(candidates.length / 2)]
6284
- : args.bad;
6897
+ const current =
6898
+ candidates.length > 0 ? candidates[Math.floor(candidates.length / 2)] : args.bad;
6285
6899
 
6286
6900
  const bisectState = {
6287
6901
  active: true,
@@ -6304,7 +6918,7 @@ export async function createJJ(options) {
6304
6918
  * @returns {Promise<Object>} Updated bisect state
6305
6919
  */
6306
6920
  async good() {
6307
- const bisectState = await storage.read('bisect/state.json');
6921
+ const bisectState = /** @type {any} */ (await storage.read('bisect/state.json'));
6308
6922
 
6309
6923
  if (!bisectState || !bisectState.active) {
6310
6924
  throw new JJError('BISECT_NOT_ACTIVE', 'No active bisect session');
@@ -6329,7 +6943,7 @@ export async function createJJ(options) {
6329
6943
  * @returns {Promise<Object>} Updated bisect state
6330
6944
  */
6331
6945
  async bad() {
6332
- const bisectState = await storage.read('bisect/state.json');
6946
+ const bisectState = /** @type {any} */ (await storage.read('bisect/state.json'));
6333
6947
 
6334
6948
  if (!bisectState || !bisectState.active) {
6335
6949
  throw new JJError('BISECT_NOT_ACTIVE', 'No active bisect session');
@@ -6354,7 +6968,7 @@ export async function createJJ(options) {
6354
6968
  * @returns {Promise<Object>} Updated bisect state
6355
6969
  */
6356
6970
  async skip() {
6357
- const bisectState = await storage.read('bisect/state.json');
6971
+ const bisectState = /** @type {any} */ (await storage.read('bisect/state.json'));
6358
6972
 
6359
6973
  if (!bisectState || !bisectState.active) {
6360
6974
  throw new JJError('BISECT_NOT_ACTIVE', 'No active bisect session');
@@ -6362,7 +6976,9 @@ export async function createJJ(options) {
6362
6976
 
6363
6977
  // Remove current from candidates
6364
6978
  if (bisectState.current) {
6365
- bisectState.candidates = bisectState.candidates.filter(c => c !== bisectState.current);
6979
+ bisectState.candidates = bisectState.candidates.filter(
6980
+ (/** @type {any} */ c) => c !== bisectState.current
6981
+ );
6366
6982
  }
6367
6983
 
6368
6984
  // Pick next candidate
@@ -6389,7 +7005,7 @@ export async function createJJ(options) {
6389
7005
  * @returns {Promise<Object>} Bisect state
6390
7006
  */
6391
7007
  async status() {
6392
- const bisectState = await storage.read('bisect/state.json');
7008
+ const bisectState = /** @type {any} */ (await storage.read('bisect/state.json'));
6393
7009
  return bisectState || { active: false };
6394
7010
  },
6395
7011
 
@@ -6406,7 +7022,7 @@ export async function createJJ(options) {
6406
7022
 
6407
7023
  // Find all ancestors of bad that are descendants of good
6408
7024
  const badAncestors = graph.getAncestors(badId);
6409
- const goodAncestors = graph.getAncestors(goodId);
7025
+ const _goodAncestors = graph.getAncestors(goodId);
6410
7026
 
6411
7027
  // Candidates are changes that are:
6412
7028
  // - Ancestors of bad (or bad itself)
@@ -6436,7 +7052,7 @@ export async function createJJ(options) {
6436
7052
  * Update bisect state after marking good/bad/skip
6437
7053
  *
6438
7054
  * @private
6439
- * @param {Object} bisectState - Current bisect state
7055
+ * @param {any} bisectState - Current bisect state
6440
7056
  */
6441
7057
  async _updateBisectState(bisectState) {
6442
7058
  await graph.load();
@@ -6482,7 +7098,8 @@ export async function createJJ(options) {
6482
7098
  }
6483
7099
 
6484
7100
  bisectState.candidates = newCandidates;
6485
- bisectState.remaining = newCandidates.length > 0 ? Math.ceil(Math.log2(newCandidates.length + 1)) : 0;
7101
+ bisectState.remaining =
7102
+ newCandidates.length > 0 ? Math.ceil(Math.log2(newCandidates.length + 1)) : 0;
6486
7103
 
6487
7104
  // Pick the middle candidate
6488
7105
  if (newCandidates.length > 0) {
@@ -6495,7 +7112,7 @@ export async function createJJ(options) {
6495
7112
  // The first bad change is the one in the bad set that has no bad ancestors
6496
7113
  for (const badId of bisectState.bad) {
6497
7114
  const badAncestors = graph.getAncestors(badId);
6498
- const hasChildBad = bisectState.bad.some(otherId => {
7115
+ const hasChildBad = bisectState.bad.some((/** @type {any} */ otherId) => {
6499
7116
  if (otherId === badId) return false;
6500
7117
  return badAncestors.includes(otherId);
6501
7118
  });
@@ -6512,13 +7129,16 @@ export async function createJJ(options) {
6512
7129
  /**
6513
7130
  * Show file differences between revisions (matches `jj diff`)
6514
7131
  *
6515
- * @param {Object} [args={}] - Diff arguments
6516
- * @param {string} [args.from] - Source revision (defaults to parent of working copy)
6517
- * @param {string} [args.to] - Target revision (defaults to working copy)
6518
- * @param {string[]} [args.paths] - Specific paths to diff
7132
+ * @param {Record<string, any>} [args={}] - Diff arguments
6519
7133
  * @returns {Promise<Object>} Diff result with changed files
6520
7134
  */
6521
7135
  async diff(args = {}) {
7136
+ // v1.6: snapshot the working copy first so diffs against @ reflect
7137
+ // out-of-band edits (only when diffing the working copy, i.e. no explicit `to`).
7138
+ if (!args.to) {
7139
+ await autoSnapshotWorkingCopy();
7140
+ }
7141
+
6522
7142
  await graph.load();
6523
7143
  await workingCopy.load();
6524
7144
 
@@ -6574,8 +7194,7 @@ export async function createJJ(options) {
6574
7194
  /**
6575
7195
  * Move working copy to next child revision (matches `jj next`)
6576
7196
  *
6577
- * @param {Object} [args={}] - Next arguments
6578
- * @param {number} [args.offset=1] - Number of generations to move forward
7197
+ * @param {Record<string, any>} [args={}] - Next arguments
6579
7198
  * @returns {Promise<Object>} Updated working copy info
6580
7199
  */
6581
7200
  async next(args = {}) {
@@ -6593,7 +7212,9 @@ export async function createJJ(options) {
6593
7212
  // Find children
6594
7213
  await graph.load();
6595
7214
  const allChanges = await graph.getAllChanges();
6596
- const children = allChanges.filter(c => c.parents && c.parents.includes(currentChangeId));
7215
+ const children = allChanges.filter(
7216
+ (/** @type {any} */ c) => c.parents && c.parents.includes(currentChangeId)
7217
+ );
6597
7218
 
6598
7219
  if (children.length === 0) {
6599
7220
  throw new JJError('NO_CHILDREN', 'No child revisions found');
@@ -6604,7 +7225,9 @@ export async function createJJ(options) {
6604
7225
 
6605
7226
  // Move forward 'offset' times
6606
7227
  for (let i = 1; i < offset; i++) {
6607
- const nextChildren = allChanges.filter(c => c.parents && c.parents.includes(targetChange.changeId));
7228
+ const nextChildren = allChanges.filter(
7229
+ (/** @type {any} */ c) => c.parents && c.parents.includes(targetChange.changeId)
7230
+ );
6608
7231
  if (nextChildren.length === 0) {
6609
7232
  throw new JJError('INSUFFICIENT_CHILDREN', `Cannot move forward ${offset} generations`);
6610
7233
  }
@@ -6625,8 +7248,7 @@ export async function createJJ(options) {
6625
7248
  /**
6626
7249
  * Move working copy to previous parent revision (matches `jj prev`)
6627
7250
  *
6628
- * @param {Object} [args={}] - Prev arguments
6629
- * @param {number} [args.offset=1] - Number of generations to move backward
7251
+ * @param {Record<string, any>} [args={}] - Prev arguments
6630
7252
  * @returns {Promise<Object>} Updated working copy info
6631
7253
  */
6632
7254
  async prev(args = {}) {
@@ -6665,9 +7287,7 @@ export async function createJJ(options) {
6665
7287
  /**
6666
7288
  * Create copies of changes (matches `jj duplicate`)
6667
7289
  *
6668
- * @param {Object} [args={}] - Duplicate arguments
6669
- * @param {string[]} [args.changes] - Change IDs to duplicate (defaults to working copy)
6670
- * @param {string} [args.destination] - Where to place duplicates
7290
+ * @param {Record<string, any>} [args={}] - Duplicate arguments
6671
7291
  * @returns {Promise<Object>} Duplication result with new change IDs
6672
7292
  */
6673
7293
  async duplicate(args = {}) {
@@ -6676,8 +7296,8 @@ export async function createJJ(options) {
6676
7296
  await userConfig.load();
6677
7297
 
6678
7298
  // Support both changeId (singular) and changes (plural)
6679
- const changesToDup = args.changes ||
6680
- (args.changeId ? [args.changeId] : [workingCopy.getCurrentChangeId()]);
7299
+ const changesToDup =
7300
+ args.changes || (args.changeId ? [args.changeId] : [workingCopy.getCurrentChangeId()]);
6681
7301
  const duplicatedChanges = [];
6682
7302
 
6683
7303
  for (const changeId of changesToDup) {
@@ -6688,7 +7308,7 @@ export async function createJJ(options) {
6688
7308
 
6689
7309
  // Create new change as a copy
6690
7310
  const newChangeId = generateChangeId();
6691
- const user = userConfig.getUser();
7311
+ const _user = /** @type {any} */ (userConfig.getUser());
6692
7312
  const newChange = {
6693
7313
  ...originalChange,
6694
7314
  changeId: newChangeId,
@@ -6713,7 +7333,7 @@ export async function createJJ(options) {
6713
7333
  view: {
6714
7334
  bookmarks: {},
6715
7335
  remoteBookmarks: {},
6716
- heads: duplicatedChanges.map(d => d.duplicate),
7336
+ heads: duplicatedChanges.map((d) => d.duplicate),
6717
7337
  workingCopy: workingCopy.getCurrentChangeId(),
6718
7338
  },
6719
7339
  });
@@ -6721,17 +7341,14 @@ export async function createJJ(options) {
6721
7341
  // Return both formats for compatibility
6722
7342
  return {
6723
7343
  duplicated: duplicatedChanges,
6724
- changeIds: duplicatedChanges.map(d => d.duplicate)
7344
+ changeIds: duplicatedChanges.map((d) => d.duplicate),
6725
7345
  };
6726
7346
  },
6727
7347
 
6728
7348
  /**
6729
7349
  * Restore paths from another revision (matches `jj restore`)
6730
7350
  *
6731
- * @param {Object} args - Restore arguments
6732
- * @param {string} [args.from] - Source revision to restore from
6733
- * @param {string} [args.to] - Target revision to restore to (defaults to working copy)
6734
- * @param {string[]} [args.paths] - Specific paths to restore (defaults to all)
7351
+ * @param {Record<string, any>} args - Restore arguments
6735
7352
  * @returns {Promise<Object>} Restore result
6736
7353
  */
6737
7354
  async restore(args = {}) {
@@ -6793,9 +7410,7 @@ export async function createJJ(options) {
6793
7410
  * This operation takes multiple changes and gives them the same parent,
6794
7411
  * making them siblings in the change graph instead of ancestors/descendants.
6795
7412
  *
6796
- * @param {Object} args - Arguments
6797
- * @param {Array<string>} args.changes - Change IDs to parallelize
6798
- * @param {string} [args.parent] - Optional parent to use (defaults to common ancestor)
7413
+ * @param {Record<string, any>} args - Arguments
6799
7414
  * @returns {Promise<Object>} Operation result with parallelized changes
6800
7415
  *
6801
7416
  * @example
@@ -6912,7 +7527,7 @@ export async function createJJ(options) {
6912
7527
  parent: parentId,
6913
7528
  };
6914
7529
  },
6915
- };
7530
+ });
6916
7531
 
6917
7532
  // ============================================================================
6918
7533
  // UNSUPPORTED FEATURE STUBS
@@ -6925,17 +7540,14 @@ export async function createJJ(options) {
6925
7540
  * @status UNSUPPORTED - Requires interactive diff editor
6926
7541
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
6927
7542
  */
6928
- jj.diffedit = async function (args) {
6929
- throw new JJError(
6930
- 'UNSUPPORTED_OPERATION',
6931
- 'diffedit requires interactive diff editor',
6932
- {
6933
- feature: 'diffedit',
6934
- reason: 'Interactive tools not supported in library API',
6935
- alternative: 'Use jj.diff() to view changes, then jj.edit() to modify the change',
6936
- suggestion: 'For programmatic editing, use jj.write() to update files and jj.describe() to update the description',
6937
- }
6938
- );
7543
+ jj.diffedit = async function (/** @type {any} */ _args) {
7544
+ throw new JJError('UNSUPPORTED_OPERATION', 'diffedit requires interactive diff editor', {
7545
+ feature: 'diffedit',
7546
+ reason: 'Interactive tools not supported in library API',
7547
+ alternative: 'Use jj.diff() to view changes, then jj.edit() to modify the change',
7548
+ suggestion:
7549
+ 'For programmatic editing, use jj.write() to update files and jj.describe() to update the description',
7550
+ });
6939
7551
  };
6940
7552
 
6941
7553
  /**
@@ -6944,55 +7556,19 @@ export async function createJJ(options) {
6944
7556
  * @status UNSUPPORTED - Requires external formatters
6945
7557
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
6946
7558
  */
6947
- jj.fix = async function (args) {
6948
- throw new JJError(
6949
- 'UNSUPPORTED_OPERATION',
6950
- 'Automatic formatting requires external tools',
6951
- {
6952
- feature: 'fix',
6953
- reason: 'External formatters (prettier, eslint, etc.) must be integrated separately',
6954
- alternative: 'Run formatters separately, then use jj.write() to update files',
6955
- suggestion: 'Consider using pre-commit hooks to run formatters automatically',
6956
- }
6957
- );
6958
- };
6959
-
6960
- /**
6961
- * sign - Cryptographically sign a revision (STUB)
6962
- *
6963
- * @status UNSUPPORTED - Cryptographic signing not implemented
6964
- * @throws {JJError} Always throws UNSUPPORTED_OPERATION
6965
- */
6966
- jj.sign = async function (args) {
6967
- throw new JJError(
6968
- 'UNSUPPORTED_OPERATION',
6969
- 'Cryptographic signing not implemented',
6970
- {
6971
- feature: 'sign',
6972
- reason: 'GPG/SSH signing requires complex key management and security considerations',
6973
- alternative: 'Use Git\'s signing features with jj.git.push() for signed commits',
6974
- suggestion: 'Sign commits at the Git layer using git commit --gpg-sign',
6975
- }
6976
- );
7559
+ jj.fix = async function (/** @type {any} */ _args) {
7560
+ throw new JJError('UNSUPPORTED_OPERATION', 'Automatic formatting requires external tools', {
7561
+ feature: 'fix',
7562
+ reason: 'External formatters (prettier, eslint, etc.) must be integrated separately',
7563
+ alternative: 'Run formatters separately, then use jj.write() to update files',
7564
+ suggestion: 'Consider using pre-commit hooks to run formatters automatically',
7565
+ });
6977
7566
  };
6978
7567
 
6979
- /**
6980
- * unsign - Remove cryptographic signature (STUB)
6981
- *
6982
- * @status UNSUPPORTED - Cryptographic signing not implemented
6983
- * @throws {JJError} Always throws UNSUPPORTED_OPERATION
6984
- */
6985
- jj.unsign = async function (args) {
6986
- throw new JJError(
6987
- 'UNSUPPORTED_OPERATION',
6988
- 'Cryptographic signing not implemented',
6989
- {
6990
- feature: 'unsign',
6991
- reason: 'Signing features not implemented in isomorphic-jj',
6992
- alternative: 'Modify commits using jj.metaedit() if you need to change metadata',
6993
- }
6994
- );
6995
- };
7568
+ // NOTE: jj.sign() / jj.unsign() are implemented as real (metadata-based)
7569
+ // methods in the main API object above (v1.5). The old UNSUPPORTED stubs were
7570
+ // removed — a pure-JS library can't verify keys, but it can record and expose
7571
+ // signature metadata for the signed() revset and Git-layer integration.
6996
7572
 
6997
7573
  /**
6998
7574
  * util - CLI utility commands (STUB namespace)
@@ -7005,15 +7581,11 @@ export async function createJJ(options) {
7005
7581
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
7006
7582
  */
7007
7583
  async completion() {
7008
- throw new JJError(
7009
- 'UNSUPPORTED_OPERATION',
7010
- 'Shell completion generation is CLI-specific',
7011
- {
7012
- feature: 'util.completion',
7013
- reason: 'Completions are for command-line shells, not library APIs',
7014
- alternative: 'Not applicable - use JJ CLI for shell completions',
7015
- }
7016
- );
7584
+ throw new JJError('UNSUPPORTED_OPERATION', 'Shell completion generation is CLI-specific', {
7585
+ feature: 'util.completion',
7586
+ reason: 'Completions are for command-line shells, not library APIs',
7587
+ alternative: 'Not applicable - use JJ CLI for shell completions',
7588
+ });
7017
7589
  },
7018
7590
 
7019
7591
  /**
@@ -7021,31 +7593,23 @@ export async function createJJ(options) {
7021
7593
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
7022
7594
  */
7023
7595
  async gc() {
7024
- throw new JJError(
7025
- 'UNSUPPORTED_OPERATION',
7026
- 'Garbage collection is a CLI utility',
7027
- {
7028
- feature: 'util.gc',
7029
- reason: 'Storage management handled automatically by isomorphic-jj',
7030
- alternative: 'Repository cleanup happens automatically',
7031
- }
7032
- );
7596
+ throw new JJError('UNSUPPORTED_OPERATION', 'Garbage collection is a CLI utility', {
7597
+ feature: 'util.gc',
7598
+ reason: 'Storage management handled automatically by isomorphic-jj',
7599
+ alternative: 'Repository cleanup happens automatically',
7600
+ });
7033
7601
  },
7034
7602
 
7035
7603
  /**
7036
7604
  * exec - Execute command with JJ environment (STUB)
7037
7605
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
7038
7606
  */
7039
- async exec(args) {
7040
- throw new JJError(
7041
- 'UNSUPPORTED_OPERATION',
7042
- 'Command execution is a CLI utility',
7043
- {
7044
- feature: 'util.exec',
7045
- reason: 'Shell command execution not appropriate for library API',
7046
- alternative: 'Use Node.js child_process module directly if needed',
7047
- }
7048
- );
7607
+ async exec(/** @type {any} */ _args) {
7608
+ throw new JJError('UNSUPPORTED_OPERATION', 'Command execution is a CLI utility', {
7609
+ feature: 'util.exec',
7610
+ reason: 'Shell command execution not appropriate for library API',
7611
+ alternative: 'Use Node.js child_process module directly if needed',
7612
+ });
7049
7613
  },
7050
7614
 
7051
7615
  /**
@@ -7053,15 +7617,11 @@ export async function createJJ(options) {
7053
7617
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
7054
7618
  */
7055
7619
  async configSchema() {
7056
- throw new JJError(
7057
- 'UNSUPPORTED_OPERATION',
7058
- 'Config schema output is a CLI utility',
7059
- {
7060
- feature: 'util.configSchema',
7061
- reason: 'Schema introspection not needed for programmatic usage',
7062
- alternative: 'See TypeScript types or API documentation for config structure',
7063
- }
7064
- );
7620
+ throw new JJError('UNSUPPORTED_OPERATION', 'Config schema output is a CLI utility', {
7621
+ feature: 'util.configSchema',
7622
+ reason: 'Schema introspection not needed for programmatic usage',
7623
+ alternative: 'See TypeScript types or API documentation for config structure',
7624
+ });
7065
7625
  },
7066
7626
  };
7067
7627
 
@@ -7075,17 +7635,13 @@ export async function createJJ(options) {
7075
7635
  * upload - Upload changes to Gerrit (STUB)
7076
7636
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
7077
7637
  */
7078
- async upload(args) {
7079
- throw new JJError(
7080
- 'UNSUPPORTED_OPERATION',
7081
- 'Gerrit integration not supported',
7082
- {
7083
- feature: 'gerrit.upload',
7084
- reason: 'Gerrit-specific features not implemented',
7085
- alternative: 'Use jj.git.push() with appropriate Gerrit remote configuration',
7086
- suggestion: 'Configure Gerrit remote and use standard Git push with refs/for/ syntax',
7087
- }
7088
- );
7638
+ async upload(/** @type {any} */ _args) {
7639
+ throw new JJError('UNSUPPORTED_OPERATION', 'Gerrit integration not supported', {
7640
+ feature: 'gerrit.upload',
7641
+ reason: 'Gerrit-specific features not implemented',
7642
+ alternative: 'Use jj.git.push() with appropriate Gerrit remote configuration',
7643
+ suggestion: 'Configure Gerrit remote and use standard Git push with refs/for/ syntax',
7644
+ });
7089
7645
  },
7090
7646
  };
7091
7647