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.
- package/CHANGELOG.md +428 -0
- package/README.md +138 -29
- package/bin/isojj.js +273 -102
- package/dist/index.cjs +3401 -1683
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +3401 -1683
- package/dist/index.mjs.map +1 -1
- package/package.json +21 -7
- package/src/api/repository.js +1301 -745
- package/src/backend/isomorphic-git-backend.js +241 -138
- package/src/backend/lazy-git-backend.js +12 -19
- package/src/browser/helpers.js +8 -4
- package/src/core/background-ops.js +58 -41
- package/src/core/bookmark-store.js +12 -8
- package/src/core/change-graph.js +26 -29
- package/src/core/conflict-model.js +66 -26
- package/src/core/jj-checkout.js +8 -4
- package/src/core/jj-operation-store.js +21 -14
- package/src/core/jj-tree-state.js +16 -11
- package/src/core/jj-view-store.js +14 -7
- package/src/core/merge-driver-registry.js +92 -46
- package/src/core/operation-log.js +30 -33
- package/src/core/revset-engine.js +1114 -485
- package/src/core/storage-manager.js +73 -30
- package/src/core/tag-store.js +42 -12
- package/src/core/user-config.js +3 -1
- package/src/core/working-copy.js +125 -9
- package/src/core/workspace-manager.js +2 -2
- package/src/drivers/built-in-drivers.js +66 -33
- package/src/types.d.ts +67 -1
- package/src/utils/atomic-write.js +44 -0
- package/src/utils/errors.js +3 -3
- package/src/utils/id-generation.js +4 -4
- package/src/utils/mutex.js +63 -0
- package/src/utils/validation.js +6 -10
|
@@ -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 {
|
|
12
|
-
* @param {
|
|
13
|
-
* @param {
|
|
14
|
-
* @returns {
|
|
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 (
|
|
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 (
|
|
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({
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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({
|
|
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 (
|
|
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],
|
|
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],
|
|
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
|
-
|
|
203
|
-
|
|
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({
|
|
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
|
-
|
|
240
|
-
|
|
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({
|
|
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
|
-
|
|
277
|
-
|
|
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,
|
|
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
|
+
}
|
package/src/utils/errors.js
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
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
|
+
}
|
package/src/utils/validation.js
CHANGED
|
@@ -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
|
-
|
|
120
|
-
|
|
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')) {
|