isomorphic-jj 1.7.0 → 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 +203 -1
- package/README.md +134 -23
- package/bin/isojj.js +4 -1
- package/dist/index.cjs +515 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +515 -41
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -4
- package/src/api/repository.js +205 -9
- package/src/backend/isomorphic-git-backend.js +59 -3
- package/src/browser/helpers.js +2 -2
- package/src/core/jj-operation-store.js +5 -2
- package/src/core/jj-tree-state.js +3 -2
- package/src/core/jj-view-store.js +3 -2
- package/src/core/revset-engine.js +48 -3
- package/src/core/storage-manager.js +47 -10
- package/src/core/tag-store.js +40 -10
- package/src/utils/atomic-write.js +44 -0
- package/src/utils/mutex.js +63 -0
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { JJError } from '../utils/errors.js';
|
|
8
|
+
import { KeyedMutex } from '../utils/mutex.js';
|
|
8
9
|
|
|
9
10
|
export class Storage {
|
|
10
11
|
/**
|
|
@@ -18,6 +19,13 @@ export class Storage {
|
|
|
18
19
|
this.repoDir = `${dir}/.jj/repo`; // Core repo data
|
|
19
20
|
this.workingCopyDir = `${dir}/.jj/working_copy`; // Default workspace working copy
|
|
20
21
|
this.cache = new Map();
|
|
22
|
+
|
|
23
|
+
// Serializes write()/appendLine() critical sections per path so that
|
|
24
|
+
// concurrent same-process callers (e.g. a user command racing
|
|
25
|
+
// BackgroundOps's autosnapshot) can't interleave a read-modify-write
|
|
26
|
+
// cycle and silently lose an update. This only protects callers that
|
|
27
|
+
// share this Storage instance — cross-process locking is out of scope.
|
|
28
|
+
this._locks = new KeyedMutex();
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
/**
|
|
@@ -92,8 +100,28 @@ export class Storage {
|
|
|
92
100
|
* @param {Object|string} data - Data to write (object will be stringified)
|
|
93
101
|
*/
|
|
94
102
|
async write(path, data) {
|
|
103
|
+
return this._locks.run(path, () => this._writeLocked(path, data));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Unlocked implementation of write(). Only call this from within a
|
|
108
|
+
* `this._locks.run(path, ...)` critical section (directly from write()
|
|
109
|
+
* itself, or from appendLine() which holds the same path's lock already).
|
|
110
|
+
*
|
|
111
|
+
* @param {string} path - Relative path from .jj directory
|
|
112
|
+
* @param {Object|string} data - Data to write (object will be stringified)
|
|
113
|
+
*/
|
|
114
|
+
async _writeLocked(path, data) {
|
|
95
115
|
const fullPath = `${this.jjDir}/${path}`;
|
|
96
|
-
|
|
116
|
+
// Random suffix (not just a millisecond timestamp) so two concurrent
|
|
117
|
+
// writes to the same path never collide on the temp filename — a
|
|
118
|
+
// collision would let one write's error-cleanup unlink() delete the
|
|
119
|
+
// other's still in-flight temp file.
|
|
120
|
+
const tmpSuffix =
|
|
121
|
+
typeof crypto !== 'undefined' && crypto.randomUUID
|
|
122
|
+
? crypto.randomUUID()
|
|
123
|
+
: `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
|
124
|
+
const tmpPath = `${fullPath}.tmp.${Date.now()}.${tmpSuffix}`;
|
|
97
125
|
const jsonData = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
98
126
|
|
|
99
127
|
try {
|
|
@@ -163,16 +191,25 @@ export class Storage {
|
|
|
163
191
|
const fullPath = `${this.jjDir}/${path}`;
|
|
164
192
|
|
|
165
193
|
try {
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
194
|
+
// Hold the same per-path lock write() uses for the *entire*
|
|
195
|
+
// read-modify-write cycle (not just the write half). Without this,
|
|
196
|
+
// two concurrent appendLine() calls on the same path can both read
|
|
197
|
+
// the pre-append content, and the loser's write silently drops its
|
|
198
|
+
// line even though both calls report success.
|
|
199
|
+
await this._locks.run(path, async () => {
|
|
200
|
+
// Read existing content
|
|
201
|
+
let existing = '';
|
|
202
|
+
try {
|
|
203
|
+
existing = await this.fs.promises.readFile(fullPath, 'utf8');
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error.code !== 'ENOENT') throw error;
|
|
206
|
+
}
|
|
173
207
|
|
|
174
|
-
|
|
175
|
-
|
|
208
|
+
// Write atomically (already inside this path's lock, so use the
|
|
209
|
+
// unlocked implementation to avoid deadlocking on a non-reentrant
|
|
210
|
+
// per-key mutex).
|
|
211
|
+
await this._writeLocked(path, existing + line + '\n');
|
|
212
|
+
});
|
|
176
213
|
} catch (error) {
|
|
177
214
|
throw new JJError('STORAGE_WRITE_FAILED', `Failed to append to ${path}: ${error.message}`, {
|
|
178
215
|
path: fullPath,
|
package/src/core/tag-store.js
CHANGED
|
@@ -56,18 +56,36 @@ export class TagStore {
|
|
|
56
56
|
this.fs = fs;
|
|
57
57
|
this.jjDir = jjDir;
|
|
58
58
|
this.tagsFile = `${jjDir}/store/tags.json`;
|
|
59
|
+
/**
|
|
60
|
+
* Tracking info for local tags mirrored from a remote (jj v0.44 `jj tag
|
|
61
|
+
* track`/`untrack`). Keyed by local tag name: { remote, remoteName }.
|
|
62
|
+
* @type {Record<string, any>}
|
|
63
|
+
*/
|
|
64
|
+
this.tracking = {};
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
/**
|
|
62
|
-
* Loads tags from storage
|
|
68
|
+
* Loads tags (and their remote-tracking info, jj v0.44) from storage
|
|
63
69
|
* @returns {Promise<Record<string, any>>} Map of tag name to changeId
|
|
64
70
|
*/
|
|
65
71
|
async load() {
|
|
66
72
|
try {
|
|
67
73
|
const data = await this.fs.promises.readFile(this.tagsFile, 'utf8');
|
|
68
|
-
|
|
74
|
+
const parsed = JSON.parse(data);
|
|
75
|
+
|
|
76
|
+
// v0.44: tags.json may be either the legacy flat `{ name: changeId }`
|
|
77
|
+
// map, or the newer `{ tags: {...}, tracked: {...} }` shape that also
|
|
78
|
+
// carries remote-tracking state. Support both for backward compat.
|
|
79
|
+
if (parsed && typeof parsed === 'object' && 'tags' in parsed) {
|
|
80
|
+
this.tracking = parsed.tracked || {};
|
|
81
|
+
return parsed.tags || {};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.tracking = {};
|
|
85
|
+
return parsed || {};
|
|
69
86
|
} catch (error) {
|
|
70
87
|
if (error.code === 'ENOENT') {
|
|
88
|
+
this.tracking = {};
|
|
71
89
|
return {};
|
|
72
90
|
}
|
|
73
91
|
throw error;
|
|
@@ -75,7 +93,7 @@ export class TagStore {
|
|
|
75
93
|
}
|
|
76
94
|
|
|
77
95
|
/**
|
|
78
|
-
* Saves tags to storage
|
|
96
|
+
* Saves tags (and their remote-tracking info, jj v0.44) to storage
|
|
79
97
|
* @param {Object} tags - Map of tag name to changeId
|
|
80
98
|
* @returns {Promise<void>}
|
|
81
99
|
*/
|
|
@@ -87,7 +105,10 @@ export class TagStore {
|
|
|
87
105
|
} catch (err) {
|
|
88
106
|
// Directory might already exist, ignore
|
|
89
107
|
}
|
|
90
|
-
await this.fs.promises.writeFile(
|
|
108
|
+
await this.fs.promises.writeFile(
|
|
109
|
+
this.tagsFile,
|
|
110
|
+
JSON.stringify({ tags, tracked: this.tracking || {} }, null, 2)
|
|
111
|
+
);
|
|
91
112
|
}
|
|
92
113
|
|
|
93
114
|
/**
|
|
@@ -116,9 +137,11 @@ export class TagStore {
|
|
|
116
137
|
}
|
|
117
138
|
|
|
118
139
|
/**
|
|
119
|
-
* Lists all tags, optionally filtered by pattern
|
|
140
|
+
* Lists all tags, optionally filtered by pattern. Each entry also carries
|
|
141
|
+
* a `tracking: { remote, ref }` field when the tag is tracking a remote
|
|
142
|
+
* (jj v0.44 `jj tag track`).
|
|
120
143
|
* @param {string} [pattern] - Optional glob pattern
|
|
121
|
-
* @returns {Promise<Array<
|
|
144
|
+
* @returns {Promise<Array<Record<string, any>>>}
|
|
122
145
|
*/
|
|
123
146
|
async list(pattern) {
|
|
124
147
|
const tags = await this.load();
|
|
@@ -129,10 +152,17 @@ export class TagStore {
|
|
|
129
152
|
tagNames = tagNames.filter((name) => matchesPattern(name, pattern));
|
|
130
153
|
}
|
|
131
154
|
|
|
132
|
-
return tagNames.map((name) =>
|
|
133
|
-
|
|
134
|
-
changeId: tags[name]
|
|
135
|
-
|
|
155
|
+
return tagNames.map((name) => {
|
|
156
|
+
/** @type {Record<string, any>} */
|
|
157
|
+
const tag = { name, changeId: tags[name] };
|
|
158
|
+
if (this.tracking && this.tracking[name]) {
|
|
159
|
+
tag.tracking = {
|
|
160
|
+
remote: this.tracking[name].remote,
|
|
161
|
+
ref: this.tracking[name].remoteName || name,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return tag;
|
|
165
|
+
});
|
|
136
166
|
}
|
|
137
167
|
|
|
138
168
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|