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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Storage Manager for isomorphic-jj
3
- *
3
+ *
4
4
  * Manages JSON storage in .jj directory with atomic writes and caching.
5
5
  */
6
6
 
@@ -8,15 +8,15 @@ import { JJError } from '../utils/errors.js';
8
8
 
9
9
  export class Storage {
10
10
  /**
11
- * @param {Object} fs - Filesystem implementation (Node fs, LightningFS, etc.)
11
+ * @param {any} fs - Filesystem implementation (Node fs, LightningFS, etc.)
12
12
  * @param {string} dir - Repository directory path
13
13
  */
14
14
  constructor(fs, dir) {
15
15
  this.fs = fs;
16
16
  this.dir = dir;
17
17
  this.jjDir = `${dir}/.jj`;
18
- this.repoDir = `${dir}/.jj/repo`; // Core repo data
19
- this.workingCopyDir = `${dir}/.jj/working_copy`; // Default workspace working copy
18
+ this.repoDir = `${dir}/.jj/repo`; // Core repo data
19
+ this.workingCopyDir = `${dir}/.jj/working_copy`; // Default workspace working copy
20
20
  this.cache = new Map();
21
21
  }
22
22
 
@@ -44,16 +44,20 @@ export class Storage {
44
44
  // Create default workspace directory
45
45
  await this.fs.promises.mkdir(`${this.workingCopyDir}/default`, { recursive: true });
46
46
  } catch (error) {
47
- throw new JJError('STORAGE_INIT_FAILED', `Failed to initialize .jj directory: ${error.message}`, {
48
- dir: this.jjDir,
49
- originalError: error,
50
- });
47
+ throw new JJError(
48
+ 'STORAGE_INIT_FAILED',
49
+ `Failed to initialize .jj directory: ${error.message}`,
50
+ {
51
+ dir: this.jjDir,
52
+ originalError: error,
53
+ }
54
+ );
51
55
  }
52
56
  }
53
57
 
54
58
  /**
55
59
  * Read and parse JSON file
56
- *
60
+ *
57
61
  * @param {string} path - Relative path from .jj directory
58
62
  * @returns {Promise<Object|null>} Parsed JSON or null if file doesn't exist
59
63
  */
