isomorphic-jj 1.4.3 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared atomic-write helper for filesystem writes.
3
+ *
4
+ * Writes content to a uniquely-named temp file, then renames it over the
5
+ * canonical path. A crash/error between those two steps leaves the
6
+ * canonical file untouched (never a truncated/partial write) — the temp
7
+ * file is best-effort cleaned up on failure.
8
+ *
9
+ * This mirrors the pattern `Storage.write()` (src/core/storage-manager.js)
10
+ * already uses for JSON stores, factored out so the protobuf-backed stores
11
+ * (JJOperationStore, JJViewStore, JJTreeState) can use it too instead of
12
+ * calling `writeFile()` directly on the canonical path — see issue #16.
13
+ */
14
+
15
+ /**
16
+ * @param {any} fs - Filesystem implementation (Node fs, LightningFS, etc.)
17
+ * @param {string} targetPath - Canonical path to write to
18
+ * @param {string|Uint8Array} content - Content to write
19
+ */
20
+ export async function atomicWriteFile(fs, targetPath, content) {
21
+ const tmpSuffix =
22
+ typeof crypto !== 'undefined' && crypto.randomUUID
23
+ ? crypto.randomUUID()
24
+ : `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
25
+ const tmpPath = `${targetPath}.tmp.${Date.now()}.${tmpSuffix}`;
26
+
27
+ try {
28
+ const dirPath = targetPath.substring(0, targetPath.lastIndexOf('/'));
29
+ if (dirPath) {
30
+ await fs.promises.mkdir(dirPath, { recursive: true });
31
+ }
32
+
33
+ await fs.promises.writeFile(tmpPath, content);
34
+ await fs.promises.rename(tmpPath, targetPath);
35
+ } catch (error) {
36
+ try {
37
+ await fs.promises.unlink(tmpPath);
38
+ } catch {
39
+ // Best-effort cleanup; ignore if the temp file was never created (or
40
+ // is already gone).
41
+ }
42
+ throw error;
43
+ }
44
+ }
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * JJError - Custom error class for isomorphic-jj
3
- *
3
+ *
4
4
  * Provides structured errors with error codes, context, and user-actionable suggestions.
5
5
  */
6
6
  export class JJError extends Error {
7
7
  /**
8
8
  * @param {string} code - Error code (e.g., 'CHANGE_NOT_FOUND')
9
9
  * @param {string} message - Human-readable error message
10
- * @param {Object} [context={}] - Additional error context
11
- * @param {string} [context.suggestion] - User-actionable suggestion
10
+ * @param {Object.<string, any>} [context] - Additional error context. May
11
+ * include a `suggestion` string plus any other diagnostic fields.
12
12
  */
13
13
  constructor(code, message, context = {}) {
14
14
  super(message);
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * ID Generation utilities for isomorphic-jj
3
- *
3
+ *
4
4
  * Generates stable change IDs and operation IDs using cryptographically secure random values.
5
5
  */
6
6
 
7
7
  /**
8
8
  * Generate a stable change ID
9
- *
9
+ *
10
10
  * Uses 128-bit cryptographically random value encoded as 32-character hex string.
11
11
  * Collision probability is negligible (2^128 space).
12
- *
12
+ *
13
13
  * @returns {string} 32-character lowercase hex string
14
14
  */
15
15
  export function generateChangeId() {
@@ -22,7 +22,7 @@ export function generateChangeId() {
22
22
  *
23
23
  * Uses SHA-256 hash of operation content for integrity verification.
24
24
  *
25
- * @param {Object} operation - Operation data
25
+ * @param {Object.<string, any>} operation - Operation data
26
26
  * @returns {Promise<string>} 64-character lowercase hex string (SHA-256)
27
27
  */
28
28
  export async function generateOperationId(operation) {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Small in-process locking utilities.
3
+ *
4
+ * These provide same-process concurrency safety only — they do not protect
5
+ * against races between separate OS processes sharing the same on-disk
6
+ * repository. Cross-process locking is a separate concern (e.g. a lockfile
7
+ * with retry/backoff) and is intentionally out of scope here.
8
+ */
9
+
10
+ /**
11
+ * A simple FIFO async mutex: `run(fn)` queues `fn` to execute only once all
12
+ * previously-queued work on this mutex has settled, serializing access to
13
+ * whatever critical section `fn` represents.
14
+ */
15
+ export class Mutex {
16
+ constructor() {
17
+ /** @type {Promise<any>} */
18
+ this._queue = Promise.resolve();
19
+ }
20
+
21
+ /**
22
+ * @template T
23
+ * @param {() => Promise<T>} fn
24
+ * @returns {Promise<T>}
25
+ */
26
+ run(fn) {
27
+ const result = this._queue.then(() => fn());
28
+ // Keep the queue moving even if `fn` rejects — a failed critical section
29
+ // must not permanently wedge the lock for subsequent callers.
30
+ this._queue = result.then(
31
+ () => undefined,
32
+ () => undefined
33
+ );
34
+ return result;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * A registry of mutexes keyed by an arbitrary string (typically a file path),
40
+ * so unrelated paths don't contend with each other while operations on the
41
+ * *same* path are fully serialized.
42
+ */
43
+ export class KeyedMutex {
44
+ constructor() {
45
+ /** @type {Map<string, Mutex>} */
46
+ this._mutexes = new Map();
47
+ }
48
+
49
+ /**
50
+ * @template T
51
+ * @param {string} key
52
+ * @param {() => Promise<T>} fn
53
+ * @returns {Promise<T>}
54
+ */
55
+ run(key, fn) {
56
+ let mutex = this._mutexes.get(key);
57
+ if (!mutex) {
58
+ mutex = new Mutex();
59
+ this._mutexes.set(key, mutex);
60
+ }
61
+ return mutex.run(fn);
62
+ }
63
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Validation utilities for isomorphic-jj
3
- *
3
+ *
4
4
  * Provides validation functions for change IDs, paths, bookmark names, etc.
5
5
  */
6
6
 
@@ -8,7 +8,7 @@ import { JJError } from './errors.js';
8
8
 
9
9
  /**
10
10
  * Validate a change ID
11
- *
11
+ *
12
12
  * @param {string} changeId - Change ID to validate
13
13
  * @throws {JJError} If change ID is invalid
14
14
  */
@@ -84,7 +84,7 @@ export function validatePath(path) {
84
84
 
85
85
  /**
86
86
  * Validate a bookmark name (follows Git ref name rules)
87
- *
87
+ *
88
88
  * @param {string} name - Bookmark name to validate
89
89
  * @throws {JJError} If bookmark name is invalid
90
90
  */
@@ -115,13 +115,9 @@ export function validateBookmarkName(name) {
115
115
  }
116
116
 
117
117
  if (name.startsWith('.') || name.endsWith('.')) {
118
- throw new JJError(
119
- 'INVALID_BOOKMARK_NAME',
120
- 'Bookmark name cannot start or end with "."',
121
- {
122
- name,
123
- }
124
- );
118
+ throw new JJError('INVALID_BOOKMARK_NAME', 'Bookmark name cannot start or end with "."', {
119
+ name,
120
+ });
125
121
  }
126
122
 
127
123
  if (name.endsWith('.lock')) {