isomorphic-jj 1.4.2 → 1.7.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`, {
@@ -1093,6 +1179,7 @@ export async function createJJ(options) {
1093
1179
  if (isWorkingCopy && !args.preserveSnapshot) {
1094
1180
  // Snapshot current file contents for conflict detection
1095
1181
  // This allows us to load file contents during merge/rebase
1182
+ /** @type {Record<string, any>} */
1096
1183
  const fileSnapshot = {};
1097
1184
  const trackedFiles = await workingCopy.listFiles();
1098
1185
 
@@ -1124,7 +1211,11 @@ export async function createJJ(options) {
1124
1211
  throw new JJError(
1125
1212
  'SNAPSHOT_FILE_FAILED',
1126
1213
  `Could not snapshot file ${filePath}: ${error.message}`,
1127
- { filePath, originalError: error.message, suggestion: 'File may be binary, deleted, or inaccessible' }
1214
+ {
1215
+ filePath,
1216
+ originalError: error.message,
1217
+ suggestion: 'File may be binary, deleted, or inaccessible',
1218
+ }
1128
1219
  );
1129
1220
  }
1130
1221
  }
@@ -1154,12 +1245,17 @@ export async function createJJ(options) {
1154
1245
  });
1155
1246
 
1156
1247
  // 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 });
1248
+ await dispatchEventAsync(
1249
+ jj,
1250
+ 'change:updated',
1251
+ {
1252
+ operation: 'describe',
1253
+ changeId: targetChangeId,
1254
+ change,
1255
+ timestamp: new Date().toISOString(),
1256
+ },
1257
+ { cancelable: false }
1258
+ );
1163
1259
 
1164
1260
  return change;
1165
1261
  },
@@ -1170,18 +1266,7 @@ export async function createJJ(options) {
1170
1266
  * Allows updating author, committer, description, and optionally regenerating the change ID.
1171
1267
  * The file content and snapshot remain unchanged.
1172
1268
  *
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
1269
+ * @param {Record<string, any>} [args={}] - Arguments
1185
1270
  * @returns {Promise<Object>} Updated change object
1186
1271
  */
1187
1272
  async metaedit(args = {}) {
@@ -1202,7 +1287,7 @@ export async function createJJ(options) {
1202
1287
  // Determine which change to edit
1203
1288
  const changeId = args.revision || workingCopy.getCurrentChangeId();
1204
1289
  const change = await graph.getChange(changeId);
1205
- const user = userConfig.getUser();
1290
+ const user = /** @type {any} */ (userConfig.getUser());
1206
1291
 
1207
1292
  if (!change) {
1208
1293
  throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
@@ -1215,17 +1300,13 @@ export async function createJJ(options) {
1215
1300
  const hasDescription = args.description !== undefined;
1216
1301
 
1217
1302
  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
- );
1303
+ throw new JJError('INVALID_ARGUMENT', 'No metadata provided to update', {
1304
+ suggestion: 'Provide author, committer, description, or resetChangeId: true',
1305
+ });
1225
1306
  }
1226
1307
 
1227
1308
  // Store old change ID for potential regeneration
1228
- const oldChangeId = change.changeId;
1309
+ const _oldChangeId = change.changeId;
1229
1310
 
1230
1311
  // Update author if provided
1231
1312
  if (args.author) {
@@ -1276,15 +1357,11 @@ export async function createJJ(options) {
1276
1357
  // - Updating all children to point to new change ID
1277
1358
  // - Updating all bookmarks pointing to old change ID
1278
1359
  // - 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
- );
1360
+ throw new JJError('UNSUPPORTED_OPERATION', 'resetChangeId is not yet implemented', {
1361
+ feature: 'metaedit resetChangeId',
1362
+ reason: 'Requires complex graph manipulation to update all references',
1363
+ suggestion: 'Create a new change with desired metadata instead',
1364
+ });
1288
1365
  }
1289
1366
 
1290
1367
  // Update the existing change
@@ -1310,12 +1387,7 @@ export async function createJJ(options) {
1310
1387
  /**
1311
1388
  * Create a new change
1312
1389
  *
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
1390
+ * @param {Record<string, any>} [args={}] - Arguments
1319
1391
  * @returns {Promise<Object>} The new change object including changeId, description, parents, author, and timestamp
1320
1392
  */
1321
1393
  async new(args = {}) {
@@ -1357,7 +1429,7 @@ export async function createJJ(options) {
1357
1429
  }
1358
1430
 
1359
1431
  const newChangeId = generateChangeId();
1360
- const user = userConfig.getUser();
1432
+ const user = /** @type {any} */ (userConfig.getUser());
1361
1433
 
1362
1434
  // Initialize with parent's file snapshot
1363
1435
  let initialSnapshot = {};
@@ -1398,7 +1470,7 @@ export async function createJJ(options) {
1398
1470
  timestamp: new Date().toISOString(),
1399
1471
  });
1400
1472
 
1401
- await graph.addChange(newChange); // Middleware will sync to Git
1473
+ await graph.addChange(newChange); // Middleware will sync to Git
1402
1474
 
1403
1475
  // Handle insertBefore: rebase target to have new change as parent
1404
1476
  if (args.insertBefore) {
@@ -1429,17 +1501,22 @@ export async function createJJ(options) {
1429
1501
  });
1430
1502
 
1431
1503
  // 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 });
1504
+ await dispatchEventAsync(
1505
+ jj,
1506
+ 'change:created',
1507
+ {
1508
+ operation: 'new',
1509
+ changeId: newChangeId,
1510
+ parents: parents,
1511
+ change: newChange,
1512
+ timestamp: new Date().toISOString(),
1513
+ },
1514
+ { cancelable: false }
1515
+ );
1439
1516
 
1440
1517
  return newChange;
1441
1518
  },
1442
-
1519
+
1443
1520
  /**
1444
1521
  * Get working copy status
1445
1522
  */
@@ -1448,21 +1525,57 @@ export async function createJJ(options) {
1448
1525
  await workingCopy.load();
1449
1526
  await conflicts.load();
1450
1527
 
1528
+ // Dirty tracked files (computed before reconciling so modifications to
1529
+ // already-tracked files are still reported).
1530
+ const modified = await workingCopy.getModifiedFiles();
1531
+
1532
+ // v1.6: snapshot the working directory so files added/removed on disk
1533
+ // out-of-band show up in status.
1534
+ const snap = await autoSnapshotWorkingCopy();
1535
+
1451
1536
  const currentChangeId = workingCopy.getCurrentChangeId();
1452
1537
  const change = await graph.getChange(currentChangeId);
1453
- const modified = await workingCopy.getModifiedFiles();
1454
1538
  // Only show unresolved conflicts in status
1455
1539
  const activeConflicts = conflicts.listConflicts({ resolved: false });
1456
1540
 
1457
1541
  return {
1458
1542
  workingCopy: change,
1459
1543
  modified,
1460
- added: [],
1461
- removed: [],
1544
+ added: snap ? snap.added : [],
1545
+ removed: snap ? snap.deleted : [],
1462
1546
  conflicts: activeConflicts,
1463
1547
  };
1464
1548
  },
1465
1549
 
1550
+ /**
1551
+ * Explicitly snapshot the working directory (v1.6).
1552
+ *
1553
+ * Walks the working copy on disk and reconciles tracked file state and the
1554
+ * working-copy change's content, picking up files created/modified/deleted
1555
+ * outside of jj.write() (editor, shell, git checkout). This runs
1556
+ * automatically before status/describe/diff/read/file.* unless the repo was
1557
+ * created with `{ autoSnapshot: false }`; call this to trigger it manually.
1558
+ *
1559
+ * @returns {Promise<{added: string[], modified: string[], deleted: string[]}>}
1560
+ */
1561
+ async snapshot() {
1562
+ await graph.load();
1563
+ await workingCopy.load();
1564
+ const result = await workingCopy.snapshot();
1565
+ // Refresh the working-copy change's content snapshot from disk.
1566
+ try {
1567
+ const wcId = workingCopy.getCurrentChangeId();
1568
+ const change = await graph.getChange(wcId);
1569
+ if (change) {
1570
+ change.fileSnapshot = await snapshotFilesystem();
1571
+ await graph.updateChange(change);
1572
+ }
1573
+ } catch {
1574
+ // Best-effort content refresh.
1575
+ }
1576
+ return result;
1577
+ },
1578
+
1466
1579
  /**
1467
1580
  * Get repository statistics and analytics
1468
1581
  *
@@ -1477,9 +1590,10 @@ export async function createJJ(options) {
1477
1590
 
1478
1591
  const all = graph.getAll();
1479
1592
  const ops = await oplog.list();
1480
- const currentUser = userConfig.getUser();
1593
+ const currentUser = /** @type {any} */ (userConfig.getUser());
1481
1594
 
1482
1595
  // Count by author
1596
+ /** @type {Record<string, any>} */
1483
1597
  const authorCounts = {};
1484
1598
  for (const change of all) {
1485
1599
  if (!change.abandoned && change.author) {
@@ -1499,26 +1613,30 @@ export async function createJJ(options) {
1499
1613
  }
1500
1614
 
1501
1615
  // Count merge commits
1502
- const mergeCommits = all.filter(c => c.parents && c.parents.length > 1).length;
1616
+ const mergeCommits = all.filter(
1617
+ (/** @type {any} */ c) => c.parents && c.parents.length > 1
1618
+ ).length;
1503
1619
 
1504
1620
  // Count empty commits
1505
- const emptyCommits = all.filter(c =>
1506
- c.tree === '0000000000000000000000000000000000000000'
1621
+ const emptyCommits = all.filter(
1622
+ (/** @type {any} */ c) => c.tree === '0000000000000000000000000000000000000000'
1507
1623
  ).length;
1508
1624
 
1509
1625
  // Count abandoned
1510
- const abandonedCount = all.filter(c => c.abandoned).length;
1626
+ const abandonedCount = all.filter((/** @type {any} */ c) => c.abandoned).length;
1511
1627
 
1512
1628
  // My commits
1513
- const myCommits = all.filter(c =>
1514
- c.author && !c.abandoned &&
1515
- (c.author.email === currentUser.email || c.author.name === currentUser.name)
1629
+ const myCommits = all.filter(
1630
+ (/** @type {any} */ c) =>
1631
+ c.author &&
1632
+ !c.abandoned &&
1633
+ (c.author.email === currentUser.email || c.author.name === currentUser.name)
1516
1634
  ).length;
1517
1635
 
1518
1636
  // Get bookmark statistics
1519
1637
  const allBookmarks = await bookmarks.list();
1520
- const localBookmarks = allBookmarks.filter(b => !b.remote);
1521
- const remoteBookmarks = allBookmarks.filter(b => b.remote);
1638
+ const localBookmarks = allBookmarks.filter((b) => !b.remote);
1639
+ const remoteBookmarks = allBookmarks.filter((b) => b.remote);
1522
1640
 
1523
1641
  return {
1524
1642
  changes: {
@@ -1537,7 +1655,7 @@ export async function createJJ(options) {
1537
1655
  files: {
1538
1656
  total: allFiles.size,
1539
1657
  list: Array.from(allFiles).sort(),
1540
- byExtension: {}, // Add file extension breakdown
1658
+ byExtension: {}, // Add file extension breakdown
1541
1659
  },
1542
1660
  bookmarks: {
1543
1661
  total: allBookmarks.length,
@@ -1558,11 +1676,8 @@ export async function createJJ(options) {
1558
1676
  /**
1559
1677
  * View change history
1560
1678
  *
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
1679
+ * @param {Record<string, any>} args - Arguments
1680
+ * @returns {Promise<Array<any>|number>} Array of changes or count if args.count is true
1566
1681
  */
1567
1682
  async log(args = {}) {
1568
1683
  await graph.load();
@@ -1591,7 +1706,7 @@ export async function createJJ(options) {
1591
1706
  const aIsParentOfB = b.parents && b.parents.includes(a.changeId);
1592
1707
  const bIsParentOfA = a.parents && a.parents.includes(b.changeId);
1593
1708
 
1594
- if (aIsParentOfB) return 1; // a is parent of b, so b comes first
1709
+ if (aIsParentOfB) return 1; // a is parent of b, so b comes first
1595
1710
  if (bIsParentOfA) return -1; // b is parent of a, so a comes first
1596
1711
 
1597
1712
  // Secondary: Sort by timestamp descending (newest first)
@@ -1611,14 +1726,13 @@ export async function createJJ(options) {
1611
1726
  /**
1612
1727
  * Show detailed information about a specific change
1613
1728
  *
1614
- * @param {Object} args - Arguments
1615
- * @param {string} args.change - Change ID or revset to show
1729
+ * @param {Record<string, any>} args - Arguments
1616
1730
  * @returns {Promise<Object>} Detailed change information
1617
1731
  */
1618
1732
  async show(args) {
1619
1733
  if (!args || !args.change) {
1620
1734
  throw new JJError('INVALID_ARGUMENT', 'Missing change argument', {
1621
- suggestion: 'Provide { change: changeId }'
1735
+ suggestion: 'Provide { change: changeId }',
1622
1736
  });
1623
1737
  }
1624
1738
 
@@ -1654,23 +1768,23 @@ export async function createJJ(options) {
1654
1768
  // Find bookmarks pointing to this change
1655
1769
  const allBookmarks = await bookmarks.list();
1656
1770
  const changeBookmarks = allBookmarks
1657
- .filter(b => b.target === change.changeId)
1658
- .map(b => b.name);
1771
+ .filter((b) => b.changeId === change.changeId)
1772
+ .map((b) => b.name);
1659
1773
 
1660
1774
  // Find operations that modified this change
1661
1775
  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
- )
1776
+ const relatedOps = ops.filter(
1777
+ (op) =>
1778
+ op.view &&
1779
+ (op.view.workingCopy === change.changeId ||
1780
+ (op.view.heads && op.view.heads.includes(change.changeId)))
1667
1781
  );
1668
1782
 
1669
1783
  return {
1670
1784
  ...change,
1671
1785
  children,
1672
1786
  bookmarks: changeBookmarks,
1673
- operations: relatedOps.map(op => ({
1787
+ operations: relatedOps.map((op) => ({
1674
1788
  id: op.id,
1675
1789
  timestamp: op.timestamp,
1676
1790
  description: op.description,
@@ -1681,8 +1795,7 @@ export async function createJJ(options) {
1681
1795
  /**
1682
1796
  * Amend the working copy change
1683
1797
  *
1684
- * @param {Object} args - Arguments
1685
- * @param {string} [args.message] - New description message
1798
+ * @param {Record<string, any>} args - Arguments
1686
1799
  * @returns {Promise<Object>} Updated change
1687
1800
  */
1688
1801
  async amend(args = {}) {
@@ -1693,10 +1806,7 @@ export async function createJJ(options) {
1693
1806
  /**
1694
1807
  * Convenience: describe current change and create new one (jj commit)
1695
1808
  *
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
1809
+ * @param {Record<string, any>} [args={}] - Arguments
1700
1810
  * @returns {Promise<Object>} The newly created change
1701
1811
  */
1702
1812
  async commit(args = {}) {
@@ -1704,19 +1814,18 @@ export async function createJJ(options) {
1704
1814
  message: args.message,
1705
1815
  author: args.author,
1706
1816
  metadata: args.metadata,
1707
- preserveSnapshot: args.preserveSnapshot
1817
+ preserveSnapshot: args.preserveSnapshot,
1708
1818
  });
1709
1819
  return await this.new({
1710
1820
  message: args.nextMessage,
1711
- preserveSnapshot: args.preserveSnapshot
1821
+ preserveSnapshot: args.preserveSnapshot,
1712
1822
  });
1713
1823
  },
1714
1824
 
1715
1825
  /**
1716
1826
  * Edit a specific change (make it the working copy)
1717
1827
  *
1718
- * @param {Object} args - Arguments
1719
- * @param {string} args.changeId - Change ID to edit
1828
+ * @param {Record<string, any>} args - Arguments
1720
1829
  * @returns {Promise<Object>} Information about the edited change including changeId, description, and file count
1721
1830
  */
1722
1831
  async edit(args) {
@@ -1736,7 +1845,7 @@ export async function createJJ(options) {
1736
1845
  await userConfig.load();
1737
1846
 
1738
1847
  const change = await graph.getChange(args.changeId);
1739
- const user = userConfig.getUser();
1848
+ const user = /** @type {any} */ (userConfig.getUser());
1740
1849
  if (!change) {
1741
1850
  throw new JJError('CHANGE_NOT_FOUND', `Change ${args.changeId} not found`, {
1742
1851
  changeId: args.changeId,
@@ -1841,13 +1950,18 @@ export async function createJJ(options) {
1841
1950
  });
1842
1951
 
1843
1952
  // 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 });
1953
+ await dispatchEventAsync(
1954
+ jj,
1955
+ 'workingcopy:switched',
1956
+ {
1957
+ operation: 'edit',
1958
+ fromChangeId: previousChangeId,
1959
+ toChangeId: args.changeId,
1960
+ change,
1961
+ timestamp: new Date().toISOString(),
1962
+ },
1963
+ { cancelable: false }
1964
+ );
1851
1965
 
1852
1966
  // Return information about the edited change
1853
1967
  return {
@@ -1858,7 +1972,7 @@ export async function createJJ(options) {
1858
1972
  timestamp: change.timestamp,
1859
1973
  };
1860
1974
  },
1861
-
1975
+
1862
1976
  /**
1863
1977
  * Undo last operation
1864
1978
  *
@@ -1875,7 +1989,7 @@ export async function createJJ(options) {
1875
1989
  // Get the current operation (the one being undone) to restore its pre-state
1876
1990
  const currentOp = ops[ops.length - 1];
1877
1991
 
1878
- const previousView = await oplog.undo();
1992
+ const previousView = /** @type {any} */ (await oplog.undo());
1879
1993
 
1880
1994
  // Restore state from previous view
1881
1995
  await workingCopy.setCurrentChange(previousView.workingCopy);
@@ -1913,18 +2027,25 @@ export async function createJJ(options) {
1913
2027
  if (currentOp && currentOp.conflictsSnapshot) {
1914
2028
  // Restore conflicts from the operation we're undoing (its pre-state)
1915
2029
  conflicts.conflicts = new Map(Object.entries(currentOp.conflictsSnapshot.conflicts || {}));
1916
- conflicts.fileConflicts = new Map(Object.entries(currentOp.conflictsSnapshot.fileConflicts || {}));
2030
+ conflicts.fileConflicts = new Map(
2031
+ Object.entries(currentOp.conflictsSnapshot.fileConflicts || {})
2032
+ );
1917
2033
  await conflicts.save();
1918
2034
  } else {
1919
2035
  // No conflicts snapshot - clear conflicts
1920
2036
  await conflicts.clear();
1921
2037
  }
1922
2038
 
1923
- // Record undo operation
2039
+ // Record undo operation. Tag it with the view it undid so that redo()
2040
+ // can re-apply it (jj's progressive undo/redo, added in jj v0.33).
1924
2041
  await oplog.recordOperation({
1925
2042
  timestamp: new Date().toISOString(),
1926
2043
  user: await getUserOplogInfo(),
1927
2044
  description: 'undo operation',
2045
+ eventType: 'undo',
2046
+ undoneOpId: currentOp.id,
2047
+ undoneView: currentOp.view,
2048
+ undoneConflictsSnapshot: currentOp.conflictsSnapshot || null,
1928
2049
  parents: [],
1929
2050
  view: previousView,
1930
2051
  });
@@ -1944,13 +2065,110 @@ export async function createJJ(options) {
1944
2065
  };
1945
2066
  },
1946
2067
 
2068
+ /**
2069
+ * Redo an operation previously undone with undo() (v1.5)
2070
+ *
2071
+ * Mirrors `jj redo` (jj v0.33): progressively re-applies the operations that
2072
+ * `undo()` reverted, most-recent-undo first. Each redo consumes exactly one
2073
+ * pending undo.
2074
+ *
2075
+ * @returns {Promise<Object>} Information about the redone operation
2076
+ */
2077
+ async redo() {
2078
+ await oplog.load();
2079
+ const ops = await oplog.list();
2080
+
2081
+ // Operations already consumed by a prior redo.
2082
+ const alreadyRedone = new Set();
2083
+ for (const op of ops) {
2084
+ if (op.eventType === 'redo' && op.redoOf) {
2085
+ alreadyRedone.add(op.redoOf);
2086
+ }
2087
+ }
2088
+
2089
+ // Find the most recent undo operation that has not yet been redone.
2090
+ let undoOp = null;
2091
+ for (let i = ops.length - 1; i >= 0; i--) {
2092
+ if (ops[i].eventType === 'undo' && !alreadyRedone.has(ops[i].id)) {
2093
+ undoOp = ops[i];
2094
+ break;
2095
+ }
2096
+ }
2097
+
2098
+ if (!undoOp) {
2099
+ throw new JJError('NOTHING_TO_REDO', 'No undone operations to redo', {
2100
+ suggestion: 'redo() only reverses a preceding undo()',
2101
+ });
2102
+ }
2103
+
2104
+ const redoneView = undoOp.undoneView || {};
2105
+
2106
+ // Restore the working-copy pointer.
2107
+ if (redoneView.workingCopy) {
2108
+ await workingCopy.setCurrentChange(redoneView.workingCopy);
2109
+ }
2110
+
2111
+ // Restore the working-copy files captured in the undone view.
2112
+ if (redoneView.fileSnapshot) {
2113
+ for (const [filePath, content] of Object.entries(redoneView.fileSnapshot)) {
2114
+ try {
2115
+ const fullPath = path.join(dir, filePath);
2116
+ const pathParts = filePath.split('/');
2117
+ if (pathParts.length > 1) {
2118
+ const fullDirPath = path.join(dir, pathParts.slice(0, -1).join('/'));
2119
+ await fs.promises.mkdir(fullDirPath, { recursive: true });
2120
+ }
2121
+ await fs.promises.writeFile(fullPath, content, 'utf8');
2122
+ } catch (error) {
2123
+ throw new JJError(
2124
+ 'REDO_FILE_RESTORE_FAILED',
2125
+ `Failed to restore file ${filePath} during redo: ${error.message}`,
2126
+ { filePath, originalError: error.message }
2127
+ );
2128
+ }
2129
+ }
2130
+ }
2131
+
2132
+ // Restore conflicts to the state that existed after the redone operation.
2133
+ await conflicts.load();
2134
+ if (undoOp.undoneConflictsSnapshot) {
2135
+ conflicts.conflicts = new Map(
2136
+ Object.entries(undoOp.undoneConflictsSnapshot.conflicts || {})
2137
+ );
2138
+ conflicts.fileConflicts = new Map(
2139
+ Object.entries(undoOp.undoneConflictsSnapshot.fileConflicts || {})
2140
+ );
2141
+ await conflicts.save();
2142
+ }
2143
+
2144
+ await oplog.recordOperation({
2145
+ timestamp: new Date().toISOString(),
2146
+ user: await getUserOplogInfo(),
2147
+ description: 'redo operation',
2148
+ eventType: 'redo',
2149
+ redoOf: undoOp.id,
2150
+ parents: [],
2151
+ view: redoneView,
2152
+ });
2153
+
2154
+ return {
2155
+ redoneOperation: {
2156
+ description: undoOp.description,
2157
+ undoneOpId: undoOp.undoneOpId,
2158
+ },
2159
+ restoredState: {
2160
+ workingCopy: redoneView.workingCopy,
2161
+ heads: redoneView.heads,
2162
+ fileCount: redoneView.fileSnapshot ? Object.keys(redoneView.fileSnapshot).length : 0,
2163
+ },
2164
+ };
2165
+ },
2166
+
1947
2167
  /**
1948
2168
  * View operation log (operation history)
1949
2169
  *
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
2170
+ * @param {Record<string, any>} [opts] - Options
2171
+ * @returns {Promise<Array<any>>} Array of operations
1954
2172
  */
1955
2173
  async obslog(opts = {}) {
1956
2174
  await oplog.load();
@@ -1961,11 +2179,11 @@ export async function createJJ(options) {
1961
2179
 
1962
2180
  // Filter by change if specified
1963
2181
  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
- )
2182
+ operations = operations.filter(
2183
+ (op) =>
2184
+ op.view &&
2185
+ (op.view.workingCopy === changeId ||
2186
+ (op.view.heads && op.view.heads.includes(changeId)))
1969
2187
  );
1970
2188
  }
1971
2189
 
@@ -1975,10 +2193,10 @@ export async function createJJ(options) {
1975
2193
  }
1976
2194
 
1977
2195
  // Transform to evolution events with operation field
1978
- const events = operations.map(op => ({
2196
+ const events = operations.map((op) => ({
1979
2197
  eventType: op.eventType || 'modify',
1980
2198
  description: op.description,
1981
- operation: op.id, // Add operation ID
2199
+ operation: op.id, // Add operation ID
1982
2200
  timestamp: op.timestamp,
1983
2201
  user: op.user,
1984
2202
  }));
@@ -1994,9 +2212,8 @@ export async function createJJ(options) {
1994
2212
  /**
1995
2213
  * List all operations
1996
2214
  *
1997
- * @param {Object} [opts] - Options
1998
- * @param {number} [opts.limit] - Maximum number to return
1999
- * @returns {Promise<Array>} Array of operations
2215
+ * @param {Record<string, any>} [opts] - Options
2216
+ * @returns {Promise<Array<any>>} Array of operations
2000
2217
  */
2001
2218
  async list(opts = {}) {
2002
2219
  await oplog.load();
@@ -2012,20 +2229,19 @@ export async function createJJ(options) {
2012
2229
  /**
2013
2230
  * View repository at a specific operation (time travel)
2014
2231
  *
2015
- * @param {Object} args - Arguments
2016
- * @param {string} args.operation - Operation ID to view at
2232
+ * @param {Record<string, any>} args - Arguments
2017
2233
  * @returns {Promise<Object>} Read-only repository view at that operation
2018
2234
  */
2019
2235
  async at(args) {
2020
2236
  if (!args || !args.operation) {
2021
2237
  throw new JJError('INVALID_ARGUMENT', 'Missing operation ID', {
2022
- suggestion: 'Provide { operation: operationId }'
2238
+ suggestion: 'Provide { operation: operationId }',
2023
2239
  });
2024
2240
  }
2025
2241
 
2026
2242
  await oplog.load();
2027
2243
  const ops = await oplog.list();
2028
- const targetOp = ops.find(op => op.id === args.operation);
2244
+ const targetOp = ops.find((op) => op.id === args.operation);
2029
2245
 
2030
2246
  if (!targetOp) {
2031
2247
  throw new JJError('OPERATION_NOT_FOUND', `Operation ${args.operation} not found`);
@@ -2033,17 +2249,17 @@ export async function createJJ(options) {
2033
2249
 
2034
2250
  // Create a read-only view at this operation
2035
2251
  return {
2036
- async log(logArgs = {}) {
2252
+ async log(_logArgs = {}) {
2037
2253
  // Get changes that existed at this operation
2038
2254
  await graph.load();
2039
2255
  const allChanges = await graph.getAllChanges();
2040
2256
 
2041
2257
  // Filter to changes that existed before or at this operation
2042
2258
  const opIndex = ops.indexOf(targetOp);
2043
- const validChanges = allChanges.filter(change => {
2259
+ const validChanges = allChanges.filter((/** @type {any} */ change) => {
2044
2260
  // 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)
2261
+ const changeOps = ops.filter(
2262
+ (op) => op.view && op.view.heads && op.view.heads.includes(change.changeId)
2047
2263
  );
2048
2264
  return changeOps.length > 0 && ops.indexOf(changeOps[0]) <= opIndex;
2049
2265
  });
@@ -2067,8 +2283,7 @@ export async function createJJ(options) {
2067
2283
  /**
2068
2284
  * Show changes in a specific operation (matches `jj operation show`)
2069
2285
  *
2070
- * @param {Object} args - Arguments
2071
- * @param {string} args.operation - Operation ID
2286
+ * @param {Record<string, any>} args - Arguments
2072
2287
  * @returns {Promise<Object>} Operation details with changes made
2073
2288
  */
2074
2289
  async show(args) {
@@ -2080,7 +2295,7 @@ export async function createJJ(options) {
2080
2295
 
2081
2296
  await oplog.load();
2082
2297
  const ops = await oplog.list();
2083
- const op = ops.find(o => o.id === args.operation);
2298
+ const op = ops.find((o) => o.id === args.operation);
2084
2299
 
2085
2300
  if (!op) {
2086
2301
  throw new JJError('OPERATION_NOT_FOUND', `Operation ${args.operation} not found`);
@@ -2112,9 +2327,7 @@ export async function createJJ(options) {
2112
2327
  /**
2113
2328
  * Compare repository state between two operations (matches `jj operation diff`)
2114
2329
  *
2115
- * @param {Object} args - Arguments
2116
- * @param {string} args.from - Source operation ID
2117
- * @param {string} args.to - Target operation ID
2330
+ * @param {Record<string, any>} args - Arguments
2118
2331
  * @returns {Promise<Object>} Differences between operations
2119
2332
  */
2120
2333
  async diff(args) {
@@ -2126,8 +2339,8 @@ export async function createJJ(options) {
2126
2339
 
2127
2340
  await oplog.load();
2128
2341
  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);
2342
+ const fromOp = ops.find((o) => o.id === args.from);
2343
+ const toOp = ops.find((o) => o.id === args.to);
2131
2344
 
2132
2345
  if (!fromOp) {
2133
2346
  throw new JJError('OPERATION_NOT_FOUND', `Operation ${args.from} not found`);
@@ -2140,13 +2353,14 @@ export async function createJJ(options) {
2140
2353
  const fromHeads = new Set(fromOp.view?.heads || []);
2141
2354
  const toHeads = new Set(toOp.view?.heads || []);
2142
2355
 
2143
- const addedHeads = [...toHeads].filter(h => !fromHeads.has(h));
2144
- const removedHeads = [...fromHeads].filter(h => !toHeads.has(h));
2356
+ const addedHeads = [...toHeads].filter((h) => !fromHeads.has(h));
2357
+ const removedHeads = [...fromHeads].filter((h) => !toHeads.has(h));
2145
2358
 
2146
2359
  // Compare bookmarks
2147
2360
  const fromBookmarks = fromOp.view?.bookmarks || {};
2148
2361
  const toBookmarks = toOp.view?.bookmarks || {};
2149
2362
 
2363
+ /** @type {Record<string, any>} */
2150
2364
  const bookmarkChanges = {};
2151
2365
  for (const name of new Set([...Object.keys(fromBookmarks), ...Object.keys(toBookmarks)])) {
2152
2366
  if (fromBookmarks[name] !== toBookmarks[name]) {
@@ -2172,8 +2386,7 @@ export async function createJJ(options) {
2172
2386
  /**
2173
2387
  * Restore repository to a specific operation (matches `jj operation restore`)
2174
2388
  *
2175
- * @param {Object} args - Arguments
2176
- * @param {string} args.operation - Operation ID to restore to
2389
+ * @param {Record<string, any>} args - Arguments
2177
2390
  * @returns {Promise<Object>} Restore result
2178
2391
  */
2179
2392
  async restore(args) {
@@ -2185,7 +2398,7 @@ export async function createJJ(options) {
2185
2398
 
2186
2399
  await oplog.load();
2187
2400
  const ops = await oplog.list();
2188
- const targetOp = ops.find(o => o.id === args.operation);
2401
+ const targetOp = ops.find((o) => o.id === args.operation);
2189
2402
 
2190
2403
  if (!targetOp) {
2191
2404
  throw new JJError('OPERATION_NOT_FOUND', `Operation ${args.operation} not found`);
@@ -2234,8 +2447,7 @@ export async function createJJ(options) {
2234
2447
  * This creates a new operation that undoes the effects of the specified operation,
2235
2448
  * without affecting any operations that came after it.
2236
2449
  *
2237
- * @param {Object} args - Arguments
2238
- * @param {string} args.operation - Operation ID to revert
2450
+ * @param {Record<string, any>} args - Arguments
2239
2451
  * @returns {Promise<Object>} Revert result
2240
2452
  */
2241
2453
  async revert(args) {
@@ -2247,7 +2459,7 @@ export async function createJJ(options) {
2247
2459
 
2248
2460
  await oplog.load();
2249
2461
  const ops = await oplog.list();
2250
- const targetOp = ops.find(o => o.id === args.operation);
2462
+ const targetOp = ops.find((o) => o.id === args.operation);
2251
2463
 
2252
2464
  if (!targetOp) {
2253
2465
  throw new JJError('OPERATION_NOT_FOUND', `Operation ${args.operation} not found`);
@@ -2264,10 +2476,10 @@ export async function createJJ(options) {
2264
2476
  const previousOp = ops[targetIndex - 1];
2265
2477
 
2266
2478
  // Compute the inverse changes
2267
- const inversChanges = {
2479
+ const inversChanges = /** @type {Record<string, any>} */ ({
2268
2480
  bookmarks: {},
2269
2481
  heads: [],
2270
- };
2482
+ });
2271
2483
 
2272
2484
  // Revert bookmark changes
2273
2485
  const prevBookmarks = previousOp.view?.bookmarks || {};
@@ -2280,8 +2492,9 @@ export async function createJJ(options) {
2280
2492
  await bookmarks.delete(name);
2281
2493
  inversChanges.bookmarks[name] = { action: 'deleted' };
2282
2494
  } else if (prevBookmarks[name] !== targetBookmarks[name]) {
2283
- // Bookmark was moved - move it back
2284
- await bookmarks.set(name, prevBookmarks[name]);
2495
+ // Bookmark was moved - move it back (the bookmark still exists, so
2496
+ // use move() rather than set(), which is create-only).
2497
+ await bookmarks.move(name, prevBookmarks[name]);
2285
2498
  inversChanges.bookmarks[name] = {
2286
2499
  action: 'moved',
2287
2500
  from: targetBookmarks[name],
@@ -2341,8 +2554,7 @@ export async function createJJ(options) {
2341
2554
  * WARNING: This is a destructive operation that cannot be undone. Use with caution.
2342
2555
  * In most cases, you should use undo(), restore(), or revert() instead.
2343
2556
  *
2344
- * @param {Object} args - Arguments
2345
- * @param {string} args.operation - Operation ID to abandon
2557
+ * @param {Record<string, any>} args - Arguments
2346
2558
  * @returns {Promise<Object>} Abandon result with relinked children
2347
2559
  */
2348
2560
  async abandon(args) {
@@ -2355,7 +2567,7 @@ export async function createJJ(options) {
2355
2567
  await oplog.load();
2356
2568
 
2357
2569
  // Call the OperationLog abandon method
2358
- const result = await oplog.abandon(args.operation);
2570
+ const result = /** @type {any} */ (await oplog.abandon(args.operation));
2359
2571
 
2360
2572
  // Record a new operation about the abandonment
2361
2573
  // Note: This creates a meta-record that an operation was abandoned
@@ -2372,7 +2584,7 @@ export async function createJJ(options) {
2372
2584
  },
2373
2585
  metadata: {
2374
2586
  abandonedOperation: args.operation,
2375
- relinkedChildren: result.relinkedChildren.map(c => c.operationId),
2587
+ relinkedChildren: result.relinkedChildren.map((/** @type {any} */ c) => c.operationId),
2376
2588
  },
2377
2589
  });
2378
2590
 
@@ -2388,14 +2600,11 @@ export async function createJJ(options) {
2388
2600
  // ========================================
2389
2601
  // v0.2 FEATURES: History Editing
2390
2602
  // ========================================
2391
-
2603
+
2392
2604
  /**
2393
2605
  * Squash source change into destination change
2394
2606
  *
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)
2607
+ * @param {Record<string, any>} args - Arguments
2399
2608
  */
2400
2609
  async squash(args = {}) {
2401
2610
  // Check for interactive mode (not supported)
@@ -2426,7 +2635,11 @@ export async function createJJ(options) {
2426
2635
  let destChangeId = dest;
2427
2636
  if (!destChangeId && source === workingCopy.getCurrentChangeId()) {
2428
2637
  const workingCopyChange = await graph.getChange(source);
2429
- if (workingCopyChange && workingCopyChange.parents && workingCopyChange.parents.length > 0) {
2638
+ if (
2639
+ workingCopyChange &&
2640
+ workingCopyChange.parents &&
2641
+ workingCopyChange.parents.length > 0
2642
+ ) {
2430
2643
  destChangeId = workingCopyChange.parents[0];
2431
2644
  } else {
2432
2645
  throw new JJError('INVALID_ARGUMENTS', 'Cannot squash working copy: no parent found');
@@ -2473,7 +2686,7 @@ export async function createJJ(options) {
2473
2686
 
2474
2687
  // If squashing the working copy, create a new empty working copy on top of dest
2475
2688
  if (isSquashingWorkingCopy) {
2476
- const user = userConfig.getUser();
2689
+ const user = /** @type {any} */ (userConfig.getUser());
2477
2690
  const newChangeId = generateChangeId();
2478
2691
  const newChange = {
2479
2692
  changeId: newChangeId,
@@ -2495,7 +2708,7 @@ export async function createJJ(options) {
2495
2708
  fileSnapshot: {},
2496
2709
  };
2497
2710
 
2498
- await graph.addChange(newChange); // Middleware will sync to Git
2711
+ await graph.addChange(newChange); // Middleware will sync to Git
2499
2712
  await workingCopy.setCurrentChange(newChangeId);
2500
2713
  newWorkingCopyId = newChangeId;
2501
2714
  }
@@ -2515,27 +2728,29 @@ export async function createJJ(options) {
2515
2728
  });
2516
2729
 
2517
2730
  // 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 });
2731
+ await dispatchEventAsync(
2732
+ jj,
2733
+ 'change:squashed',
2734
+ {
2735
+ operation: 'squash',
2736
+ sourceChangeId: source,
2737
+ destChangeId: destChangeId,
2738
+ sourceChange,
2739
+ destChange,
2740
+ timestamp: new Date().toISOString(),
2741
+ },
2742
+ { cancelable: false }
2743
+ );
2526
2744
 
2527
2745
  return destChange;
2528
2746
  },
2529
-
2747
+
2530
2748
  /**
2531
2749
  * Rebase a change to a new parent (matches `jj rebase`)
2532
2750
  *
2533
2751
  * This is the proper JJ CLI semantic for moving changes in history.
2534
2752
  *
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
2753
+ * @param {Record<string, any>} args - Arguments
2539
2754
  * @returns {Promise<Object>} Updated change object
2540
2755
  *
2541
2756
  * @example
@@ -2556,8 +2771,7 @@ export async function createJJ(options) {
2556
2771
  /**
2557
2772
  * Abandon a change
2558
2773
  *
2559
- * @param {Object} [args] - Arguments
2560
- * @param {string} [args.changeId] - Change ID to abandon (defaults to @ - working copy)
2774
+ * @param {Record<string, any>} [args] - Arguments
2561
2775
  * @returns {Promise<Object>} The abandoned change object including changeId, description, and abandoned flag
2562
2776
  */
2563
2777
  async abandon(args = {}) {
@@ -2569,7 +2783,7 @@ export async function createJJ(options) {
2569
2783
  const changeId = args.changeId || workingCopy.getCurrentChangeId();
2570
2784
 
2571
2785
  const change = await graph.getChange(changeId);
2572
- const user = userConfig.getUser();
2786
+ const user = /** @type {any} */ (userConfig.getUser());
2573
2787
 
2574
2788
  if (!change) {
2575
2789
  throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
@@ -2601,12 +2815,17 @@ export async function createJJ(options) {
2601
2815
  });
2602
2816
 
2603
2817
  // 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 });
2818
+ await dispatchEventAsync(
2819
+ jj,
2820
+ 'change:abandoned',
2821
+ {
2822
+ operation: 'abandon',
2823
+ changeId: changeId,
2824
+ change,
2825
+ timestamp: new Date().toISOString(),
2826
+ },
2827
+ { cancelable: false }
2828
+ );
2610
2829
 
2611
2830
  return change;
2612
2831
  },
@@ -2617,9 +2836,7 @@ export async function createJJ(options) {
2617
2836
  * Similar to `git revert`, this creates a new change that undoes the changes made
2618
2837
  * by the specified revision without modifying the original change.
2619
2838
  *
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
2839
+ * @param {Record<string, any>} args - Arguments
2623
2840
  * @returns {Promise<Object>} The new backout change including changeId, description, and backedOut field
2624
2841
  */
2625
2842
  async backout(args) {
@@ -2634,7 +2851,8 @@ export async function createJJ(options) {
2634
2851
 
2635
2852
  if (!args || !args.revision) {
2636
2853
  throw new JJError('INVALID_ARGUMENT', 'Missing revision argument', {
2637
- suggestion: 'Provide the change ID to back out: { revision: "abc123..." } (or use change/changeId/target)',
2854
+ suggestion:
2855
+ 'Provide the change ID to back out: { revision: "abc123..." } (or use change/changeId/target)',
2638
2856
  });
2639
2857
  }
2640
2858
 
@@ -2643,7 +2861,7 @@ export async function createJJ(options) {
2643
2861
  await userConfig.load();
2644
2862
 
2645
2863
  const originalChange = await graph.getChange(args.revision);
2646
- const user = userConfig.getUser();
2864
+ const user = /** @type {any} */ (userConfig.getUser());
2647
2865
 
2648
2866
  if (!originalChange) {
2649
2867
  throw new JJError('CHANGE_NOT_FOUND', `Change ${args.revision} not found`);
@@ -2713,6 +2931,112 @@ export async function createJJ(options) {
2713
2931
  };
2714
2932
  },
2715
2933
 
2934
+ /**
2935
+ * Revert a change by creating a new change that undoes it (v1.5)
2936
+ *
2937
+ * This is the canonical name in modern Jujutsu (`jj revert`), which fully
2938
+ * replaced `jj backout` in jj v0.35. `backout()` remains as a deprecated
2939
+ * alias for backward compatibility.
2940
+ *
2941
+ * @param {Record<string, any>} args - Revert arguments
2942
+ * @returns {Promise<Object>} Result including `revertedFrom`
2943
+ */
2944
+ async revert(args) {
2945
+ const result = await jj.backout(args);
2946
+ return {
2947
+ changeId: result.changeId,
2948
+ description: result.description,
2949
+ revertedFrom: result.backedOut,
2950
+ // Keep the legacy field so existing callers keep working.
2951
+ backedOut: result.backedOut,
2952
+ fileSnapshot: result.fileSnapshot,
2953
+ };
2954
+ },
2955
+
2956
+ /**
2957
+ * Sign a change (matches `jj sign`, jj v0.27)
2958
+ *
2959
+ * A pure-JS library cannot verify GPG/SSH keys the way the jj CLI does, so
2960
+ * this records signature *metadata* on the change (backend/key/status). This
2961
+ * makes changes discoverable via the `signed()` revset and lets tooling
2962
+ * round-trip signature intent. Pass a real signer via `args.backend`/`args.key`.
2963
+ *
2964
+ * @param {Record<string, any>} [args] - Arguments
2965
+ * @returns {Promise<Object>} { changeId, signed, signature }
2966
+ */
2967
+ async sign(args = {}) {
2968
+ await graph.load();
2969
+ await workingCopy.load();
2970
+
2971
+ const revision =
2972
+ args.revision || args.change || args.changeId || workingCopy.getCurrentChangeId();
2973
+ const change = await graph.getChange(revision);
2974
+ if (!change) {
2975
+ throw new JJError('CHANGE_NOT_FOUND', `Change ${revision} not found`);
2976
+ }
2977
+
2978
+ const signature = {
2979
+ status: 'good',
2980
+ backend: args.backend || 'none',
2981
+ key: args.key || null,
2982
+ timestamp: new Date().toISOString(),
2983
+ };
2984
+
2985
+ await graph.updateChange({ ...change, signed: true, signature });
2986
+
2987
+ await oplog.recordOperation({
2988
+ timestamp: new Date().toISOString(),
2989
+ user: await getUserOplogInfo(),
2990
+ description: `sign ${revision.slice(0, 8)}`,
2991
+ parents: [],
2992
+ view: {
2993
+ bookmarks: {},
2994
+ remoteBookmarks: {},
2995
+ heads: [revision],
2996
+ workingCopy: workingCopy.getCurrentChangeId(),
2997
+ },
2998
+ });
2999
+
3000
+ return { changeId: revision, signed: true, signature };
3001
+ },
3002
+
3003
+ /**
3004
+ * Remove a signature from a change (matches `jj unsign`, jj v0.27)
3005
+ *
3006
+ * @param {Record<string, any>} [args] - Arguments
3007
+ * @returns {Promise<Object>} { changeId, signed }
3008
+ */
3009
+ async unsign(args = {}) {
3010
+ await graph.load();
3011
+ await workingCopy.load();
3012
+
3013
+ const revision =
3014
+ args.revision || args.change || args.changeId || workingCopy.getCurrentChangeId();
3015
+ const change = await graph.getChange(revision);
3016
+ if (!change) {
3017
+ throw new JJError('CHANGE_NOT_FOUND', `Change ${revision} not found`);
3018
+ }
3019
+
3020
+ const updated = { ...change, signed: false };
3021
+ delete updated.signature;
3022
+ await graph.updateChange(updated);
3023
+
3024
+ await oplog.recordOperation({
3025
+ timestamp: new Date().toISOString(),
3026
+ user: await getUserOplogInfo(),
3027
+ description: `unsign ${revision.slice(0, 8)}`,
3028
+ parents: [],
3029
+ view: {
3030
+ bookmarks: {},
3031
+ remoteBookmarks: {},
3032
+ heads: [revision],
3033
+ workingCopy: workingCopy.getCurrentChangeId(),
3034
+ },
3035
+ });
3036
+
3037
+ return { changeId: revision, signed: false };
3038
+ },
3039
+
2716
3040
  /**
2717
3041
  * Absorb working copy changes into ancestor changes
2718
3042
  *
@@ -2727,8 +3051,7 @@ export async function createJJ(options) {
2727
3051
  * 4. Update each ancestor with its modifications
2728
3052
  * 5. Remove absorbed changes from working copy
2729
3053
  *
2730
- * @param {Object} [args] - Optional arguments
2731
- * @param {boolean} [args.dryRun] - Preview without making changes
3054
+ * @param {Record<string, any>} [args] - Optional arguments
2732
3055
  * @returns {Promise<Object>} Result with affected changes
2733
3056
  */
2734
3057
  async absorb(args = {}) {
@@ -2738,7 +3061,7 @@ export async function createJJ(options) {
2738
3061
 
2739
3062
  const workingChangeId = workingCopy.getCurrentChangeId();
2740
3063
  const workingChange = await graph.getChange(workingChangeId);
2741
- const user = userConfig.getUser();
3064
+ const user = /** @type {any} */ (userConfig.getUser());
2742
3065
 
2743
3066
  if (!workingChange) {
2744
3067
  // No working copy
@@ -2767,6 +3090,7 @@ export async function createJJ(options) {
2767
3090
  await graph.updateChange(workingChange);
2768
3091
 
2769
3092
  // Get parent snapshot to determine what's modified
3093
+ /** @type {Record<string, any>} */
2770
3094
  let parentSnapshot = {};
2771
3095
  if (workingChange.parents && workingChange.parents.length > 0) {
2772
3096
  const parent = await graph.getChange(workingChange.parents[0]);
@@ -2776,6 +3100,7 @@ export async function createJJ(options) {
2776
3100
  }
2777
3101
 
2778
3102
  // Find modified files (existed in parent AND content is different)
3103
+ /** @type {Record<string, any>} */
2779
3104
  const modifiedFiles = {};
2780
3105
  for (const [filePath, content] of Object.entries(currentSnapshot)) {
2781
3106
  // Only count as modified if file existed in parent (not a new file)
@@ -2792,6 +3117,7 @@ export async function createJJ(options) {
2792
3117
  }
2793
3118
 
2794
3119
  // Check if there are new files (not in parent)
3120
+ /** @type {Record<string, any>} */
2795
3121
  const newFiles = {};
2796
3122
  for (const [filePath, content] of Object.entries(currentSnapshot)) {
2797
3123
  if (parentSnapshot[filePath] === undefined) {
@@ -2816,6 +3142,7 @@ export async function createJJ(options) {
2816
3142
 
2817
3143
  // For each modified file, use line-level tracking to find which ancestor
2818
3144
  // modified each line, then group modifications by ancestor
3145
+ /** @type {Record<string, any>} */
2819
3146
  const ancestorModifications = {}; // ancestorId -> { filePath: content }
2820
3147
 
2821
3148
  for (const filePath of Object.keys(modifiedFiles)) {
@@ -2943,9 +3270,8 @@ export async function createJJ(options) {
2943
3270
  // Update working copy to reflect absorbed changes
2944
3271
  // Working copy should now match parent's new state (after absorption) plus any new files
2945
3272
  const updatedParent = await graph.getChange(workingChange.parents[0]);
2946
- const newWorkingSnapshot = updatedParent && updatedParent.fileSnapshot
2947
- ? { ...updatedParent.fileSnapshot }
2948
- : {};
3273
+ const newWorkingSnapshot =
3274
+ updatedParent && updatedParent.fileSnapshot ? { ...updatedParent.fileSnapshot } : {};
2949
3275
 
2950
3276
  // Add new files that weren't absorbed
2951
3277
  for (const [filePath, content] of Object.entries(newFiles)) {
@@ -3049,7 +3375,7 @@ export async function createJJ(options) {
3049
3375
  * @param {string} parentContent - Content from parent change
3050
3376
  * @param {string} currentContent - Current working copy content
3051
3377
  * @param {string[]} startingPoints - Change IDs to start search from (parent changeIds)
3052
- * @returns {Promise<Map>} Map of ancestorId -> { lines: Map(lineNum -> content), originalContent }
3378
+ * @returns {Promise<Map<any, any>>} Map of ancestorId -> { lines: Map(lineNum -> content), originalContent }
3053
3379
  */
3054
3380
  async _findLineAncestors(filePath, parentContent, currentContent, startingPoints) {
3055
3381
  if (!startingPoints || startingPoints.length === 0) {
@@ -3203,7 +3529,11 @@ export async function createJJ(options) {
3203
3529
  continue;
3204
3530
  }
3205
3531
 
3206
- if (change.parents && change.parents.includes(currentId) && !visited.has(change.changeId)) {
3532
+ if (
3533
+ change.parents &&
3534
+ change.parents.includes(currentId) &&
3535
+ !visited.has(change.changeId)
3536
+ ) {
3207
3537
  visited.add(change.changeId);
3208
3538
  queue.push(change.changeId);
3209
3539
  descendants.push(change.changeId);
@@ -3219,11 +3549,16 @@ export async function createJJ(options) {
3219
3549
  *
3220
3550
  * @private
3221
3551
  * @param {string[]} descendantIds - List of descendant IDs to rebuild
3222
- * @param {Map} originalStates - Map of changeId -> original state before absorb
3552
+ * @param {Map<any, any>} originalStates - Map of changeId -> original state before absorb
3223
3553
  * @param {string} stopAtChangeId - Don't rebuild this change
3224
- * @param {Set} directlyModified - Set of changeIds that were directly modified by absorb
3554
+ * @param {Set<any>} directlyModified - Set of changeIds that were directly modified by absorb
3225
3555
  */
3226
- async _rebuildDescendantsWithStates(descendantIds, originalStates, stopAtChangeId, directlyModified = new Set()) {
3556
+ async _rebuildDescendantsWithStates(
3557
+ descendantIds,
3558
+ originalStates,
3559
+ stopAtChangeId,
3560
+ directlyModified = new Set()
3561
+ ) {
3227
3562
  // Sort descendants by depth (parents before children)
3228
3563
  const sorted = await this._topologicalSort(descendantIds);
3229
3564
 
@@ -3269,6 +3604,7 @@ export async function createJJ(options) {
3269
3604
 
3270
3605
  // For each file, track which lines the descendant modified from original parent
3271
3606
  // then reapply those modifications to the updated parent
3607
+ /** @type {Record<string, any>} */
3272
3608
  const newSnapshot = {};
3273
3609
 
3274
3610
  const allFiles = new Set([
@@ -3290,9 +3626,15 @@ export async function createJJ(options) {
3290
3626
  const resultLines = [...updatedParentLines];
3291
3627
 
3292
3628
  // 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;
3629
+ for (
3630
+ let i = 0;
3631
+ i < Math.max(originalDescendantLines.length, originalParentLines.length);
3632
+ i++
3633
+ ) {
3634
+ const originalDescendantLine =
3635
+ i < originalDescendantLines.length ? originalDescendantLines[i] : undefined;
3636
+ const originalParentLine =
3637
+ i < originalParentLines.length ? originalParentLines[i] : undefined;
3296
3638
 
3297
3639
  // If descendant modified this line from original parent, apply that modification
3298
3640
  if (originalDescendantLine !== originalParentLine) {
@@ -3356,7 +3698,11 @@ export async function createJJ(options) {
3356
3698
  continue;
3357
3699
  }
3358
3700
 
3359
- if (change.parents && change.parents.includes(currentId) && !visited.has(change.changeId)) {
3701
+ if (
3702
+ change.parents &&
3703
+ change.parents.includes(currentId) &&
3704
+ !visited.has(change.changeId)
3705
+ ) {
3360
3706
  visited.add(change.changeId);
3361
3707
  queue.push(change.changeId);
3362
3708
  descendants.push(change.changeId);
@@ -3394,6 +3740,7 @@ export async function createJJ(options) {
3394
3740
 
3395
3741
  // For each file, track which lines the descendant modified from original parent
3396
3742
  // then reapply those modifications to the updated parent
3743
+ /** @type {Record<string, any>} */
3397
3744
  const newSnapshot = {};
3398
3745
 
3399
3746
  const allFiles = new Set([
@@ -3417,7 +3764,8 @@ export async function createJJ(options) {
3417
3764
  // Find lines that descendant modified
3418
3765
  for (let i = 0; i < Math.max(descendantLines.length, originalParentLines.length); i++) {
3419
3766
  const descendantLine = i < descendantLines.length ? descendantLines[i] : undefined;
3420
- const originalParentLine = i < originalParentLines.length ? originalParentLines[i] : undefined;
3767
+ const originalParentLine =
3768
+ i < originalParentLines.length ? originalParentLines[i] : undefined;
3421
3769
 
3422
3770
  // If descendant modified this line from original parent, apply that modification
3423
3771
  if (descendantLine !== originalParentLine) {
@@ -3451,15 +3799,14 @@ export async function createJJ(options) {
3451
3799
  * @returns {Promise<string[]>} Sorted change IDs
3452
3800
  */
3453
3801
  async _topologicalSort(changeIds) {
3454
- const changes = await Promise.all(
3455
- changeIds.map(id => graph.getChange(id))
3456
- );
3802
+ const changes = await Promise.all(changeIds.map((id) => graph.getChange(id)));
3457
3803
 
3804
+ /** @type {any[]} */
3458
3805
  const sorted = [];
3459
3806
  const visited = new Set();
3460
3807
  const inProgress = new Set();
3461
3808
 
3462
- const visit = async (change) => {
3809
+ const visit = async (/** @type {any} */ change) => {
3463
3810
  if (!change || visited.has(change.changeId)) {
3464
3811
  return;
3465
3812
  }
@@ -3499,8 +3846,7 @@ export async function createJJ(options) {
3499
3846
  * A parent is redundant if it's already an ancestor through another parent.
3500
3847
  * This operation optimizes the graph structure without changing the content.
3501
3848
  *
3502
- * @param {Object} args - Arguments
3503
- * @param {string} args.revision - Change ID to simplify
3849
+ * @param {Record<string, any>} args - Arguments
3504
3850
  * @returns {Promise<{changeId: string, simplified: boolean, removedParents?: string[]}>}
3505
3851
  */
3506
3852
  async simplifyParents(args) {
@@ -3514,7 +3860,7 @@ export async function createJJ(options) {
3514
3860
  await userConfig.load();
3515
3861
 
3516
3862
  const change = await graph.getChange(args.revision);
3517
- const user = userConfig.getUser();
3863
+ const user = /** @type {any} */ (userConfig.getUser());
3518
3864
 
3519
3865
  if (!change) {
3520
3866
  throw new JJError('CHANGE_NOT_FOUND', `Change ${args.revision} not found`);
@@ -3530,6 +3876,7 @@ export async function createJJ(options) {
3530
3876
 
3531
3877
  // Find redundant parents
3532
3878
  // A parent P is redundant if there exists another parent Q such that P is an ancestor of Q
3879
+ /** @type {any[]} */
3533
3880
  const redundantParents = [];
3534
3881
 
3535
3882
  for (let i = 0; i < change.parents.length; i++) {
@@ -3543,7 +3890,7 @@ export async function createJJ(options) {
3543
3890
 
3544
3891
  // Get all ancestors of parentQ
3545
3892
  const ancestorsQ = await this.log({ revisions: [parentQ] });
3546
- const ancestorIds = ancestorsQ.map(c => c.changeId);
3893
+ const ancestorIds = ancestorsQ.map((/** @type {any} */ c) => c.changeId);
3547
3894
 
3548
3895
  // If parentP is in the ancestors of parentQ, then parentP is redundant
3549
3896
  if (ancestorIds.includes(parentP)) {
@@ -3564,7 +3911,9 @@ export async function createJJ(options) {
3564
3911
  }
3565
3912
 
3566
3913
  // Remove redundant parents
3567
- change.parents = change.parents.filter(p => !redundantParents.includes(p));
3914
+ change.parents = change.parents.filter(
3915
+ (/** @type {any} */ p) => !redundantParents.includes(p)
3916
+ );
3568
3917
  await graph.updateChange(change);
3569
3918
 
3570
3919
  // Record operation
@@ -3591,8 +3940,7 @@ export async function createJJ(options) {
3591
3940
  /**
3592
3941
  * Un-abandon a change (restore from abandoned state)
3593
3942
  *
3594
- * @param {Object} args - Arguments
3595
- * @param {string} args.changeId - Change ID to un-abandon
3943
+ * @param {Record<string, any>} args - Arguments
3596
3944
  * @returns {Promise<Object>} The un-abandoned change object including changeId, description, and abandoned flag (now false)
3597
3945
  */
3598
3946
  async unabandon(args) {
@@ -3617,7 +3965,7 @@ export async function createJJ(options) {
3617
3965
  await userConfig.load();
3618
3966
 
3619
3967
  const change = await graph.getChange(args.changeId);
3620
- const user = userConfig.getUser();
3968
+ const user = /** @type {any} */ (userConfig.getUser());
3621
3969
 
3622
3970
  if (!change) {
3623
3971
  throw new JJError('CHANGE_NOT_FOUND', `Change ${args.changeId} not found`);
@@ -3649,24 +3997,25 @@ export async function createJJ(options) {
3649
3997
  });
3650
3998
 
3651
3999
  // 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 });
4000
+ await dispatchEventAsync(
4001
+ jj,
4002
+ 'change:unabandoned',
4003
+ {
4004
+ operation: 'unabandon',
4005
+ changeId: args.changeId,
4006
+ change,
4007
+ timestamp: new Date().toISOString(),
4008
+ },
4009
+ { cancelable: false }
4010
+ );
3658
4011
 
3659
4012
  return change;
3660
4013
  },
3661
-
4014
+
3662
4015
  /**
3663
4016
  * Split a change into multiple changes (simplified v0.2 implementation)
3664
4017
  *
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)
4018
+ * @param {Record<string, any>} args - Arguments
3670
4019
  */
3671
4020
  async split(args) {
3672
4021
  // Normalize: accept common parameter aliases
@@ -3746,7 +4095,7 @@ export async function createJJ(options) {
3746
4095
 
3747
4096
  return { original: originalChange, new: newChange };
3748
4097
  },
3749
-
4098
+
3750
4099
  // ========================================
3751
4100
  // v0.3 FEATURES: Git Integration
3752
4101
  // ========================================
@@ -3759,18 +4108,13 @@ export async function createJJ(options) {
3759
4108
  * Initialize a new Git-backed JJ repository
3760
4109
  * Matches `jj git init` CLI command
3761
4110
  *
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')
4111
+ * @param {Record<string, any>} opts - Initialization options
3766
4112
  */
3767
4113
  async init(opts = {}) {
3768
4114
  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
- );
4115
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4116
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4117
+ });
3774
4118
  }
3775
4119
 
3776
4120
  // Initialize Git backend
@@ -3795,7 +4139,7 @@ export async function createJJ(options) {
3795
4139
 
3796
4140
  // Create root change
3797
4141
  const rootChangeId = generateChangeId();
3798
- const user = userConfig.getUser();
4142
+ const user = /** @type {any} */ (userConfig.getUser());
3799
4143
  const rootChange = {
3800
4144
  changeId: rootChangeId,
3801
4145
  commitId: '0000000000000000000000000000000000000000',
@@ -3840,32 +4184,22 @@ export async function createJJ(options) {
3840
4184
  /**
3841
4185
  * Fetch changes from remote Git repository
3842
4186
  *
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
4187
+ * @param {Record<string, any>} args - Fetch arguments
3852
4188
  */
3853
4189
  async fetch(args) {
3854
4190
  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
- );
4191
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4192
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4193
+ });
3860
4194
  }
3861
4195
 
3862
4196
  const result = await gitBackend.fetch({
3863
4197
  remote: args.remote,
3864
4198
  refs: args.refs,
3865
- depth: args.depth, // v0.4: Shallow clone support
3866
- relative: args.relative, // v0.4: Relative depth
4199
+ depth: args.depth, // v0.4: Shallow clone support
4200
+ relative: args.relative, // v0.4: Relative depth
3867
4201
  singleBranch: args.singleBranch, // v0.4: Single branch only
3868
- noTags: args.noTags, // v0.4: Skip tags
4202
+ noTags: args.noTags, // v0.4: Skip tags
3869
4203
  onProgress: args.onProgress,
3870
4204
  onAuth: args.onAuth,
3871
4205
  });
@@ -3890,20 +4224,13 @@ export async function createJJ(options) {
3890
4224
  /**
3891
4225
  * Push changes to remote Git repository
3892
4226
  *
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
4227
+ * @param {Record<string, any>} args - Push arguments
3899
4228
  */
3900
4229
  async push(args) {
3901
4230
  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
- );
4231
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4232
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4233
+ });
3907
4234
  }
3908
4235
 
3909
4236
  const result = await gitBackend.push({
@@ -3934,24 +4261,14 @@ export async function createJJ(options) {
3934
4261
  /**
3935
4262
  * Clone a Git repository (matches `jj git clone`)
3936
4263
  *
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
4264
+ * @param {Record<string, any>} args - Clone arguments
3946
4265
  * @returns {Promise<Object>} Clone result with directory path
3947
4266
  */
3948
4267
  async clone(args) {
3949
4268
  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
- );
4269
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4270
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4271
+ });
3955
4272
  }
3956
4273
 
3957
4274
  if (!args || !args.url) {
@@ -3968,7 +4285,12 @@ export async function createJJ(options) {
3968
4285
 
3969
4286
  // Determine clone directory
3970
4287
  const git = (await import('isomorphic-git')).default;
3971
- const cloneDir = args.dir || args.url.split('/').pop().replace(/\.git$/, '');
4288
+ const cloneDir =
4289
+ args.dir ||
4290
+ args.url
4291
+ .split('/')
4292
+ .pop()
4293
+ .replace(/\.git$/, '');
3972
4294
  const fullCloneDir = path.join(dir, cloneDir);
3973
4295
 
3974
4296
  // Clone using isomorphic-git
@@ -3986,7 +4308,9 @@ export async function createJJ(options) {
3986
4308
  });
3987
4309
 
3988
4310
  // Initialize JJ repository structure in cloned directory
3989
- const clonedBackend = new IsomorphicGitBackend({ fs, http, dir: fullCloneDir });
4311
+ const clonedBackend = /** @type {any} */ (
4312
+ new IsomorphicGitBackend({ fs, http, dir: fullCloneDir })
4313
+ );
3990
4314
  await clonedBackend._createJJRepoStructure();
3991
4315
 
3992
4316
  // Record operation
@@ -4017,11 +4341,9 @@ export async function createJJ(options) {
4017
4341
  */
4018
4342
  async import() {
4019
4343
  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
- );
4344
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4345
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4346
+ });
4025
4347
  }
4026
4348
 
4027
4349
  await graph.load();
@@ -4039,14 +4361,18 @@ export async function createJJ(options) {
4039
4361
  if (!change) {
4040
4362
  // Create a new change for this commit
4041
4363
  const changeId = generateChangeId();
4042
- const user = userConfig.getUser();
4364
+ const user = /** @type {any} */ (userConfig.getUser());
4043
4365
  change = {
4044
4366
  changeId,
4045
4367
  commitId: ref.oid,
4046
4368
  parents: [],
4047
4369
  tree: ref.oid,
4048
4370
  author: { name: user.name, email: user.email, timestamp: new Date().toISOString() },
4049
- committer: { name: user.name, email: user.email, timestamp: new Date().toISOString() },
4371
+ committer: {
4372
+ name: user.name,
4373
+ email: user.email,
4374
+ timestamp: new Date().toISOString(),
4375
+ },
4050
4376
  description: `Imported from Git ref ${ref.name}`,
4051
4377
  timestamp: new Date().toISOString(),
4052
4378
  };
@@ -4090,11 +4416,9 @@ export async function createJJ(options) {
4090
4416
  */
4091
4417
  async export() {
4092
4418
  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
- );
4419
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4420
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4421
+ });
4098
4422
  }
4099
4423
 
4100
4424
  await graph.load();
@@ -4139,37 +4463,31 @@ export async function createJJ(options) {
4139
4463
  /**
4140
4464
  * List Git remotes (matches `jj git remote list`)
4141
4465
  *
4142
- * @returns {Promise<Array>} List of remotes with names and URLs
4466
+ * @returns {Promise<Array<any>>} List of remotes with names and URLs
4143
4467
  */
4144
4468
  async list() {
4145
4469
  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
- );
4470
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4471
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4472
+ });
4151
4473
  }
4152
4474
 
4153
4475
  const git = (await import('isomorphic-git')).default;
4154
4476
  const remotes = await git.listRemotes({ fs, dir });
4155
- return remotes.map(r => ({ name: r.remote, url: r.url }));
4477
+ return remotes.map((r) => ({ name: r.remote, url: r.url }));
4156
4478
  },
4157
4479
 
4158
4480
  /**
4159
4481
  * Add a Git remote (matches `jj git remote add`)
4160
4482
  *
4161
- * @param {Object} args - Remote arguments
4162
- * @param {string} args.name - Remote name
4163
- * @param {string} args.url - Remote URL
4483
+ * @param {Record<string, any>} args - Remote arguments
4164
4484
  * @returns {Promise<Object>} Added remote info
4165
4485
  */
4166
4486
  async add(args) {
4167
4487
  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
- );
4488
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4489
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4490
+ });
4173
4491
  }
4174
4492
 
4175
4493
  if (!args || !args.name || !args.url) {
@@ -4206,17 +4524,14 @@ export async function createJJ(options) {
4206
4524
  /**
4207
4525
  * Remove a Git remote (matches `jj git remote remove`)
4208
4526
  *
4209
- * @param {Object} args - Remote arguments
4210
- * @param {string} args.name - Remote name to remove
4527
+ * @param {Record<string, any>} args - Remote arguments
4211
4528
  * @returns {Promise<Object>} Removal result
4212
4529
  */
4213
4530
  async remove(args) {
4214
4531
  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
- );
4532
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4533
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4534
+ });
4220
4535
  }
4221
4536
 
4222
4537
  if (!args || !args.name) {
@@ -4252,18 +4567,14 @@ export async function createJJ(options) {
4252
4567
  /**
4253
4568
  * Rename a Git remote (matches `jj git remote rename`)
4254
4569
  *
4255
- * @param {Object} args - Remote arguments
4256
- * @param {string} args.oldName - Current remote name
4257
- * @param {string} args.newName - New remote name
4570
+ * @param {Record<string, any>} args - Remote arguments
4258
4571
  * @returns {Promise<Object>} Rename result
4259
4572
  */
4260
4573
  async rename(args) {
4261
4574
  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
- );
4575
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4576
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4577
+ });
4267
4578
  }
4268
4579
 
4269
4580
  if (!args || !args.oldName || !args.newName) {
@@ -4276,7 +4587,7 @@ export async function createJJ(options) {
4276
4587
 
4277
4588
  // Get current URL
4278
4589
  const remotes = await git.listRemotes({ fs, dir });
4279
- const remote = remotes.find(r => r.remote === args.oldName);
4590
+ const remote = remotes.find((r) => r.remote === args.oldName);
4280
4591
  if (!remote) {
4281
4592
  throw new JJError('NOT_FOUND', `Remote ${args.oldName} not found`);
4282
4593
  }
@@ -4305,18 +4616,14 @@ export async function createJJ(options) {
4305
4616
  /**
4306
4617
  * Set URL for a Git remote (matches `jj git remote set-url`)
4307
4618
  *
4308
- * @param {Object} args - Remote arguments
4309
- * @param {string} args.name - Remote name
4310
- * @param {string} args.url - New URL
4619
+ * @param {Record<string, any>} args - Remote arguments
4311
4620
  * @returns {Promise<Object>} Updated remote info
4312
4621
  */
4313
4622
  async setUrl(args) {
4314
4623
  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
- );
4624
+ throw new JJError('BACKEND_NOT_AVAILABLE', 'Git backend not configured', {
4625
+ suggestion: 'Provide git instance: createJJ({ fs, dir, git, http })',
4626
+ });
4320
4627
  }
4321
4628
 
4322
4629
  if (!args || !args.name || !args.url) {
@@ -4329,7 +4636,7 @@ export async function createJJ(options) {
4329
4636
 
4330
4637
  // Check if remote exists
4331
4638
  const remotes = await git.listRemotes({ fs, dir });
4332
- const remote = remotes.find(r => r.remote === args.name);
4639
+ const remote = remotes.find((r) => r.remote === args.name);
4333
4640
  if (!remote) {
4334
4641
  throw new JJError('NOT_FOUND', `Remote ${args.name} not found`);
4335
4642
  }
@@ -4383,8 +4690,7 @@ export async function createJJ(options) {
4383
4690
  /**
4384
4691
  * Merge changes from another change, detecting conflicts
4385
4692
  *
4386
- * @param {Object} args - Merge arguments
4387
- * @param {string} args.source - Source change ID to merge
4693
+ * @param {Record<string, any>} args - Merge arguments
4388
4694
  * @returns {Promise<Object>} Merge result with conflicts
4389
4695
  */
4390
4696
  async merge(args) {
@@ -4402,7 +4708,7 @@ export async function createJJ(options) {
4402
4708
  };
4403
4709
 
4404
4710
  const currentChangeId = workingCopy.getCurrentChangeId();
4405
- const currentChange = await graph.getChange(currentChangeId);
4711
+ const _currentChange = await graph.getChange(currentChangeId);
4406
4712
  const sourceChange = await graph.getChange(args.source);
4407
4713
 
4408
4714
  if (!sourceChange) {
@@ -4467,9 +4773,9 @@ export async function createJJ(options) {
4467
4773
  baseFiles,
4468
4774
  leftFiles,
4469
4775
  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
4776
+ drivers: args.drivers || {}, // v0.5: custom merge drivers
4777
+ workingCopyDir: /** @type {any} */ (args.dryRun ? null : dir), // v0.5: skip file writes in dry-run
4778
+ baseChange: baseChangeId, // v0.5: metadata for drivers
4473
4779
  leftChange: currentChangeId,
4474
4780
  rightChange: args.source,
4475
4781
  });
@@ -4522,8 +4828,7 @@ export async function createJJ(options) {
4522
4828
  /**
4523
4829
  * List active (unresolved) conflicts
4524
4830
  *
4525
- * @param {Object} opts - List options
4526
- * @param {boolean} [opts.includeResolved=false] - Include resolved conflicts
4831
+ * @param {Record<string, any>} opts - List options
4527
4832
  */
4528
4833
  async list(opts = {}) {
4529
4834
  await conflicts.load();
@@ -4535,11 +4840,7 @@ export async function createJJ(options) {
4535
4840
  /**
4536
4841
  * Resolve a conflict (v0.5 enhanced)
4537
4842
  *
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')
4843
+ * @param {Record<string, any>} args - Resolution arguments
4543
4844
  */
4544
4845
  async resolve(args) {
4545
4846
  if (!args || !args.conflictId) {
@@ -4569,12 +4870,12 @@ export async function createJJ(options) {
4569
4870
 
4570
4871
  const driverResult = await mergeDrivers.executeDriver(
4571
4872
  driver,
4572
- {
4873
+ /** @type {any} */ ({
4573
4874
  path: conflict.path,
4574
4875
  base: conflict.sides.base,
4575
4876
  ours: conflict.sides.left,
4576
4877
  theirs: conflict.sides.right,
4577
- },
4878
+ }),
4578
4879
  mergeDrivers.isBinaryFile(conflict.path, conflict.sides.left)
4579
4880
  );
4580
4881
 
@@ -4603,10 +4904,7 @@ export async function createJJ(options) {
4603
4904
  /**
4604
4905
  * Resolve all conflicts (v0.5)
4605
4906
  *
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
4907
+ * @param {Record<string, any>} args - Resolution arguments
4610
4908
  */
4611
4909
  async resolveAll(args) {
4612
4910
  if (!args || !args.strategy) {
@@ -4620,7 +4918,7 @@ export async function createJJ(options) {
4620
4918
  let toResolve = allConflicts;
4621
4919
  if (args.filter && args.filter.path) {
4622
4920
  const pattern = args.filter.path;
4623
- toResolve = allConflicts.filter(c => {
4921
+ toResolve = allConflicts.filter((c) => {
4624
4922
  // Simple pattern matching - exact match or glob
4625
4923
  if (pattern.includes('*')) {
4626
4924
  const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
@@ -4655,8 +4953,7 @@ export async function createJJ(options) {
4655
4953
  /**
4656
4954
  * Get conflict markers for a conflict (v0.5)
4657
4955
  *
4658
- * @param {Object} args - Arguments
4659
- * @param {string} args.conflictId - Conflict ID
4956
+ * @param {Record<string, any>} args - Arguments
4660
4957
  * @returns {Promise<string>} Conflict markers in standard format
4661
4958
  */
4662
4959
  async markers(args) {
@@ -4681,7 +4978,7 @@ export async function createJJ(options) {
4681
4978
  /**
4682
4979
  * Mark conflict as resolved
4683
4980
  */
4684
- async markResolved(args) {
4981
+ async markResolved(/** @type {Record<string, any>} */ args) {
4685
4982
  if (!args || !args.conflictId) {
4686
4983
  throw new JJError('INVALID_ARGUMENT', 'Missing conflictId');
4687
4984
  }
@@ -4702,19 +4999,15 @@ export async function createJJ(options) {
4702
4999
  *
4703
5000
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
4704
5001
  */
4705
- async resolve(args = {}) {
5002
+ async resolve(/** @type {Record<string, any>} */ args = {}) {
4706
5003
  // Check for external merge tool usage
4707
5004
  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
- );
5005
+ throw new JJError('UNSUPPORTED_OPERATION', 'External merge tools not supported', {
5006
+ feature: 'resolve --tool',
5007
+ reason: 'External merge tools require system integration not available in library API',
5008
+ alternative: 'Use jj.conflicts.resolve() with programmatic resolution',
5009
+ suggestion: 'For conflict resolution, use the programmatic API or resolve files manually',
5010
+ });
4718
5011
  }
4719
5012
 
4720
5013
  throw new JJError(
@@ -4743,10 +5036,7 @@ export async function createJJ(options) {
4743
5036
  /**
4744
5037
  * Show file content (matches `jj file show`)
4745
5038
  *
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')
5039
+ * @param {Record<string, any>} args - Arguments
4750
5040
  * @returns {Promise<string|Uint8Array>} File contents
4751
5041
  */
4752
5042
  async show(args) {
@@ -4756,20 +5046,80 @@ export async function createJJ(options) {
4756
5046
  /**
4757
5047
  * List files (matches `jj file list`)
4758
5048
  *
4759
- * @param {Object} [args={}] - Arguments
4760
- * @param {string} [args.changeId] - Change ID (defaults to working copy)
5049
+ * @param {Record<string, any>} [args={}] - Arguments
4761
5050
  * @returns {Promise<Array<string>>} Array of file paths
4762
5051
  */
4763
5052
  async list(args = {}) {
4764
5053
  return await jj.listFiles(args);
4765
5054
  },
4766
5055
 
5056
+ /**
5057
+ * Search file contents (matches `jj file search`, jj v0.37).
5058
+ *
5059
+ * Searches the tracked files of a change (defaults to the working copy)
5060
+ * for lines matching a pattern. Following jj v0.41, the pattern is treated
5061
+ * as a regular expression by default; pass `{ kind: 'substring' }` for a
5062
+ * literal search.
5063
+ *
5064
+ * @param {Record<string, any>} args - Arguments
5065
+ * @returns {Promise<Array<{path: string, lineNumber: number, line: string}>>}
5066
+ */
5067
+ async search(args) {
5068
+ if (!args || !args.pattern) {
5069
+ throw new JJError('INVALID_ARGUMENT', 'Missing pattern argument', {
5070
+ suggestion: 'Provide a search pattern: { pattern: "TODO" }',
5071
+ });
5072
+ }
5073
+
5074
+ // v1.6: when searching the working copy, snapshot the disk first so
5075
+ // out-of-band files are searchable.
5076
+ if (!args.changeId && !args.change) {
5077
+ await autoSnapshotWorkingCopy();
5078
+ }
5079
+
5080
+ await graph.load();
5081
+ const changeId = args.changeId || args.change || workingCopy.getCurrentChangeId();
5082
+ const change = await graph.getChange(changeId);
5083
+ if (!change) {
5084
+ throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
5085
+ }
5086
+
5087
+ const kind = args.kind || 'regex';
5088
+ let matcher;
5089
+ if (kind === 'substring') {
5090
+ matcher = (/** @type {any} */ line) => line.includes(args.pattern);
5091
+ } else {
5092
+ let regex;
5093
+ try {
5094
+ regex = new RegExp(args.pattern);
5095
+ } catch (error) {
5096
+ throw new JJError('INVALID_PATTERN', `Invalid regex pattern: ${error.message}`, {
5097
+ pattern: args.pattern,
5098
+ });
5099
+ }
5100
+ matcher = (/** @type {any} */ line) => regex.test(line);
5101
+ }
5102
+
5103
+ const snapshot = change.fileSnapshot || {};
5104
+ /** @type {any[]} */
5105
+ const results = [];
5106
+ for (const [filePath, content] of Object.entries(snapshot)) {
5107
+ if (args.path && filePath !== args.path) continue;
5108
+ if (typeof content !== 'string') continue; // skip binary
5109
+ const lines = content.split('\n');
5110
+ lines.forEach((line, index) => {
5111
+ if (matcher(line)) {
5112
+ results.push({ path: filePath, lineNumber: index + 1, line });
5113
+ }
5114
+ });
5115
+ }
5116
+ return results;
5117
+ },
5118
+
4767
5119
  /**
4768
5120
  * Write file content (organized file operation)
4769
5121
  *
4770
- * @param {Object} args - Arguments
4771
- * @param {string} args.path - File path to write
4772
- * @param {string|Uint8Array} args.data - File content
5122
+ * @param {Record<string, any>} args - Arguments
4773
5123
  * @returns {Promise<Object>} Write result with file info
4774
5124
  */
4775
5125
  async write(args) {
@@ -4779,9 +5129,7 @@ export async function createJJ(options) {
4779
5129
  /**
4780
5130
  * Move/rename file (organized file operation)
4781
5131
  *
4782
- * @param {Object} args - Arguments
4783
- * @param {string} args.from - Source path
4784
- * @param {string} args.to - Destination path
5132
+ * @param {Record<string, any>} args - Arguments
4785
5133
  * @returns {Promise<Object>} Move result
4786
5134
  */
4787
5135
  async move(args) {
@@ -4791,8 +5139,7 @@ export async function createJJ(options) {
4791
5139
  /**
4792
5140
  * Remove file (organized file operation)
4793
5141
  *
4794
- * @param {Object} args - Arguments
4795
- * @param {string} args.path - File path to remove
5142
+ * @param {Record<string, any>} args - Arguments
4796
5143
  * @returns {Promise<Object>} Remove result with file info
4797
5144
  */
4798
5145
  async remove(args) {
@@ -4802,10 +5149,8 @@ export async function createJJ(options) {
4802
5149
  /**
4803
5150
  * Show which revision modified each line (matches `jj file annotate` / git blame)
4804
5151
  *
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
5152
+ * @param {Record<string, any>} args - Arguments
5153
+ * @returns {Promise<Array<any>>} Array of line annotations with changeId, author, timestamp, and content
4809
5154
  */
4810
5155
  async annotate(args) {
4811
5156
  if (!args || !args.path) {
@@ -4847,7 +5192,8 @@ export async function createJJ(options) {
4847
5192
  }
4848
5193
 
4849
5194
  // Move to parent (simplified - takes first parent)
4850
- currentChangeId = change.parents && change.parents.length > 0 ? change.parents[0] : null;
5195
+ currentChangeId =
5196
+ change.parents && change.parents.length > 0 ? change.parents[0] : null;
4851
5197
  }
4852
5198
 
4853
5199
  annotations.push({
@@ -4866,9 +5212,7 @@ export async function createJJ(options) {
4866
5212
  * Change file permissions (matches `jj file chmod`)
4867
5213
  * Note: Only works in Node.js, not in browsers
4868
5214
  *
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')
5215
+ * @param {Record<string, any>} args - Arguments
4872
5216
  * @returns {Promise<Object>} chmod result
4873
5217
  */
4874
5218
  async chmod(args) {
@@ -4886,9 +5230,13 @@ export async function createJJ(options) {
4886
5230
 
4887
5231
  // Check if we're in a browser environment
4888
5232
  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
- });
5233
+ throw new JJError(
5234
+ 'UNSUPPORTED_OPERATION',
5235
+ 'chmod is not supported in browser environments',
5236
+ {
5237
+ suggestion: 'chmod only works in Node.js',
5238
+ }
5239
+ );
4892
5240
  }
4893
5241
 
4894
5242
  const fullPath = path.join(dir, args.path);
@@ -4914,11 +5262,10 @@ export async function createJJ(options) {
4914
5262
  * This is a stub method that throws an error explaining why explicit
4915
5263
  * file tracking is not needed in JavaScript environments.
4916
5264
  *
4917
- * @param {Object} args - Arguments
4918
- * @param {string} args.path - File path to track
5265
+ * @param {Record<string, any>} _args - Arguments
4919
5266
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
4920
5267
  */
4921
- async track(args) {
5268
+ async track(_args) {
4922
5269
  throw new JJError(
4923
5270
  'UNSUPPORTED_OPERATION',
4924
5271
  'Explicit file tracking (jj file track) is not needed in JavaScript environments',
@@ -4945,11 +5292,10 @@ export async function createJJ(options) {
4945
5292
  * This is a stub method that throws an error explaining why explicit
4946
5293
  * file untracking is not needed in JavaScript environments.
4947
5294
  *
4948
- * @param {Object} args - Arguments
4949
- * @param {string} args.path - File path to untrack
5295
+ * @param {Record<string, any>} _args - Arguments
4950
5296
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
4951
5297
  */
4952
- async untrack(args) {
5298
+ async untrack(_args) {
4953
5299
  throw new JJError(
4954
5300
  'UNSUPPORTED_OPERATION',
4955
5301
  'Explicit file untracking (jj file untrack) is not needed in JavaScript environments',
@@ -4979,10 +5325,7 @@ export async function createJJ(options) {
4979
5325
  /**
4980
5326
  * Add a new workspace
4981
5327
  *
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
5328
+ * @param {Record<string, any>} args - Workspace arguments
4986
5329
  */
4987
5330
  async add(args) {
4988
5331
  await workspaces.load();
@@ -4993,7 +5336,7 @@ export async function createJJ(options) {
4993
5336
  args = { ...args, changeId: currentChangeId };
4994
5337
  }
4995
5338
 
4996
- const workspace = await workspaces.add(args);
5339
+ const workspace = /** @type {any} */ (await workspaces.add(/** @type {any} */ (args)));
4997
5340
 
4998
5341
  // If a change was specified, check it out in the new workspace
4999
5342
  if (args.changeId) {
@@ -5050,9 +5393,7 @@ export async function createJJ(options) {
5050
5393
  /**
5051
5394
  * Remove a workspace
5052
5395
  *
5053
- * @param {Object} args - Remove arguments
5054
- * @param {string} args.id - Workspace ID
5055
- * @param {boolean} [args.force=false] - Force removal
5396
+ * @param {Record<string, any>} args - Remove arguments
5056
5397
  */
5057
5398
  async remove(args) {
5058
5399
  if (!args || !args.id) {
@@ -5082,8 +5423,7 @@ export async function createJJ(options) {
5082
5423
  /**
5083
5424
  * Forget a workspace without removing files (matches `jj workspace forget`)
5084
5425
  *
5085
- * @param {Object} args - Forget arguments
5086
- * @param {string} args.id - Workspace ID
5426
+ * @param {Record<string, any>} args - Forget arguments
5087
5427
  */
5088
5428
  async forget(args) {
5089
5429
  if (!args || !args.id) {
@@ -5115,7 +5455,7 @@ export async function createJJ(options) {
5115
5455
  */
5116
5456
  async list() {
5117
5457
  await workspaces.load();
5118
- return workspaces.list();
5458
+ return /** @type {any[]} */ (workspaces.list());
5119
5459
  },
5120
5460
 
5121
5461
  /**
@@ -5125,7 +5465,7 @@ export async function createJJ(options) {
5125
5465
  */
5126
5466
  async get(id) {
5127
5467
  await workspaces.load();
5128
- const workspace = workspaces.get(id);
5468
+ const workspace = /** @type {any} */ (workspaces.get(id));
5129
5469
  if (!workspace) {
5130
5470
  throw new JJError('WORKSPACE_NOT_FOUND', `Workspace ${id} not found`);
5131
5471
  }
@@ -5135,9 +5475,7 @@ export async function createJJ(options) {
5135
5475
  /**
5136
5476
  * Rename a workspace (matches `jj workspace rename`)
5137
5477
  *
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
5478
+ * @param {Record<string, any>} args - Rename arguments
5141
5479
  * @returns {Promise<Object>} Updated workspace
5142
5480
  */
5143
5481
  async rename(args) {
@@ -5146,7 +5484,7 @@ export async function createJJ(options) {
5146
5484
  }
5147
5485
 
5148
5486
  await workspaces.load();
5149
- const workspace = workspaces.get(args.workspace);
5487
+ const workspace = /** @type {any} */ (workspaces.get(args.workspace));
5150
5488
  if (!workspace) {
5151
5489
  throw new JJError('WORKSPACE_NOT_FOUND', `Workspace ${args.workspace} not found`);
5152
5490
  }
@@ -5174,8 +5512,7 @@ export async function createJJ(options) {
5174
5512
  /**
5175
5513
  * Get workspace root directory (matches `jj workspace root`)
5176
5514
  *
5177
- * @param {Object} [args={}] - Arguments
5178
- * @param {string} [args.workspace] - Workspace name/id (defaults to current)
5515
+ * @param {Record<string, any>} [args={}] - Arguments
5179
5516
  * @returns {Promise<string>} Root directory path
5180
5517
  */
5181
5518
  async root(args = {}) {
@@ -5187,10 +5524,10 @@ export async function createJJ(options) {
5187
5524
  await workspaces.load();
5188
5525
 
5189
5526
  // Try to get by ID first, then by name
5190
- let workspace = workspaces.get(args.workspace);
5527
+ let workspace = /** @type {any} */ (workspaces.get(args.workspace));
5191
5528
  if (!workspace) {
5192
5529
  // Search by name
5193
- const allWorkspaces = workspaces.list();
5530
+ const allWorkspaces = /** @type {any[]} */ (workspaces.list());
5194
5531
  workspace = allWorkspaces.find((ws) => ws.name === args.workspace);
5195
5532
  }
5196
5533
 
@@ -5204,15 +5541,14 @@ export async function createJJ(options) {
5204
5541
  /**
5205
5542
  * Update stale workspaces (matches `jj workspace update-stale`)
5206
5543
  *
5207
- * @param {Object} [args={}] - Arguments
5208
- * @param {string} [args.workspace] - Specific workspace to update (defaults to all stale)
5544
+ * @param {Record<string, any>} [args={}] - Arguments
5209
5545
  * @returns {Promise<Object>} Update result
5210
5546
  */
5211
5547
  async updateStale(args = {}) {
5212
5548
  await workspaces.load();
5213
5549
  await graph.load();
5214
5550
 
5215
- const allWorkspaces = workspaces.list();
5551
+ const allWorkspaces = /** @type {any[]} */ (workspaces.list());
5216
5552
  const staleWorkspaces = [];
5217
5553
 
5218
5554
  for (const ws of allWorkspaces) {
@@ -5235,9 +5571,9 @@ export async function createJJ(options) {
5235
5571
 
5236
5572
  if (args.workspace && toUpdate.length === 0) {
5237
5573
  // Look up the workspace to get its name for a better error message
5238
- let workspace = workspaces.get(args.workspace);
5574
+ let workspace = /** @type {any} */ (workspaces.get(args.workspace));
5239
5575
  if (!workspace) {
5240
- const allWorkspaces = workspaces.list();
5576
+ const allWorkspaces = /** @type {any[]} */ (workspaces.list());
5241
5577
  workspace = allWorkspaces.find((ws) => ws.name === args.workspace);
5242
5578
  }
5243
5579
  const workspaceName = workspace ? workspace.name : args.workspace;
@@ -5305,7 +5641,7 @@ export async function createJJ(options) {
5305
5641
  * Queue a background operation
5306
5642
  *
5307
5643
  * @param {Function} operation - Async operation
5308
- * @param {Object} [opts] - Options
5644
+ * @param {Record<string, any>} [opts] - Options
5309
5645
  */
5310
5646
  async queue(operation, opts) {
5311
5647
  if (!jj.backgroundOps) {
@@ -5319,7 +5655,7 @@ export async function createJJ(options) {
5319
5655
  /**
5320
5656
  * List background operations
5321
5657
  *
5322
- * @param {Object} [opts] - Filter options
5658
+ * @param {Record<string, any>} [opts] - Filter options
5323
5659
  */
5324
5660
  listOperations(opts) {
5325
5661
  if (!jj.backgroundOps) {
@@ -5331,7 +5667,7 @@ export async function createJJ(options) {
5331
5667
  /**
5332
5668
  * Enable auto-snapshot on file changes
5333
5669
  *
5334
- * @param {Object} [opts] - Options
5670
+ * @param {Record<string, any>} [opts] - Options
5335
5671
  */
5336
5672
  async enableAutoSnapshot(opts) {
5337
5673
  if (!jj.backgroundOps) {
@@ -5373,7 +5709,7 @@ export async function createJJ(options) {
5373
5709
  /**
5374
5710
  * List all bookmarks
5375
5711
  *
5376
- * @returns {Promise<Array>} Array of bookmarks with name and changeId
5712
+ * @returns {Promise<Array<any>>} Array of bookmarks with name and changeId
5377
5713
  */
5378
5714
  async list() {
5379
5715
  await bookmarks.load();
@@ -5383,9 +5719,7 @@ export async function createJJ(options) {
5383
5719
  /**
5384
5720
  * Set/create a bookmark (matches `jj bookmark set`)
5385
5721
  *
5386
- * @param {Object} args - Bookmark arguments
5387
- * @param {string} args.name - Bookmark name
5388
- * @param {string} args.changeId - Change ID to point to
5722
+ * @param {Record<string, any>} args - Bookmark arguments
5389
5723
  * @returns {Promise<Object>} Bookmark info
5390
5724
  */
5391
5725
  async set(args) {
@@ -5403,7 +5737,8 @@ export async function createJJ(options) {
5403
5737
  if (!args || !args.name || !args.changeId) {
5404
5738
  throw new JJError('INVALID_ARGUMENT', 'Missing name or changeId', {
5405
5739
  args,
5406
- suggestion: 'Provide both: { name: "main", changeId: "abc123..." } (or use target/change/revision instead of changeId)',
5740
+ suggestion:
5741
+ 'Provide both: { name: "main", changeId: "abc123..." } (or use target/change/revision instead of changeId)',
5407
5742
  });
5408
5743
  }
5409
5744
 
@@ -5433,9 +5768,7 @@ export async function createJJ(options) {
5433
5768
  *
5434
5769
  * Unlike `set()`, this will fail if the bookmark already exists.
5435
5770
  *
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)
5771
+ * @param {Record<string, any>} args - Bookmark arguments
5439
5772
  * @returns {Promise<Object>} Bookmark info
5440
5773
  */
5441
5774
  async create(args) {
@@ -5452,7 +5785,8 @@ export async function createJJ(options) {
5452
5785
 
5453
5786
  if (!args || !args.name) {
5454
5787
  throw new JJError('INVALID_ARGUMENT', 'Missing name argument', {
5455
- suggestion: 'Provide: { name: "main", changeId: "abc123..." } or { name: "main" } for working copy',
5788
+ suggestion:
5789
+ 'Provide: { name: "main", changeId: "abc123..." } or { name: "main" } for working copy',
5456
5790
  });
5457
5791
  }
5458
5792
 
@@ -5469,7 +5803,8 @@ export async function createJJ(options) {
5469
5803
  const existingBookmark = await bookmarks.get(args.name);
5470
5804
  if (existingBookmark) {
5471
5805
  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',
5806
+ suggestion:
5807
+ 'Use bookmark.set() to update an existing bookmark, or bookmark.move() to move it',
5473
5808
  });
5474
5809
  }
5475
5810
 
@@ -5496,9 +5831,7 @@ export async function createJJ(options) {
5496
5831
  /**
5497
5832
  * Move a bookmark to a different change (matches `jj bookmark move`)
5498
5833
  *
5499
- * @param {Object} args - Bookmark arguments
5500
- * @param {string} args.name - Bookmark name
5501
- * @param {string} args.to - Target change ID
5834
+ * @param {Record<string, any>} args - Bookmark arguments
5502
5835
  * @returns {Promise<Object>} Move result
5503
5836
  */
5504
5837
  async move(args) {
@@ -5518,7 +5851,8 @@ export async function createJJ(options) {
5518
5851
  if (!args || !args.name || !args.to) {
5519
5852
  throw new JJError('INVALID_ARGUMENT', 'Missing name or to', {
5520
5853
  args,
5521
- suggestion: 'Provide both: { name: "main", to: "abc123..." } (or use target/changeId/change/revision instead of to)',
5854
+ suggestion:
5855
+ 'Provide both: { name: "main", to: "abc123..." } (or use target/changeId/change/revision instead of to)',
5522
5856
  });
5523
5857
  }
5524
5858
 
@@ -5547,11 +5881,68 @@ export async function createJJ(options) {
5547
5881
  return { name: args.name, from: bookmarkChangeId, to: args.to };
5548
5882
  },
5549
5883
 
5884
+ /**
5885
+ * Advance a bookmark to a descendant change (matches `jj bookmark advance`,
5886
+ * jj v0.39). Unlike move(), advance() only moves the bookmark *forward*
5887
+ * along the graph — the target must be a descendant of the bookmark's
5888
+ * current position (defaults to the working copy).
5889
+ *
5890
+ * @param {Record<string, any>} args - Arguments
5891
+ * @returns {Promise<Object>} { name, from, to }
5892
+ */
5893
+ async advance(args) {
5894
+ if (args) {
5895
+ const alias = args.to || args.target || args.changeId || args.change || args.revision;
5896
+ if (alias) args = { ...args, to: alias };
5897
+ }
5898
+ if (!args || !args.name) {
5899
+ throw new JJError('INVALID_ARGUMENT', 'Missing bookmark name', {
5900
+ suggestion: 'Provide the bookmark name: { name: "main", to: "abc123..." }',
5901
+ });
5902
+ }
5903
+
5904
+ await bookmarks.load();
5905
+ await graph.load();
5906
+
5907
+ const from = await bookmarks.get(args.name);
5908
+ if (!from) {
5909
+ throw new JJError('NOT_FOUND', `Bookmark ${args.name} not found`);
5910
+ }
5911
+
5912
+ const to = args.to || workingCopy.getCurrentChangeId();
5913
+
5914
+ // The target must be a descendant of (or equal to) the current position.
5915
+ const descendants = new Set(await revset.getDescendants(from));
5916
+ if (to !== from && !descendants.has(to)) {
5917
+ throw new JJError(
5918
+ 'BOOKMARK_NOT_ADVANCEABLE',
5919
+ `Cannot advance bookmark ${args.name}: ${to.slice(0, 8)} is not a descendant of ${from.slice(0, 8)}`,
5920
+ { suggestion: 'Use bookmark.move() to move a bookmark backward or sideways' }
5921
+ );
5922
+ }
5923
+
5924
+ await bookmarks.move(args.name, to);
5925
+
5926
+ await oplog.recordOperation({
5927
+ timestamp: new Date().toISOString(),
5928
+ user: await getUserOplogInfo(),
5929
+ description: `bookmark advance ${args.name}`,
5930
+ parents: [],
5931
+ view: {
5932
+ bookmarks: { [args.name]: to },
5933
+ remoteBookmarks: {},
5934
+ heads: [],
5935
+ workingCopy: workingCopy.getCurrentChangeId(),
5936
+ },
5937
+ });
5938
+
5939
+ return { name: args.name, from, to };
5940
+ },
5941
+
5550
5942
  /**
5551
5943
  * Delete a bookmark (matches `jj bookmark delete`)
5552
5944
  *
5553
- * @param {Object} args - Bookmark arguments
5554
- * @param {string} args.name - Bookmark name to delete
5945
+ * @param {Record<string, any>} args - Bookmark arguments
5555
5946
  * @returns {Promise<Object>} Deletion result
5556
5947
  */
5557
5948
  async delete(args) {
@@ -5562,7 +5953,7 @@ export async function createJJ(options) {
5562
5953
  }
5563
5954
 
5564
5955
  await bookmarks.load();
5565
- const bookmark = bookmarks.get(args.name);
5956
+ const bookmark = await bookmarks.get(args.name);
5566
5957
  if (!bookmark) {
5567
5958
  throw new JJError('NOT_FOUND', `Bookmark ${args.name} not found`);
5568
5959
  }
@@ -5590,9 +5981,7 @@ export async function createJJ(options) {
5590
5981
  /**
5591
5982
  * Rename a bookmark (matches `jj bookmark rename`)
5592
5983
  *
5593
- * @param {Object} args - Bookmark arguments
5594
- * @param {string} args.oldName - Current bookmark name
5595
- * @param {string} args.newName - New bookmark name
5984
+ * @param {Record<string, any>} args - Bookmark arguments
5596
5985
  * @returns {Promise<Object>} Rename result
5597
5986
  */
5598
5987
  async rename(args) {
@@ -5633,9 +6022,7 @@ export async function createJJ(options) {
5633
6022
  /**
5634
6023
  * Track a remote bookmark (matches `jj bookmark track`)
5635
6024
  *
5636
- * @param {Object} args - Bookmark arguments
5637
- * @param {string} args.name - Bookmark name to track
5638
- * @param {string} [args.remote='origin'] - Remote name
6025
+ * @param {Record<string, any>} args - Bookmark arguments
5639
6026
  * @returns {Promise<Object>} Track result
5640
6027
  */
5641
6028
  async track(args) {
@@ -5650,6 +6037,7 @@ export async function createJJ(options) {
5650
6037
 
5651
6038
  // Store tracking info in bookmarks metadata
5652
6039
  await bookmarks.load();
6040
+ /** @type {Record<string, any>} */
5653
6041
  const tracking = bookmarks.tracking || {};
5654
6042
  tracking[args.name] = { remote, remoteName: args.name };
5655
6043
  bookmarks.tracking = tracking;
@@ -5675,8 +6063,7 @@ export async function createJJ(options) {
5675
6063
  /**
5676
6064
  * Untrack a remote bookmark (matches `jj bookmark untrack`)
5677
6065
  *
5678
- * @param {Object} args - Bookmark arguments
5679
- * @param {string} args.name - Bookmark name to untrack
6066
+ * @param {Record<string, any>} args - Bookmark arguments
5680
6067
  * @returns {Promise<Object>} Untrack result
5681
6068
  */
5682
6069
  async untrack(args) {
@@ -5688,6 +6075,7 @@ export async function createJJ(options) {
5688
6075
 
5689
6076
  // Remove tracking info
5690
6077
  await bookmarks.load();
6078
+ /** @type {Record<string, any>} */
5691
6079
  const tracking = bookmarks.tracking || {};
5692
6080
  const wasTracking = tracking[args.name];
5693
6081
  delete tracking[args.name];
@@ -5714,9 +6102,7 @@ export async function createJJ(options) {
5714
6102
  /**
5715
6103
  * Forget a remote bookmark (matches `jj bookmark forget`)
5716
6104
  *
5717
- * @param {Object} args - Bookmark arguments
5718
- * @param {string} args.name - Bookmark name to forget
5719
- * @param {string} [args.remote='origin'] - Remote name
6105
+ * @param {Record<string, any>} args - Bookmark arguments
5720
6106
  * @returns {Promise<Object>} Forget result
5721
6107
  */
5722
6108
  async forget(args) {
@@ -5730,6 +6116,7 @@ export async function createJJ(options) {
5730
6116
 
5731
6117
  // Untrack and remove local reference
5732
6118
  await bookmarks.load();
6119
+ /** @type {Record<string, any>} */
5733
6120
  const tracking = bookmarks.tracking || {};
5734
6121
  delete tracking[args.name];
5735
6122
  bookmarks.tracking = tracking;
@@ -5776,16 +6163,15 @@ export async function createJJ(options) {
5776
6163
  * Tags are immutable references that cannot be moved once created.
5777
6164
  * Unlike bookmarks, tags are permanent markers typically used for releases.
5778
6165
  *
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)
6166
+ * @param {Record<string, any>} args - Tag arguments
5782
6167
  * @returns {Promise<{name: string, changeId: string}>} Created tag
5783
6168
  * @throws {JJError} If tag already exists or name is invalid
5784
6169
  */
5785
6170
  async create(args) {
5786
6171
  if (!args || args.name === undefined || args.name === null) {
5787
6172
  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',
6173
+ suggestion:
6174
+ 'Provide: { name: "v1.0.0", changeId: "abc123..." } or { name: "v1.0.0" } for working copy',
5789
6175
  });
5790
6176
  }
5791
6177
 
@@ -5816,11 +6202,55 @@ export async function createJJ(options) {
5816
6202
  return result;
5817
6203
  },
5818
6204
 
6205
+ /**
6206
+ * Create or update (move) a tag (matches `jj tag set`, jj v0.35).
6207
+ *
6208
+ * Unlike create(), set() is an upsert: it will point an existing tag at a
6209
+ * new change instead of erroring.
6210
+ *
6211
+ * @param {Record<string, any>} args - Tag arguments
6212
+ * @returns {Promise<{name: string, changeId: string, updated: boolean}>}
6213
+ */
6214
+ async set(args) {
6215
+ if (!args || args.name === undefined || args.name === null) {
6216
+ throw new JJError('INVALID_ARGUMENT', 'Missing name argument', {
6217
+ suggestion: 'Provide: { name: "v1.0.0", changeId: "abc123..." }',
6218
+ });
6219
+ }
6220
+
6221
+ let changeId = args.changeId || args.change || args.revision;
6222
+ if (!changeId) {
6223
+ await workingCopy.load();
6224
+ changeId = workingCopy.getCurrentChangeId();
6225
+ }
6226
+
6227
+ await tags.load();
6228
+ const existed = await tags.exists(args.name);
6229
+ if (existed) {
6230
+ await tags.delete(args.name);
6231
+ }
6232
+ const result = await tags.create(args.name, changeId);
6233
+
6234
+ await oplog.recordOperation({
6235
+ timestamp: new Date().toISOString(),
6236
+ user: await getUserOplogInfo(),
6237
+ description: `tag set ${args.name}`,
6238
+ parents: [],
6239
+ view: {
6240
+ bookmarks: {},
6241
+ remoteBookmarks: {},
6242
+ heads: [],
6243
+ workingCopy: workingCopy.getCurrentChangeId(),
6244
+ },
6245
+ });
6246
+
6247
+ return { ...result, updated: existed };
6248
+ },
6249
+
5819
6250
  /**
5820
6251
  * List all tags
5821
6252
  *
5822
- * @param {Object} [args] - Optional filter arguments
5823
- * @param {string} [args.pattern] - Optional glob pattern (e.g., "v1*")
6253
+ * @param {Record<string, any>} [args] - Optional filter arguments
5824
6254
  * @returns {Promise<Array<{name: string, changeId: string}>>} List of tags
5825
6255
  */
5826
6256
  async list(args) {
@@ -5831,8 +6261,7 @@ export async function createJJ(options) {
5831
6261
  /**
5832
6262
  * Delete a tag
5833
6263
  *
5834
- * @param {Object} args - Tag arguments
5835
- * @param {string} args.name - Tag name to delete
6264
+ * @param {Record<string, any>} args - Tag arguments
5836
6265
  * @returns {Promise<{deleted: string}>} Deletion result
5837
6266
  * @throws {JJError} If tag doesn't exist
5838
6267
  */
@@ -5870,7 +6299,7 @@ export async function createJJ(options) {
5870
6299
  /**
5871
6300
  * Fetch from remote (alias to jj.git.fetch for convenience)
5872
6301
  *
5873
- * @param {Object} args - Fetch arguments
6302
+ * @param {Record<string, any>} args - Fetch arguments
5874
6303
  * @returns {Promise<Object>} Fetch result
5875
6304
  */
5876
6305
  async fetch(args) {
@@ -5880,7 +6309,7 @@ export async function createJJ(options) {
5880
6309
  /**
5881
6310
  * Push to remote (alias to jj.git.push for convenience)
5882
6311
  *
5883
- * @param {Object} args - Push arguments
6312
+ * @param {Record<string, any>} args - Push arguments
5884
6313
  * @returns {Promise<Object>} Push result
5885
6314
  */
5886
6315
  async push(args) {
@@ -5890,7 +6319,7 @@ export async function createJJ(options) {
5890
6319
  /**
5891
6320
  * Add a remote (alias to jj.git.remote.add for convenience)
5892
6321
  *
5893
- * @param {Object} args - Remote arguments
6322
+ * @param {Record<string, any>} args - Remote arguments
5894
6323
  * @returns {Promise<Object>} Added remote info
5895
6324
  */
5896
6325
  async add(args) {
@@ -5905,8 +6334,7 @@ export async function createJJ(options) {
5905
6334
  /**
5906
6335
  * Get a configuration value (matches `jj config get`)
5907
6336
  *
5908
- * @param {Object} args - Config arguments
5909
- * @param {string} args.name - Config key (e.g., 'user.name')
6337
+ * @param {Record<string, any>} args - Config arguments
5910
6338
  * @returns {Promise<any>} Config value
5911
6339
  */
5912
6340
  async get(args) {
@@ -5931,9 +6359,7 @@ export async function createJJ(options) {
5931
6359
  /**
5932
6360
  * Set a configuration value (matches `jj config set`)
5933
6361
  *
5934
- * @param {Object} args - Config arguments
5935
- * @param {string} args.name - Config key (e.g., 'user.name')
5936
- * @param {any} args.value - Config value
6362
+ * @param {Record<string, any>} args - Config arguments
5937
6363
  * @returns {Promise<Object>} Set result
5938
6364
  */
5939
6365
  async set(args) {
@@ -5980,9 +6406,7 @@ export async function createJJ(options) {
5980
6406
  * v0.35.0: Loads workspace-config.json if present
5981
6407
  * v0.36.0: Accepts optional config object for programmatic configuration
5982
6408
  *
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
6409
+ * @param {Record<string, any>} [opts] - Optional config options
5986
6410
  * @returns {Promise<void>}
5987
6411
  *
5988
6412
  * @example
@@ -6077,8 +6501,7 @@ export async function createJJ(options) {
6077
6501
  /**
6078
6502
  * Set sparse patterns (replaces all existing patterns)
6079
6503
  *
6080
- * @param {Object} args - Arguments
6081
- * @param {string[]} args.patterns - Array of glob patterns
6504
+ * @param {Record<string, any>} args - Arguments
6082
6505
  * @returns {Promise<{patterns: string[]}>} Result with current patterns
6083
6506
  */
6084
6507
  async set(args) {
@@ -6112,8 +6535,7 @@ export async function createJJ(options) {
6112
6535
  /**
6113
6536
  * Add patterns to sparse checkout
6114
6537
  *
6115
- * @param {Object} args - Arguments
6116
- * @param {string[]} args.patterns - Array of glob patterns to add
6538
+ * @param {Record<string, any>} args - Arguments
6117
6539
  * @returns {Promise<{patterns: string[]}>} Result with current patterns
6118
6540
  */
6119
6541
  async add(args) {
@@ -6157,8 +6579,7 @@ export async function createJJ(options) {
6157
6579
  /**
6158
6580
  * Remove patterns from sparse checkout
6159
6581
  *
6160
- * @param {Object} args - Arguments
6161
- * @param {string[]} args.patterns - Array of glob patterns to remove
6582
+ * @param {Record<string, any>} args - Arguments
6162
6583
  * @returns {Promise<{patterns: string[]}>} Result with current patterns
6163
6584
  */
6164
6585
  async remove(args) {
@@ -6172,7 +6593,7 @@ export async function createJJ(options) {
6172
6593
  const currentPatterns = workingCopy.getSparsePatterns();
6173
6594
 
6174
6595
  // Remove specified patterns
6175
- const updatedPatterns = currentPatterns.filter(p => !args.patterns.includes(p));
6596
+ const updatedPatterns = currentPatterns.filter((p) => !args.patterns.includes(p));
6176
6597
 
6177
6598
  await workingCopy.setSparsePatterns(updatedPatterns);
6178
6599
  await workingCopy.save();
@@ -6241,9 +6662,7 @@ export async function createJJ(options) {
6241
6662
  /**
6242
6663
  * Start bisect session
6243
6664
  *
6244
- * @param {Object} args - Bisect arguments
6245
- * @param {string} args.good - Known good change ID
6246
- * @param {string} args.bad - Known bad change ID
6665
+ * @param {Record<string, any>} args - Bisect arguments
6247
6666
  * @returns {Promise<Object>} Bisect state
6248
6667
  */
6249
6668
  async start(args) {
@@ -6256,7 +6675,7 @@ export async function createJJ(options) {
6256
6675
  await graph.load();
6257
6676
 
6258
6677
  // Check if bisect is already active
6259
- const existingState = await storage.read('bisect/state.json');
6678
+ const existingState = /** @type {any} */ (await storage.read('bisect/state.json'));
6260
6679
  if (existingState && existingState.active) {
6261
6680
  throw new JJError('BISECT_ALREADY_ACTIVE', 'Bisect session already in progress', {
6262
6681
  suggestion: 'Use bisect.reset() to end current session first',
@@ -6279,9 +6698,8 @@ export async function createJJ(options) {
6279
6698
 
6280
6699
  // Pick the middle change
6281
6700
  // 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;
6701
+ const current =
6702
+ candidates.length > 0 ? candidates[Math.floor(candidates.length / 2)] : args.bad;
6285
6703
 
6286
6704
  const bisectState = {
6287
6705
  active: true,
@@ -6304,7 +6722,7 @@ export async function createJJ(options) {
6304
6722
  * @returns {Promise<Object>} Updated bisect state
6305
6723
  */
6306
6724
  async good() {
6307
- const bisectState = await storage.read('bisect/state.json');
6725
+ const bisectState = /** @type {any} */ (await storage.read('bisect/state.json'));
6308
6726
 
6309
6727
  if (!bisectState || !bisectState.active) {
6310
6728
  throw new JJError('BISECT_NOT_ACTIVE', 'No active bisect session');
@@ -6329,7 +6747,7 @@ export async function createJJ(options) {
6329
6747
  * @returns {Promise<Object>} Updated bisect state
6330
6748
  */
6331
6749
  async bad() {
6332
- const bisectState = await storage.read('bisect/state.json');
6750
+ const bisectState = /** @type {any} */ (await storage.read('bisect/state.json'));
6333
6751
 
6334
6752
  if (!bisectState || !bisectState.active) {
6335
6753
  throw new JJError('BISECT_NOT_ACTIVE', 'No active bisect session');
@@ -6354,7 +6772,7 @@ export async function createJJ(options) {
6354
6772
  * @returns {Promise<Object>} Updated bisect state
6355
6773
  */
6356
6774
  async skip() {
6357
- const bisectState = await storage.read('bisect/state.json');
6775
+ const bisectState = /** @type {any} */ (await storage.read('bisect/state.json'));
6358
6776
 
6359
6777
  if (!bisectState || !bisectState.active) {
6360
6778
  throw new JJError('BISECT_NOT_ACTIVE', 'No active bisect session');
@@ -6362,7 +6780,9 @@ export async function createJJ(options) {
6362
6780
 
6363
6781
  // Remove current from candidates
6364
6782
  if (bisectState.current) {
6365
- bisectState.candidates = bisectState.candidates.filter(c => c !== bisectState.current);
6783
+ bisectState.candidates = bisectState.candidates.filter(
6784
+ (/** @type {any} */ c) => c !== bisectState.current
6785
+ );
6366
6786
  }
6367
6787
 
6368
6788
  // Pick next candidate
@@ -6389,7 +6809,7 @@ export async function createJJ(options) {
6389
6809
  * @returns {Promise<Object>} Bisect state
6390
6810
  */
6391
6811
  async status() {
6392
- const bisectState = await storage.read('bisect/state.json');
6812
+ const bisectState = /** @type {any} */ (await storage.read('bisect/state.json'));
6393
6813
  return bisectState || { active: false };
6394
6814
  },
6395
6815
 
@@ -6406,7 +6826,7 @@ export async function createJJ(options) {
6406
6826
 
6407
6827
  // Find all ancestors of bad that are descendants of good
6408
6828
  const badAncestors = graph.getAncestors(badId);
6409
- const goodAncestors = graph.getAncestors(goodId);
6829
+ const _goodAncestors = graph.getAncestors(goodId);
6410
6830
 
6411
6831
  // Candidates are changes that are:
6412
6832
  // - Ancestors of bad (or bad itself)
@@ -6436,7 +6856,7 @@ export async function createJJ(options) {
6436
6856
  * Update bisect state after marking good/bad/skip
6437
6857
  *
6438
6858
  * @private
6439
- * @param {Object} bisectState - Current bisect state
6859
+ * @param {any} bisectState - Current bisect state
6440
6860
  */
6441
6861
  async _updateBisectState(bisectState) {
6442
6862
  await graph.load();
@@ -6482,7 +6902,8 @@ export async function createJJ(options) {
6482
6902
  }
6483
6903
 
6484
6904
  bisectState.candidates = newCandidates;
6485
- bisectState.remaining = newCandidates.length > 0 ? Math.ceil(Math.log2(newCandidates.length + 1)) : 0;
6905
+ bisectState.remaining =
6906
+ newCandidates.length > 0 ? Math.ceil(Math.log2(newCandidates.length + 1)) : 0;
6486
6907
 
6487
6908
  // Pick the middle candidate
6488
6909
  if (newCandidates.length > 0) {
@@ -6495,7 +6916,7 @@ export async function createJJ(options) {
6495
6916
  // The first bad change is the one in the bad set that has no bad ancestors
6496
6917
  for (const badId of bisectState.bad) {
6497
6918
  const badAncestors = graph.getAncestors(badId);
6498
- const hasChildBad = bisectState.bad.some(otherId => {
6919
+ const hasChildBad = bisectState.bad.some((/** @type {any} */ otherId) => {
6499
6920
  if (otherId === badId) return false;
6500
6921
  return badAncestors.includes(otherId);
6501
6922
  });
@@ -6512,13 +6933,16 @@ export async function createJJ(options) {
6512
6933
  /**
6513
6934
  * Show file differences between revisions (matches `jj diff`)
6514
6935
  *
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
6936
+ * @param {Record<string, any>} [args={}] - Diff arguments
6519
6937
  * @returns {Promise<Object>} Diff result with changed files
6520
6938
  */
6521
6939
  async diff(args = {}) {
6940
+ // v1.6: snapshot the working copy first so diffs against @ reflect
6941
+ // out-of-band edits (only when diffing the working copy, i.e. no explicit `to`).
6942
+ if (!args.to) {
6943
+ await autoSnapshotWorkingCopy();
6944
+ }
6945
+
6522
6946
  await graph.load();
6523
6947
  await workingCopy.load();
6524
6948
 
@@ -6574,8 +6998,7 @@ export async function createJJ(options) {
6574
6998
  /**
6575
6999
  * Move working copy to next child revision (matches `jj next`)
6576
7000
  *
6577
- * @param {Object} [args={}] - Next arguments
6578
- * @param {number} [args.offset=1] - Number of generations to move forward
7001
+ * @param {Record<string, any>} [args={}] - Next arguments
6579
7002
  * @returns {Promise<Object>} Updated working copy info
6580
7003
  */
6581
7004
  async next(args = {}) {
@@ -6593,7 +7016,9 @@ export async function createJJ(options) {
6593
7016
  // Find children
6594
7017
  await graph.load();
6595
7018
  const allChanges = await graph.getAllChanges();
6596
- const children = allChanges.filter(c => c.parents && c.parents.includes(currentChangeId));
7019
+ const children = allChanges.filter(
7020
+ (/** @type {any} */ c) => c.parents && c.parents.includes(currentChangeId)
7021
+ );
6597
7022
 
6598
7023
  if (children.length === 0) {
6599
7024
  throw new JJError('NO_CHILDREN', 'No child revisions found');
@@ -6604,7 +7029,9 @@ export async function createJJ(options) {
6604
7029
 
6605
7030
  // Move forward 'offset' times
6606
7031
  for (let i = 1; i < offset; i++) {
6607
- const nextChildren = allChanges.filter(c => c.parents && c.parents.includes(targetChange.changeId));
7032
+ const nextChildren = allChanges.filter(
7033
+ (/** @type {any} */ c) => c.parents && c.parents.includes(targetChange.changeId)
7034
+ );
6608
7035
  if (nextChildren.length === 0) {
6609
7036
  throw new JJError('INSUFFICIENT_CHILDREN', `Cannot move forward ${offset} generations`);
6610
7037
  }
@@ -6625,8 +7052,7 @@ export async function createJJ(options) {
6625
7052
  /**
6626
7053
  * Move working copy to previous parent revision (matches `jj prev`)
6627
7054
  *
6628
- * @param {Object} [args={}] - Prev arguments
6629
- * @param {number} [args.offset=1] - Number of generations to move backward
7055
+ * @param {Record<string, any>} [args={}] - Prev arguments
6630
7056
  * @returns {Promise<Object>} Updated working copy info
6631
7057
  */
6632
7058
  async prev(args = {}) {
@@ -6665,9 +7091,7 @@ export async function createJJ(options) {
6665
7091
  /**
6666
7092
  * Create copies of changes (matches `jj duplicate`)
6667
7093
  *
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
7094
+ * @param {Record<string, any>} [args={}] - Duplicate arguments
6671
7095
  * @returns {Promise<Object>} Duplication result with new change IDs
6672
7096
  */
6673
7097
  async duplicate(args = {}) {
@@ -6676,8 +7100,8 @@ export async function createJJ(options) {
6676
7100
  await userConfig.load();
6677
7101
 
6678
7102
  // Support both changeId (singular) and changes (plural)
6679
- const changesToDup = args.changes ||
6680
- (args.changeId ? [args.changeId] : [workingCopy.getCurrentChangeId()]);
7103
+ const changesToDup =
7104
+ args.changes || (args.changeId ? [args.changeId] : [workingCopy.getCurrentChangeId()]);
6681
7105
  const duplicatedChanges = [];
6682
7106
 
6683
7107
  for (const changeId of changesToDup) {
@@ -6688,7 +7112,7 @@ export async function createJJ(options) {
6688
7112
 
6689
7113
  // Create new change as a copy
6690
7114
  const newChangeId = generateChangeId();
6691
- const user = userConfig.getUser();
7115
+ const _user = /** @type {any} */ (userConfig.getUser());
6692
7116
  const newChange = {
6693
7117
  ...originalChange,
6694
7118
  changeId: newChangeId,
@@ -6713,7 +7137,7 @@ export async function createJJ(options) {
6713
7137
  view: {
6714
7138
  bookmarks: {},
6715
7139
  remoteBookmarks: {},
6716
- heads: duplicatedChanges.map(d => d.duplicate),
7140
+ heads: duplicatedChanges.map((d) => d.duplicate),
6717
7141
  workingCopy: workingCopy.getCurrentChangeId(),
6718
7142
  },
6719
7143
  });
@@ -6721,17 +7145,14 @@ export async function createJJ(options) {
6721
7145
  // Return both formats for compatibility
6722
7146
  return {
6723
7147
  duplicated: duplicatedChanges,
6724
- changeIds: duplicatedChanges.map(d => d.duplicate)
7148
+ changeIds: duplicatedChanges.map((d) => d.duplicate),
6725
7149
  };
6726
7150
  },
6727
7151
 
6728
7152
  /**
6729
7153
  * Restore paths from another revision (matches `jj restore`)
6730
7154
  *
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)
7155
+ * @param {Record<string, any>} args - Restore arguments
6735
7156
  * @returns {Promise<Object>} Restore result
6736
7157
  */
6737
7158
  async restore(args = {}) {
@@ -6793,9 +7214,7 @@ export async function createJJ(options) {
6793
7214
  * This operation takes multiple changes and gives them the same parent,
6794
7215
  * making them siblings in the change graph instead of ancestors/descendants.
6795
7216
  *
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)
7217
+ * @param {Record<string, any>} args - Arguments
6799
7218
  * @returns {Promise<Object>} Operation result with parallelized changes
6800
7219
  *
6801
7220
  * @example
@@ -6912,7 +7331,7 @@ export async function createJJ(options) {
6912
7331
  parent: parentId,
6913
7332
  };
6914
7333
  },
6915
- };
7334
+ });
6916
7335
 
6917
7336
  // ============================================================================
6918
7337
  // UNSUPPORTED FEATURE STUBS
@@ -6925,17 +7344,14 @@ export async function createJJ(options) {
6925
7344
  * @status UNSUPPORTED - Requires interactive diff editor
6926
7345
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
6927
7346
  */
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
- );
7347
+ jj.diffedit = async function (/** @type {any} */ _args) {
7348
+ throw new JJError('UNSUPPORTED_OPERATION', 'diffedit requires interactive diff editor', {
7349
+ feature: 'diffedit',
7350
+ reason: 'Interactive tools not supported in library API',
7351
+ alternative: 'Use jj.diff() to view changes, then jj.edit() to modify the change',
7352
+ suggestion:
7353
+ 'For programmatic editing, use jj.write() to update files and jj.describe() to update the description',
7354
+ });
6939
7355
  };
6940
7356
 
6941
7357
  /**
@@ -6944,55 +7360,19 @@ export async function createJJ(options) {
6944
7360
  * @status UNSUPPORTED - Requires external formatters
6945
7361
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
6946
7362
  */
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
- );
7363
+ jj.fix = async function (/** @type {any} */ _args) {
7364
+ throw new JJError('UNSUPPORTED_OPERATION', 'Automatic formatting requires external tools', {
7365
+ feature: 'fix',
7366
+ reason: 'External formatters (prettier, eslint, etc.) must be integrated separately',
7367
+ alternative: 'Run formatters separately, then use jj.write() to update files',
7368
+ suggestion: 'Consider using pre-commit hooks to run formatters automatically',
7369
+ });
6977
7370
  };
6978
7371
 
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
- };
7372
+ // NOTE: jj.sign() / jj.unsign() are implemented as real (metadata-based)
7373
+ // methods in the main API object above (v1.5). The old UNSUPPORTED stubs were
7374
+ // removed — a pure-JS library can't verify keys, but it can record and expose
7375
+ // signature metadata for the signed() revset and Git-layer integration.
6996
7376
 
6997
7377
  /**
6998
7378
  * util - CLI utility commands (STUB namespace)
@@ -7005,15 +7385,11 @@ export async function createJJ(options) {
7005
7385
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
7006
7386
  */
7007
7387
  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
- );
7388
+ throw new JJError('UNSUPPORTED_OPERATION', 'Shell completion generation is CLI-specific', {
7389
+ feature: 'util.completion',
7390
+ reason: 'Completions are for command-line shells, not library APIs',
7391
+ alternative: 'Not applicable - use JJ CLI for shell completions',
7392
+ });
7017
7393
  },
7018
7394
 
7019
7395
  /**
@@ -7021,31 +7397,23 @@ export async function createJJ(options) {
7021
7397
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
7022
7398
  */
7023
7399
  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
- );
7400
+ throw new JJError('UNSUPPORTED_OPERATION', 'Garbage collection is a CLI utility', {
7401
+ feature: 'util.gc',
7402
+ reason: 'Storage management handled automatically by isomorphic-jj',
7403
+ alternative: 'Repository cleanup happens automatically',
7404
+ });
7033
7405
  },
7034
7406
 
7035
7407
  /**
7036
7408
  * exec - Execute command with JJ environment (STUB)
7037
7409
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
7038
7410
  */
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
- );
7411
+ async exec(/** @type {any} */ _args) {
7412
+ throw new JJError('UNSUPPORTED_OPERATION', 'Command execution is a CLI utility', {
7413
+ feature: 'util.exec',
7414
+ reason: 'Shell command execution not appropriate for library API',
7415
+ alternative: 'Use Node.js child_process module directly if needed',
7416
+ });
7049
7417
  },
7050
7418
 
7051
7419
  /**
@@ -7053,15 +7421,11 @@ export async function createJJ(options) {
7053
7421
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
7054
7422
  */
7055
7423
  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
- );
7424
+ throw new JJError('UNSUPPORTED_OPERATION', 'Config schema output is a CLI utility', {
7425
+ feature: 'util.configSchema',
7426
+ reason: 'Schema introspection not needed for programmatic usage',
7427
+ alternative: 'See TypeScript types or API documentation for config structure',
7428
+ });
7065
7429
  },
7066
7430
  };
7067
7431
 
@@ -7075,17 +7439,13 @@ export async function createJJ(options) {
7075
7439
  * upload - Upload changes to Gerrit (STUB)
7076
7440
  * @throws {JJError} Always throws UNSUPPORTED_OPERATION
7077
7441
  */
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
- );
7442
+ async upload(/** @type {any} */ _args) {
7443
+ throw new JJError('UNSUPPORTED_OPERATION', 'Gerrit integration not supported', {
7444
+ feature: 'gerrit.upload',
7445
+ reason: 'Gerrit-specific features not implemented',
7446
+ alternative: 'Use jj.git.push() with appropriate Gerrit remote configuration',
7447
+ suggestion: 'Configure Gerrit remote and use standard Git push with refs/for/ syntax',
7448
+ });
7089
7449
  },
7090
7450
  };
7091
7451