@@ -83,7 +87,7 @@ export class Storage {
83
87
 
84
88
  /**
85
89
  * Write JSON file atomically
86
- *
90
+ *
87
91
  * @param {string} path - Relative path from .jj directory
88
92
  * @param {Object|string} data - Data to write (object will be stringified)
89
93
  */
@@ -111,7 +115,9 @@ export class Storage {
111
115
  // Clean up temp file
112
116
  try {
113
117
  await this.fs.promises.unlink(tmpPath);
114
- } catch {}
118
+ } catch {
119
+ // Best-effort cleanup; ignore if the temp file is already gone.
120
+ }
115
121
 
116
122
  throw new JJError('STORAGE_WRITE_FAILED', `Failed to write ${path}: ${error.message}`, {
117
123
  path: fullPath,
@@ -122,9 +128,9 @@ export class Storage {
122
128
 
123
129
  /**
124
130
  * Read JSONL file (newline-delimited JSON)
125
- *
131
+ *
126
132
  * @param {string} path - Relative path from .jj directory
127
- * @returns {Promise<Array>} Array of parsed JSON objects
133
+ * @returns {Promise<Array<any>>} Array of parsed JSON objects
128
134
  */
129
135
  async readLines(path) {
130
136
  const fullPath = `${this.jjDir}/${path}`;
@@ -134,8 +140,8 @@ export class Storage {
134
140
  return content
135
141
  .trim()
136
142
  .split('\n')
137
- .filter((line) => line.length > 0)
138
- .map((line) => JSON.parse(line));
143
+ .filter((/** @type {any} */ line) => line.length > 0)
144
+ .map((/** @type {any} */ line) => JSON.parse(line));
139
145
  } catch (error) {
140
146
  if (error.code === 'ENOENT') {
141
147
  return [];
@@ -149,7 +155,7 @@ export class Storage {
149
155
 
150
156
  /**
151
157
  * Append line to JSONL file
152
- *
158
+ *
153
159
  * @param {string} path - Relative path from .jj directory
154
160
  * @param {string} line - JSON string to append
155
161
  */
@@ -177,13 +183,13 @@ export class Storage {
177
183
 
178
184
  /**
179
185
  * Check if file exists
180
- *
186
+ *
181
187
  * @param {string} path - Relative path from .jj directory
182
188
  * @returns {Promise<boolean>}
183
189
  */
184
190
  async exists(path) {
185
191
  const fullPath = `${this.jjDir}/${path}`;
186
-
192
+
187
193
  try {
188
194
  await this.fs.promises.stat(fullPath);
189
195
  return true;
@@ -194,7 +200,7 @@ export class Storage {
194
200
 
195
201
  /**
196
202
  * Invalidate cache
197
- *
203
+ *
198
204
  * @param {string} [path] - Specific path to invalidate, or all if not provided
199
205
  */
200
206
  invalidateCache(path) {
@@ -49,7 +49,7 @@ function matchesPattern(name, pattern) {
49
49
 
50
50
  export class TagStore {
51
51
  /**
52
- * @param {Object} fs - Filesystem interface
52
+ * @param {any} fs - Filesystem interface
53
53
  * @param {string} jjDir - Path to .jj directory
54
54
  */
55
55
  constructor(fs, jjDir) {
@@ -60,7 +60,7 @@ export class TagStore {
60
60
 
61
61
  /**
62
62
  * Loads tags from storage
63
- * @returns {Promise<Object>} Map of tag name to changeId
63
+ * @returns {Promise<Record<string, any>>} Map of tag name to changeId
64
64
  */
65
65
  async load() {
66
66
  try {
@@ -80,6 +80,8 @@ export class UserConfig {
80
80
 
81
81
  /**
82
82
  * Deep merge two config objects (workspace overrides global)
83
+ * @param {any} target - Base config object
84
+ * @param {any} source - Overriding config object
83
85
  * @private
84
86
  */
85
87
  _deepMerge(target, source) {
@@ -171,7 +173,7 @@ export class UserConfig {
171
173
  }
172
174
 
173
175
  const parts = key.split('.');
174
- const lastPart = parts.pop();
176
+ const lastPart = /** @type {string} */ (parts.pop());
175
177
  let target = this.config;
176
178
 
177
179
  for (const part of parts) {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * WorkingCopy - Manages working copy state and file tracking
3
- *
3
+ *
4
4
  * Tracks file states (mtime, size, mode) for efficient modification detection.
5
5
  */
6
6
 
@@ -10,7 +10,7 @@ import { validateChangeId, validatePath } from '../utils/validation.js';
10
10
  export class WorkingCopy {
11
11
  /**
12
12
  * @param {Storage} storage - Storage manager instance
13
- * @param {Object} fs - Filesystem implementation
13
+ * @param {any} fs - Filesystem implementation
14
14
  * @param {string} dir - Repository directory
15
15
  * @param {string} [workspaceId='default'] - Workspace identifier
16
16
  */
@@ -85,8 +85,8 @@ export class WorkingCopy {
85
85
 
86
86
  /**
87
87
  * Get current working copy state
88
- *
89
- * @returns {Object} Working copy state
88
+ *
89
+ * @returns {Promise<Record<string, any>>} Working copy state
90
90
  */
91
91
  async getState() {
92
92
  if (!this.state) {
@@ -97,7 +97,7 @@ export class WorkingCopy {
97
97
 
98
98
  /**
99
99
  * Get current change ID
100
- *
100
+ *
101
101
  * @returns {string} Current change ID
102
102
  */
103
103
  getCurrentChangeId() {
@@ -111,7 +111,7 @@ export class WorkingCopy {
111
111
 
112
112
  /**
113
113
  * Set current change ID
114
- *
114
+ *
115
115
  * @param {string} changeId - New change ID
116
116
  */
117
117
  async setCurrentChange(changeId) {
@@ -127,7 +127,7 @@ export class WorkingCopy {
127
127
 
128
128
  /**
129
129
  * Track a file in the working copy
130
- *
130
+ *
131
131
  * @param {string} path - File path relative to repo root
132
132
  * @param {Object} fileState - File state (mtime, size, mode, hash)
133
133
  */
@@ -160,9 +160,9 @@ export class WorkingCopy {
160
160
 
161
161
  /**
162
162
  * Get list of modified files
163
- *
163
+ *
164
164
  * Uses mtime + size comparison for fast detection.
165
- *
165
+ *
166
166
  * @returns {Promise<Array<string>>} Array of modified file paths
167
167
  */
168
168
  async getModifiedFiles() {
@@ -198,6 +198,122 @@ export class WorkingCopy {
198
198
  return modified;
199
199
  }
200
200
 
201
+ /**
202
+ * Recursively walk the working directory on disk and return every file path
203
+ * relative to the repo root (v1.6).
204
+ *
205
+ * This is the foundation of automatic snapshotting: unlike listFiles() (which
206
+ * only returns files already tracked in state), this discovers files created
207
+ * out-of-band (editors, shell, git checkout). The .git and .jj metadata dirs
208
+ * and node_modules are always excluded.
209
+ *
210
+ * @param {object} [opts]
211
+ * @param {string[]} [opts.exclude] - Additional top-level directory/file names to skip
212
+ * @returns {Promise<Array<string>>} Repo-relative file paths (forward-slash separated)
213
+ */
214
+ async walk(opts = {}) {
215
+ const exclude = new Set(['.git', '.jj', 'node_modules', ...(opts.exclude || [])]);
216
+ /** @type {string[]} */
217
+ const results = [];
218
+
219
+ /**
220
+ * @param {string} current - Absolute directory path
221
+ * @param {string} prefix - Repo-relative prefix
222
+ */
223
+ const recurse = async (current, prefix) => {
224
+ /** @type {string[]} */
225
+ let entries;
226
+ try {
227
+ entries = await this.fs.promises.readdir(current);
228
+ } catch {
229
+ return; // not a readable directory
230
+ }
231
+ for (const name of entries) {
232
+ if (exclude.has(name)) continue;
233
+ const full = `${current}/${name}`;
234
+ const rel = prefix ? `${prefix}/${name}` : name;
235
+ let stats;
236
+ try {
237
+ stats = await this.fs.promises.stat(full);
238
+ } catch {
239
+ // Some in-memory filesystems don't materialize directory entries;
240
+ // treat an unstattable name as a possible directory and recurse.
241
+ await recurse(full, rel);
242
+ continue;
243
+ }
244
+ if (stats.isDirectory()) {
245
+ await recurse(full, rel);
246
+ } else if (stats.isFile()) {
247
+ results.push(rel);
248
+ }
249
+ }
250
+ };
251
+
252
+ await recurse(this.dir, '');
253
+ return results;
254
+ }
255
+
256
+ /**
257
+ * Snapshot the working directory: scan the disk, then reconcile tracked file
258
+ * state with what actually exists (v1.6, mirrors jj's snapshot-before-command).
259
+ *
260
+ * Newly-seen files are tracked, changed files (by mtime/size) are updated, and
261
+ * tracked-but-missing files are untracked. Sparse patterns are honored.
262
+ *
263
+ * @param {object} [opts]
264
+ * @param {string[]} [opts.exclude] - Additional names to skip while walking
265
+ * @returns {Promise<{added: string[], modified: string[], deleted: string[]}>}
266
+ */
267
+ async snapshot(opts = {}) {
268
+ if (!this.state) {
269
+ await this.load();
270
+ }
271
+
272
+ const onDisk = await this.walk(opts);
273
+ const trackedBefore = new Set(Object.keys(this.state.fileStates));
274
+ const seen = new Set();
275
+ /** @type {string[]} */
276
+ const added = [];
277
+ /** @type {string[]} */
278
+ const modified = [];
279
+
280
+ for (const rel of onDisk) {
281
+ // Respect sparse checkout: don't snapshot files outside the sparse set.
282
+ if (!this.matchesSparsePatterns(rel)) continue;
283
+ seen.add(rel);
284
+
285
+ let stats;
286
+ try {
287
+ stats = await this.fs.promises.stat(`${this.dir}/${rel}`);
288
+ } catch {
289
+ continue;
290
+ }
291
+
292
+ const prev = this.state.fileStates[rel];
293
+ const next = { mtime: stats.mtime, size: stats.size, mode: stats.mode };
294
+
295
+ if (!prev) {
296
+ this.state.fileStates[rel] = next;
297
+ added.push(rel);
298
+ } else if (prev.mtime !== stats.mtime || prev.size !== stats.size) {
299
+ this.state.fileStates[rel] = next;
300
+ modified.push(rel);
301
+ }
302
+ }
303
+
304
+ /** @type {string[]} */
305
+ const deleted = [];
306
+ for (const rel of trackedBefore) {
307
+ if (!seen.has(rel)) {
308
+ delete this.state.fileStates[rel];
309
+ deleted.push(rel);
310
+ }
311
+ }
312
+
313
+ await this.save();
314
+ return { added, modified, deleted };
315
+ }
316
+
201
317
  /**
202
318
  * List all tracked files in working copy
203
319
  *
@@ -8,14 +8,14 @@
8
8
  */
9
9
 
10
10
  import { JJError } from '../utils/errors.js';
11
- import { validateChangeId, validatePath } from '../utils/validation.js';
11
+ import { validateChangeId } from '../utils/validation.js';
12
12
  import { generateId } from '../utils/id-generation.js';
13
13
  import path from 'path';
14
14
 
15
15
  export class WorkspaceManager {
16
16
  /**
17
17
  * @param {Storage} storage - Storage manager instance
18
- * @param {Object} fs - Filesystem implementation
18
+ * @param {any} fs - Filesystem implementation
19
19
  * @param {string} repoDir - Repository directory
20
20
  */
21
21
  constructor(storage, fs, repoDir) {
@@ -8,10 +8,10 @@
8
8
  * Deep merge for JSON objects
9
9
  * Recursively merges objects, with theirs taking precedence on conflicts
10
10
  *
11
- * @param {Object} base - Base object
12
- * @param {Object} ours - Our changes
13
- * @param {Object} theirs - Their changes
14
- * @returns {Object} Merged object
11
+ * @param {any} base - Base object
12
+ * @param {any} ours - Our changes
13
+ * @param {any} theirs - Their changes
14
+ * @returns {Record<string, any>|null} Merged object
15
15
  */
16
16
  function deepMergeJSON(base, ours, theirs) {
17
17
  // If all are identical, return any
@@ -30,7 +30,12 @@ function deepMergeJSON(base, ours, theirs) {
30
30
  }
31
31
 
32
32
  // Both sides changed - need smart merge
33
- if (typeof ours !== 'object' || typeof theirs !== 'object' || Array.isArray(ours) || Array.isArray(theirs)) {
33
+ if (
34
+ typeof ours !== 'object' ||
35
+ typeof theirs !== 'object' ||
36
+ Array.isArray(ours) ||
37
+ Array.isArray(theirs)
38
+ ) {
34
39
  // Primitives or arrays - conflict
35
40
  return null;
36
41
  }
@@ -61,7 +66,12 @@ function deepMergeJSON(base, ours, theirs) {
61
66
  result[key] = oursVal;
62
67
  } else {
63
68
  // Both changed differently
64
- if (typeof oursVal === 'object' && typeof theirsVal === 'object' && !Array.isArray(oursVal) && !Array.isArray(theirsVal)) {
69
+ if (
70
+ typeof oursVal === 'object' &&
71
+ typeof theirsVal === 'object' &&
72
+ !Array.isArray(oursVal) &&
73
+ !Array.isArray(theirsVal)
74
+ ) {
65
75
  // Recurse for nested objects
66
76
  const merged = deepMergeJSON(baseVal || {}, oursVal, theirsVal);
67
77
  if (merged === null) {
@@ -83,8 +93,10 @@ function deepMergeJSON(base, ours, theirs) {
83
93
  *
84
94
  * Attempts to merge JSON files intelligently by merging objects.
85
95
  * Falls back to conflict markers if merge isn't possible.
96
+ *
97
+ * @param {{ path?: string, content: { base?: string, ours?: string, theirs?: string } }} args
86
98
  */
87
- export async function jsonDriver({ path, content }) {
99
+ export async function jsonDriver({ content }) {
88
100
  const { base, ours, theirs } = content;
89
101
 
90
102
  try {
@@ -106,10 +118,12 @@ export async function jsonDriver({ path, content }) {
106
118
  return {
107
119
  content: conflictContent,
108
120
  hasConflict: true,
109
- conflicts: [{
110
- type: 'json-merge-conflict',
111
- message: 'Both sides modified the same field with different values',
112
- }],
121
+ conflicts: [
122
+ {
123
+ type: 'json-merge-conflict',
124
+ message: 'Both sides modified the same field with different values',
125
+ },
126
+ ],
113
127
  };
114
128
  }
115
129
  } catch (error) {
@@ -118,10 +132,12 @@ export async function jsonDriver({ path, content }) {
118
132
  return {
119
133
  content: conflictContent,
120
134
  hasConflict: true,
121
- conflicts: [{
122
- type: 'json-parse-error',
123
- message: `JSON parsing failed: ${error.message}`,
124
- }],
135
+ conflicts: [
136
+ {
137
+ type: 'json-parse-error',
138
+ message: `JSON parsing failed: ${error.message}`,
139
+ },
140
+ ],
125
141
  };
126
142
  }
127
143
  }
@@ -133,8 +149,10 @@ export async function jsonDriver({ path, content }) {
133
149
  * - Union merge for dependencies/devDependencies
134
150
  * - Take theirs for version (semver conflicts are hard)
135
151
  * - Merge other fields intelligently
152
+ *
153
+ * @param {{ path?: string, content: { base?: string, ours?: string, theirs?: string } }} args
136
154
  */
137
- export async function packageJsonDriver({ path, content }) {
155
+ export async function packageJsonDriver({ content }) {
138
156
  const { base, ours, theirs } = content;
139
157
 
140
158
  try {
@@ -147,12 +165,17 @@ export async function packageJsonDriver({ path, content }) {
147
165
 
148
166
  // Merge top-level fields
149
167
  for (const key of Object.keys({ ...oursObj, ...theirsObj })) {
150
- if (key === 'dependencies' || key === 'devDependencies' || key === 'peerDependencies' || key === 'optionalDependencies') {
168
+ if (
169
+ key === 'dependencies' ||
170
+ key === 'devDependencies' ||
171
+ key === 'peerDependencies' ||
172
+ key === 'optionalDependencies'
173
+ ) {
151
174
  // Union merge dependencies
152
175
  merged[key] = {
153
176
  ...baseObj[key],
154
177
  ...oursObj[key],
155
- ...theirsObj[key], // theirs wins on conflicts
178
+ ...theirsObj[key], // theirs wins on conflicts
156
179
  };
157
180
  } else if (key === 'version') {
158
181
  // Version conflicts are tricky - take theirs
@@ -162,7 +185,7 @@ export async function packageJsonDriver({ path, content }) {
162
185
  merged[key] = {
163
186
  ...baseObj[key],
164
187
  ...oursObj[key],
165
- ...theirsObj[key], // theirs wins
188
+ ...theirsObj[key], // theirs wins
166
189
  };
167
190
  } else {
168
191
  // Other fields - use standard logic
@@ -198,10 +221,12 @@ export async function packageJsonDriver({ path, content }) {
198
221
  return {
199
222
  content: conflictContent,
200
223
  hasConflict: true,
201
- conflicts: [{
202
- type: 'parse-error',
203
- message: `package.json parsing failed: ${error.message}`,
204
- }],
224
+ conflicts: [
225
+ {
226
+ type: 'parse-error',
227
+ message: `package.json parsing failed: ${error.message}`,
228
+ },
229
+ ],
205
230
  };
206
231
  }
207
232
  }
@@ -211,8 +236,10 @@ export async function packageJsonDriver({ path, content }) {
211
236
  *
212
237
  * Note: This is a simple implementation. For production use, consider
213
238
  * using a proper YAML library like 'js-yaml'.
239
+ *
240
+ * @param {{ path?: string, content: { base?: string, ours?: string, theirs?: string } }} args
214
241
  */
215
- export async function yamlDriver({ path, content }) {
242
+ export async function yamlDriver({ content }) {
216
243
  const { base, ours, theirs } = content;
217
244
 
218
245
  // Simple YAML merge - just do text-based for now
@@ -235,10 +262,12 @@ export async function yamlDriver({ path, content }) {
235
262
  return {
236
263
  content: conflictContent,
237
264
  hasConflict: true,
238
- conflicts: [{
239
- type: 'yaml-conflict',
240
- message: 'YAML files modified on both sides',
241
- }],
265
+ conflicts: [
266
+ {
267
+ type: 'yaml-conflict',
268
+ message: 'YAML files modified on both sides',
269
+ },
270
+ ],
242
271
  };
243
272
  }
244
273
 
@@ -247,8 +276,10 @@ export async function yamlDriver({ path, content }) {
247
276
  *
248
277
  * Attempts to merge markdown files by sections (headers).
249
278
  * Falls back to conflict markers for complex cases.
279
+ *
280
+ * @param {{ path?: string, content: { base?: string, ours?: string, theirs?: string } }} args
250
281
  */
251
- export async function markdownDriver({ path, content }) {
282
+ export async function markdownDriver({ content }) {
252
283
  const { base, ours, theirs } = content;
253
284
 
254
285
  // Simple markdown merge - just text-based for now
@@ -272,10 +303,12 @@ export async function markdownDriver({ path, content }) {
272
303
  return {
273
304
  content: conflictContent,
274
305
  hasConflict: true,
275
- conflicts: [{
276
- type: 'markdown-conflict',
277
- message: 'Markdown files modified on both sides',
278
- }],
306
+ conflicts: [
307
+ {
308
+ type: 'markdown-conflict',
309
+ message: 'Markdown files modified on both sides',
310
+ },
311
+ ],
279
312
  };
280
313
  }
281
314
 
package/src/types.d.ts CHANGED
@@ -294,6 +294,12 @@ export interface CreateJJOptions {
294
294
  backend?: string | any;
295
295
  /** Event hooks */
296
296
  hooks?: Hooks;
297
+ /**
298
+ * Automatically snapshot the working directory (disk-walk) before
299
+ * read/commit operations so out-of-band file changes are picked up.
300
+ * Defaults to `true`; set `false` to only track files written via jj.write().
301
+ */
302
+ autoSnapshot?: boolean;
297
303
  }
298
304
 
299
305
  /**
@@ -1042,6 +1048,13 @@ export interface JJ {
1042
1048
  edit(args: EditArgs): Promise<void>;
1043
1049
  status(): Promise<Status>;
1044
1050
  stats(): Promise<RepositoryStats>;
1051
+ /**
1052
+ * Snapshot the working directory (disk-walk), reconciling tracked file state
1053
+ * and the working-copy change's content with what exists on disk (v1.6).
1054
+ * Runs automatically before status/describe/diff/read/file.* unless the repo
1055
+ * was created with `{ autoSnapshot: false }`.
1056
+ */
1057
+ snapshot(): Promise<{ added: string[]; modified: string[]; deleted: string[] }>;
1045
1058
 
1046
1059
  // History editing
1047
1060
  squash(args: SquashArgs): Promise<void>;
@@ -1051,6 +1064,39 @@ export interface JJ {
1051
1064
  unabandon(args: UnabandonArgs): Promise<void>;
1052
1065
  absorb(args?: AbsorbArgs): Promise<AbsorbResult>;
1053
1066
 
1067
+ /**
1068
+ * Create a change that reverses another change (matches `jj revert`, v1.5).
1069
+ * Canonical replacement for the deprecated `backout()`.
1070
+ */
1071
+ revert(args: { revision?: ChangeID; change?: ChangeID; changeId?: ChangeID; target?: ChangeID; message?: string }): Promise<{
1072
+ changeId: ChangeID;
1073
+ description: string;
1074
+ revertedFrom: ChangeID;
1075
+ backedOut: ChangeID;
1076
+ fileSnapshot: Record<string, string>;
1077
+ }>;
1078
+
1079
+ /** @deprecated Use {@link JJ.revert}. Retained for backward compatibility. */
1080
+ backout(args: { revision?: ChangeID; change?: ChangeID; changeId?: ChangeID; target?: ChangeID; message?: string }): Promise<{
1081
+ changeId: ChangeID;
1082
+ description: string;
1083
+ backedOut: ChangeID;
1084
+ fileSnapshot: Record<string, string>;
1085
+ }>;
1086
+
1087
+ /** Sign a change, recording signature metadata (matches `jj sign`, v1.5). */
1088
+ sign(args?: { revision?: ChangeID; change?: ChangeID; changeId?: ChangeID; backend?: string; key?: string }): Promise<{
1089
+ changeId: ChangeID;
1090
+ signed: true;
1091
+ signature: { status: string; backend: string; key: string | null; timestamp: string };
1092
+ }>;
1093
+
1094
+ /** Remove signature metadata from a change (matches `jj unsign`, v1.5). */
1095
+ unsign(args?: { revision?: ChangeID; change?: ChangeID; changeId?: ChangeID }): Promise<{
1096
+ changeId: ChangeID;
1097
+ signed: false;
1098
+ }>;
1099
+
1054
1100
  // Queries
1055
1101
  log(opts?: LogOptions & { count?: false }): Promise<LogEntry[]>;
1056
1102
  log(opts: LogOptions & { count: true }): Promise<number>;
@@ -1059,6 +1105,11 @@ export interface JJ {
1059
1105
 
1060
1106
  // Operations
1061
1107
  undo(opts?: UndoOptions): Promise<UndoResult>;
1108
+ /** Re-apply an operation previously reverted by {@link JJ.undo} (matches `jj redo`, v1.5). */
1109
+ redo(): Promise<{
1110
+ redoneOperation: { description: string; undoneOpId: OperationID };
1111
+ restoredState: { workingCopy: ChangeID; heads: ChangeID[]; fileCount: number };
1112
+ }>;
1062
1113
  operations: {
1063
1114
  list(opts?: { limit?: number }): Promise<Operation[]>;
1064
1115
  at(args: { operation: OperationID }): Promise<JJ>;
@@ -1116,6 +1167,8 @@ export interface JJ {
1116
1167
  set(args: BookmarkSetArgs): Promise<{ name: string; changeId: ChangeID }>;
1117
1168
  create(args: BookmarkCreateArgs): Promise<{ name: string; changeId: ChangeID }>;
1118
1169
  move(args: BookmarkMoveArgs): Promise<{ name: string; from: ChangeID; to: ChangeID }>;
1170
+ /** Move a bookmark forward only; rejects non-descendant targets (matches `jj bookmark advance`, v1.5). */
1171
+ advance(args: { name: string; to?: ChangeID; target?: ChangeID; changeId?: ChangeID; change?: ChangeID; revision?: ChangeID }): Promise<{ name: string; from: ChangeID; to: ChangeID }>;
1119
1172
  delete(args: BookmarkDeleteArgs): Promise<{ deleted: string }>;
1120
1173
  rename(args: BookmarkRenameArgs): Promise<{ oldName: string; newName: string; changeId: ChangeID }>;
1121
1174
  track(args: BookmarkTrackArgs): Promise<{ name: string; remote: string; tracking: boolean }>;
@@ -1152,6 +1205,8 @@ export interface JJ {
1152
1205
  file: {
1153
1206
  show(args: ReadArgs): Promise<string | Uint8Array>;
1154
1207
  list(args?: ListFilesArgs): Promise<string[]>;
1208
+ /** Search tracked file contents (matches `jj file search`, v1.5). Regex by default. */
1209
+ search(args: { pattern: string; changeId?: ChangeID; change?: ChangeID; kind?: 'regex' | 'substring'; path?: string }): Promise<Array<{ path: string; lineNumber: number; line: string }>>;
1155
1210
  write(args: WriteArgs): Promise<WriteResult>;
1156
1211
  move(args: MoveFileArgs): Promise<MoveResult>;
1157
1212
  remove(args: RemoveArgs): Promise<RemoveResult>;
@@ -1270,6 +1325,15 @@ export interface JJ {
1270
1325
  reset(): Promise<void>;
1271
1326
  clear(): Promise<void>;
1272
1327
  };
1328
+
1329
+ // Tags (immutable references; `set` is the v1.5 upsert)
1330
+ tag: {
1331
+ create(args: { name: string; changeId?: ChangeID }): Promise<{ name: string; changeId: ChangeID }>;
1332
+ /** Create or move a tag (matches `jj tag set`, v1.5). */
1333
+ set(args: { name: string; changeId?: ChangeID; change?: ChangeID; revision?: ChangeID }): Promise<{ name: string; changeId: ChangeID; updated: boolean }>;
1334
+ list(args?: { pattern?: string }): Promise<Array<{ name: string; changeId: ChangeID }>>;
1335
+ delete(args: { name: string }): Promise<{ deleted: string }>;
1336
+ };
1273
1337
  }
1274
1338
 
1275
1339
  // ============================================================================
@@ -1290,6 +1354,8 @@ export function createJJ(options: CreateJJOptions): Promise<JJ>;
1290
1354
  export class JJError extends Error {
1291
1355
  code: string;
1292
1356
  suggestion?: string;
1357
+ context: Record<string, any>;
1358
+ details: Record<string, any>;
1293
1359
 
1294
- constructor(code: string, message: string, details?: { suggestion?: string });
1360
+ constructor(code: string, message: string, context?: { suggestion?: string } & Record<string, any>);
1295
1361
  }