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
package/dist/index.mjs
CHANGED
|
@@ -26,6 +26,70 @@ class JJError extends Error {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Small in-process locking utilities.
|
|
31
|
+
*
|
|
32
|
+
* These provide same-process concurrency safety only — they do not protect
|
|
33
|
+
* against races between separate OS processes sharing the same on-disk
|
|
34
|
+
* repository. Cross-process locking is a separate concern (e.g. a lockfile
|
|
35
|
+
* with retry/backoff) and is intentionally out of scope here.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A simple FIFO async mutex: `run(fn)` queues `fn` to execute only once all
|
|
40
|
+
* previously-queued work on this mutex has settled, serializing access to
|
|
41
|
+
* whatever critical section `fn` represents.
|
|
42
|
+
*/
|
|
43
|
+
class Mutex {
|
|
44
|
+
constructor() {
|
|
45
|
+
/** @type {Promise<any>} */
|
|
46
|
+
this._queue = Promise.resolve();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @template T
|
|
51
|
+
* @param {() => Promise<T>} fn
|
|
52
|
+
* @returns {Promise<T>}
|
|
53
|
+
*/
|
|
54
|
+
run(fn) {
|
|
55
|
+
const result = this._queue.then(() => fn());
|
|
56
|
+
// Keep the queue moving even if `fn` rejects — a failed critical section
|
|
57
|
+
// must not permanently wedge the lock for subsequent callers.
|
|
58
|
+
this._queue = result.then(
|
|
59
|
+
() => undefined,
|
|
60
|
+
() => undefined
|
|
61
|
+
);
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A registry of mutexes keyed by an arbitrary string (typically a file path),
|
|
68
|
+
* so unrelated paths don't contend with each other while operations on the
|
|
69
|
+
* *same* path are fully serialized.
|
|
70
|
+
*/
|
|
71
|
+
class KeyedMutex {
|
|
72
|
+
constructor() {
|
|
73
|
+
/** @type {Map<string, Mutex>} */
|
|
74
|
+
this._mutexes = new Map();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @template T
|
|
79
|
+
* @param {string} key
|
|
80
|
+
* @param {() => Promise<T>} fn
|
|
81
|
+
* @returns {Promise<T>}
|
|
82
|
+
*/
|
|
83
|
+
run(key, fn) {
|
|
84
|
+
let mutex = this._mutexes.get(key);
|
|
85
|
+
if (!mutex) {
|
|
86
|
+
mutex = new Mutex();
|
|
87
|
+
this._mutexes.set(key, mutex);
|
|
88
|
+
}
|
|
89
|
+
return mutex.run(fn);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
29
93
|
/**
|
|
30
94
|
* Storage Manager for isomorphic-jj
|
|
31
95
|
*
|
|
@@ -45,6 +109,13 @@ class Storage {
|
|
|
45
109
|
this.repoDir = `${dir}/.jj/repo`; // Core repo data
|
|
46
110
|
this.workingCopyDir = `${dir}/.jj/working_copy`; // Default workspace working copy
|
|
47
111
|
this.cache = new Map();
|
|
112
|
+
|
|
113
|
+
// Serializes write()/appendLine() critical sections per path so that
|
|
114
|
+
// concurrent same-process callers (e.g. a user command racing
|
|
115
|
+
// BackgroundOps's autosnapshot) can't interleave a read-modify-write
|
|
116
|
+
// cycle and silently lose an update. This only protects callers that
|
|
117
|
+
// share this Storage instance — cross-process locking is out of scope.
|
|
118
|
+
this._locks = new KeyedMutex();
|
|
48
119
|
}
|
|
49
120
|
|
|
50
121
|
/**
|
|
@@ -119,8 +190,28 @@ class Storage {
|
|
|
119
190
|
* @param {Object|string} data - Data to write (object will be stringified)
|
|
120
191
|
*/
|
|
121
192
|
async write(path, data) {
|
|
193
|
+
return this._locks.run(path, () => this._writeLocked(path, data));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Unlocked implementation of write(). Only call this from within a
|
|
198
|
+
* `this._locks.run(path, ...)` critical section (directly from write()
|
|
199
|
+
* itself, or from appendLine() which holds the same path's lock already).
|
|
200
|
+
*
|
|
201
|
+
* @param {string} path - Relative path from .jj directory
|
|
202
|
+
* @param {Object|string} data - Data to write (object will be stringified)
|
|
203
|
+
*/
|
|
204
|
+
async _writeLocked(path, data) {
|
|
122
205
|
const fullPath = `${this.jjDir}/${path}`;
|
|
123
|
-
|
|
206
|
+
// Random suffix (not just a millisecond timestamp) so two concurrent
|
|
207
|
+
// writes to the same path never collide on the temp filename — a
|
|
208
|
+
// collision would let one write's error-cleanup unlink() delete the
|
|
209
|
+
// other's still in-flight temp file.
|
|
210
|
+
const tmpSuffix =
|
|
211
|
+
typeof crypto !== 'undefined' && crypto.randomUUID
|
|
212
|
+
? crypto.randomUUID()
|
|
213
|
+
: `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
|
214
|
+
const tmpPath = `${fullPath}.tmp.${Date.now()}.${tmpSuffix}`;
|
|
124
215
|
const jsonData = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
125
216
|
|
|
126
217
|
try {
|
|
@@ -190,16 +281,25 @@ class Storage {
|
|
|
190
281
|
const fullPath = `${this.jjDir}/${path}`;
|
|
191
282
|
|
|
192
283
|
try {
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
284
|
+
// Hold the same per-path lock write() uses for the *entire*
|
|
285
|
+
// read-modify-write cycle (not just the write half). Without this,
|
|
286
|
+
// two concurrent appendLine() calls on the same path can both read
|
|
287
|
+
// the pre-append content, and the loser's write silently drops its
|
|
288
|
+
// line even though both calls report success.
|
|
289
|
+
await this._locks.run(path, async () => {
|
|
290
|
+
// Read existing content
|
|
291
|
+
let existing = '';
|
|
292
|
+
try {
|
|
293
|
+
existing = await this.fs.promises.readFile(fullPath, 'utf8');
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (error.code !== 'ENOENT') throw error;
|
|
296
|
+
}
|
|
200
297
|
|
|
201
|
-
|
|
202
|
-
|
|
298
|
+
// Write atomically (already inside this path's lock, so use the
|
|
299
|
+
// unlocked implementation to avoid deadlocking on a non-reentrant
|
|
300
|
+
// per-key mutex).
|
|
301
|
+
await this._writeLocked(path, existing + line + '\n');
|
|
302
|
+
});
|
|
203
303
|
} catch (error) {
|
|
204
304
|
throw new JJError('STORAGE_WRITE_FAILED', `Failed to append to ${path}: ${error.message}`, {
|
|
205
305
|
path: fullPath,
|
|
@@ -1710,18 +1810,36 @@ class TagStore {
|
|
|
1710
1810
|
this.fs = fs;
|
|
1711
1811
|
this.jjDir = jjDir;
|
|
1712
1812
|
this.tagsFile = `${jjDir}/store/tags.json`;
|
|
1813
|
+
/**
|
|
1814
|
+
* Tracking info for local tags mirrored from a remote (jj v0.44 `jj tag
|
|
1815
|
+
* track`/`untrack`). Keyed by local tag name: { remote, remoteName }.
|
|
1816
|
+
* @type {Record<string, any>}
|
|
1817
|
+
*/
|
|
1818
|
+
this.tracking = {};
|
|
1713
1819
|
}
|
|
1714
1820
|
|
|
1715
1821
|
/**
|
|
1716
|
-
* Loads tags from storage
|
|
1822
|
+
* Loads tags (and their remote-tracking info, jj v0.44) from storage
|
|
1717
1823
|
* @returns {Promise<Record<string, any>>} Map of tag name to changeId
|
|
1718
1824
|
*/
|
|
1719
1825
|
async load() {
|
|
1720
1826
|
try {
|
|
1721
1827
|
const data = await this.fs.promises.readFile(this.tagsFile, 'utf8');
|
|
1722
|
-
|
|
1828
|
+
const parsed = JSON.parse(data);
|
|
1829
|
+
|
|
1830
|
+
// v0.44: tags.json may be either the legacy flat `{ name: changeId }`
|
|
1831
|
+
// map, or the newer `{ tags: {...}, tracked: {...} }` shape that also
|
|
1832
|
+
// carries remote-tracking state. Support both for backward compat.
|
|
1833
|
+
if (parsed && typeof parsed === 'object' && 'tags' in parsed) {
|
|
1834
|
+
this.tracking = parsed.tracked || {};
|
|
1835
|
+
return parsed.tags || {};
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
this.tracking = {};
|
|
1839
|
+
return parsed || {};
|
|
1723
1840
|
} catch (error) {
|
|
1724
1841
|
if (error.code === 'ENOENT') {
|
|
1842
|
+
this.tracking = {};
|
|
1725
1843
|
return {};
|
|
1726
1844
|
}
|
|
1727
1845
|
throw error;
|
|
@@ -1729,7 +1847,7 @@ class TagStore {
|
|
|
1729
1847
|
}
|
|
1730
1848
|
|
|
1731
1849
|
/**
|
|
1732
|
-
* Saves tags to storage
|
|
1850
|
+
* Saves tags (and their remote-tracking info, jj v0.44) to storage
|
|
1733
1851
|
* @param {Object} tags - Map of tag name to changeId
|
|
1734
1852
|
* @returns {Promise<void>}
|
|
1735
1853
|
*/
|
|
@@ -1741,7 +1859,10 @@ class TagStore {
|
|
|
1741
1859
|
} catch (err) {
|
|
1742
1860
|
// Directory might already exist, ignore
|
|
1743
1861
|
}
|
|
1744
|
-
await this.fs.promises.writeFile(
|
|
1862
|
+
await this.fs.promises.writeFile(
|
|
1863
|
+
this.tagsFile,
|
|
1864
|
+
JSON.stringify({ tags, tracked: this.tracking || {} }, null, 2)
|
|
1865
|
+
);
|
|
1745
1866
|
}
|
|
1746
1867
|
|
|
1747
1868
|
/**
|
|
@@ -1770,9 +1891,11 @@ class TagStore {
|
|
|
1770
1891
|
}
|
|
1771
1892
|
|
|
1772
1893
|
/**
|
|
1773
|
-
* Lists all tags, optionally filtered by pattern
|
|
1894
|
+
* Lists all tags, optionally filtered by pattern. Each entry also carries
|
|
1895
|
+
* a `tracking: { remote, ref }` field when the tag is tracking a remote
|
|
1896
|
+
* (jj v0.44 `jj tag track`).
|
|
1774
1897
|
* @param {string} [pattern] - Optional glob pattern
|
|
1775
|
-
* @returns {Promise<Array<
|
|
1898
|
+
* @returns {Promise<Array<Record<string, any>>>}
|
|
1776
1899
|
*/
|
|
1777
1900
|
async list(pattern) {
|
|
1778
1901
|
const tags = await this.load();
|
|
@@ -1783,10 +1906,17 @@ class TagStore {
|
|
|
1783
1906
|
tagNames = tagNames.filter((name) => matchesPattern$1(name, pattern));
|
|
1784
1907
|
}
|
|
1785
1908
|
|
|
1786
|
-
return tagNames.map((name) =>
|
|
1787
|
-
|
|
1788
|
-
changeId: tags[name]
|
|
1789
|
-
|
|
1909
|
+
return tagNames.map((name) => {
|
|
1910
|
+
/** @type {Record<string, any>} */
|
|
1911
|
+
const tag = { name, changeId: tags[name] };
|
|
1912
|
+
if (this.tracking && this.tracking[name]) {
|
|
1913
|
+
tag.tracking = {
|
|
1914
|
+
remote: this.tracking[name].remote,
|
|
1915
|
+
ref: this.tracking[name].remoteName || name,
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
return tag;
|
|
1919
|
+
});
|
|
1790
1920
|
}
|
|
1791
1921
|
|
|
1792
1922
|
/**
|
|
@@ -2140,11 +2270,30 @@ class RevsetParser {
|
|
|
2140
2270
|
this.next();
|
|
2141
2271
|
const value = /** @type {string} */ (tok.value);
|
|
2142
2272
|
|
|
2143
|
-
//
|
|
2273
|
+
// `::` (bare, no operands) means "all visible changes", matching jj's
|
|
2274
|
+
// `::` alias for all().
|
|
2275
|
+
if (value === '::') {
|
|
2276
|
+
return { type: 'rangeAll' };
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// A bare "base..tip" range, e.g. `abcd1234..ef567890` — and the
|
|
2280
|
+
// open-ended forms `..tip` (ancestors(tip)) and `base..` (everything
|
|
2281
|
+
// not an ancestor of base, approximated here as descendants(base)).
|
|
2144
2282
|
if (value.includes('..')) {
|
|
2145
2283
|
const parts = value.split('..');
|
|
2146
2284
|
if (parts.length === 2) {
|
|
2147
|
-
|
|
2285
|
+
const base = parts[0].trim();
|
|
2286
|
+
const tip = parts[1].trim();
|
|
2287
|
+
if (base === '' && tip === '') {
|
|
2288
|
+
return { type: 'rangeAll' };
|
|
2289
|
+
}
|
|
2290
|
+
if (base === '') {
|
|
2291
|
+
return { type: 'rangeAncestors', tip };
|
|
2292
|
+
}
|
|
2293
|
+
if (tip === '') {
|
|
2294
|
+
return { type: 'rangeDescendants', base };
|
|
2295
|
+
}
|
|
2296
|
+
return { type: 'range', base, tip };
|
|
2148
2297
|
}
|
|
2149
2298
|
}
|
|
2150
2299
|
|
|
@@ -2213,7 +2362,7 @@ class RevsetEngine {
|
|
|
2213
2362
|
* @returns {string}
|
|
2214
2363
|
*/
|
|
2215
2364
|
_suggestion() {
|
|
2216
|
-
return 'Use @, @-, @+, bookmark(name), all(),
|
|
2365
|
+
return 'Use @, @-, @+, bookmark(name), all(), builtin_log(), none(), root(), visible_heads(), ancestors(revset[, depth]), author(name), author_name(x), author_email(x), committer(x), committer_name(x), committer_email(x), subject(pattern), description(text), change_id(prefix), commit_id(prefix), empty(), mine(), merge(), merges(), forks(), signed(), divergent(), file(pattern), roots(revset), heads(revset), parents(revset), children(revset), first_parent(revset), first_ancestors(revset), fork_point(revset), merge_point(revset), exactly(revset, n), present(revset), coalesce(a, b, ...), latest(revset, [count]), tags([pattern]), remote_tags([pattern]), bookmarks([pattern]), last(N[dh]), since(date), between(start, end), descendants(rev[, depth]), common_ancestor(rev1, rev2), range(base..tip), diverge_point(rev1, rev2), connected(rev1, rev2), operators (x-, x+), set operations (& | ~), or a direct change ID (git_refs()/git_head() still work but are deprecated — jj removed them in v0.43; use bookmarks()/tags()/@ instead)';
|
|
2217
2366
|
}
|
|
2218
2367
|
|
|
2219
2368
|
/**
|
|
@@ -2270,6 +2419,15 @@ class RevsetEngine {
|
|
|
2270
2419
|
}
|
|
2271
2420
|
case 'range':
|
|
2272
2421
|
return await this.evalRange(node.base, node.tip);
|
|
2422
|
+
// Open-ended ranges (issue #18): `..tip` / `base..` / `::` delegate to
|
|
2423
|
+
// the equivalent ancestors()/descendants()/all() functions rather than
|
|
2424
|
+
// failing with a misleading "invalid IDs" error.
|
|
2425
|
+
case 'rangeAll':
|
|
2426
|
+
return await this.evaluateFunctionCall('all', '');
|
|
2427
|
+
case 'rangeAncestors':
|
|
2428
|
+
return await this.evaluateFunctionCall('ancestors', node.tip);
|
|
2429
|
+
case 'rangeDescendants':
|
|
2430
|
+
return await this.evaluateFunctionCall('descendants', node.base);
|
|
2273
2431
|
case 'call':
|
|
2274
2432
|
return await this.evaluateFunctionCall(node.name, node.argsRaw);
|
|
2275
2433
|
default:
|
|
@@ -2343,6 +2501,16 @@ class RevsetEngine {
|
|
|
2343
2501
|
await this.graph.load();
|
|
2344
2502
|
return this.graph.getAll().map((c) => c.changeId);
|
|
2345
2503
|
|
|
2504
|
+
// `builtin_log()` (jj v0.44) is the revset the built-in `jj log` uses
|
|
2505
|
+
// when no user override is configured. isomorphic-jj's `log()` already
|
|
2506
|
+
// defaults to `all()` when no revset is given (see `jj.log()`), so
|
|
2507
|
+
// `builtin_log()` is a pure alias for that same default — this lets
|
|
2508
|
+
// callers compose it (e.g. `builtin_log() & mine()`) instead of
|
|
2509
|
+
// duplicating `all()`.
|
|
2510
|
+
case 'builtin_log':
|
|
2511
|
+
await this.graph.load();
|
|
2512
|
+
return this.graph.getAll().map((c) => c.changeId);
|
|
2513
|
+
|
|
2346
2514
|
case 'none':
|
|
2347
2515
|
return [];
|
|
2348
2516
|
|
|
@@ -2373,6 +2541,13 @@ class RevsetEngine {
|
|
|
2373
2541
|
return allChanges.filter((c) => !hasChildren.has(c.changeId)).map((c) => c.changeId);
|
|
2374
2542
|
}
|
|
2375
2543
|
|
|
2544
|
+
// `git_refs()` and `git_head()` are deprecated compatibility shims.
|
|
2545
|
+
// Upstream jj deprecated both in favor of `bookmarks()`/`tags()` and
|
|
2546
|
+
// `@`, then removed them outright in jj v0.43 (revset/template calls
|
|
2547
|
+
// to either now error in real jj). isomorphic-jj keeps them working
|
|
2548
|
+
// rather than hard-breaking existing callers, but they are no longer
|
|
2549
|
+
// advertised in `_suggestion()` — prefer `bookmarks([pattern])` /
|
|
2550
|
+
// `tags([pattern])` / `@` instead.
|
|
2376
2551
|
case 'git_refs': {
|
|
2377
2552
|
if (!this.bookmarkStore) return [];
|
|
2378
2553
|
await this.bookmarkStore.load();
|
|
@@ -5327,6 +5502,51 @@ class JJCheckout {
|
|
|
5327
5502
|
}
|
|
5328
5503
|
}
|
|
5329
5504
|
|
|
5505
|
+
/**
|
|
5506
|
+
* Shared atomic-write helper for filesystem writes.
|
|
5507
|
+
*
|
|
5508
|
+
* Writes content to a uniquely-named temp file, then renames it over the
|
|
5509
|
+
* canonical path. A crash/error between those two steps leaves the
|
|
5510
|
+
* canonical file untouched (never a truncated/partial write) — the temp
|
|
5511
|
+
* file is best-effort cleaned up on failure.
|
|
5512
|
+
*
|
|
5513
|
+
* This mirrors the pattern `Storage.write()` (src/core/storage-manager.js)
|
|
5514
|
+
* already uses for JSON stores, factored out so the protobuf-backed stores
|
|
5515
|
+
* (JJOperationStore, JJViewStore, JJTreeState) can use it too instead of
|
|
5516
|
+
* calling `writeFile()` directly on the canonical path — see issue #16.
|
|
5517
|
+
*/
|
|
5518
|
+
|
|
5519
|
+
/**
|
|
5520
|
+
* @param {any} fs - Filesystem implementation (Node fs, LightningFS, etc.)
|
|
5521
|
+
* @param {string} targetPath - Canonical path to write to
|
|
5522
|
+
* @param {string|Uint8Array} content - Content to write
|
|
5523
|
+
*/
|
|
5524
|
+
async function atomicWriteFile(fs, targetPath, content) {
|
|
5525
|
+
const tmpSuffix =
|
|
5526
|
+
typeof crypto !== 'undefined' && crypto.randomUUID
|
|
5527
|
+
? crypto.randomUUID()
|
|
5528
|
+
: `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
|
5529
|
+
const tmpPath = `${targetPath}.tmp.${Date.now()}.${tmpSuffix}`;
|
|
5530
|
+
|
|
5531
|
+
try {
|
|
5532
|
+
const dirPath = targetPath.substring(0, targetPath.lastIndexOf('/'));
|
|
5533
|
+
if (dirPath) {
|
|
5534
|
+
await fs.promises.mkdir(dirPath, { recursive: true });
|
|
5535
|
+
}
|
|
5536
|
+
|
|
5537
|
+
await fs.promises.writeFile(tmpPath, content);
|
|
5538
|
+
await fs.promises.rename(tmpPath, targetPath);
|
|
5539
|
+
} catch (error) {
|
|
5540
|
+
try {
|
|
5541
|
+
await fs.promises.unlink(tmpPath);
|
|
5542
|
+
} catch {
|
|
5543
|
+
// Best-effort cleanup; ignore if the temp file was never created (or
|
|
5544
|
+
// is already gone).
|
|
5545
|
+
}
|
|
5546
|
+
throw error;
|
|
5547
|
+
}
|
|
5548
|
+
}
|
|
5549
|
+
|
|
5330
5550
|
/**
|
|
5331
5551
|
* JJ TreeState File Manager
|
|
5332
5552
|
*
|
|
@@ -5398,9 +5618,9 @@ class JJTreeState {
|
|
|
5398
5618
|
// Encode to binary
|
|
5399
5619
|
const buffer = TreeState.encode(message).finish();
|
|
5400
5620
|
|
|
5401
|
-
// Write to .jj/working_copy/tree_state
|
|
5621
|
+
// Write to .jj/working_copy/tree_state (atomically — see issue #16).
|
|
5402
5622
|
const treeStatePath = `${this.dir}/.jj/working_copy/tree_state`;
|
|
5403
|
-
await this.fs
|
|
5623
|
+
await atomicWriteFile(this.fs, treeStatePath, buffer);
|
|
5404
5624
|
}
|
|
5405
5625
|
|
|
5406
5626
|
/**
|
|
@@ -5509,9 +5729,11 @@ class JJOperationStore {
|
|
|
5509
5729
|
// Encode to binary
|
|
5510
5730
|
const buffer = Operation.encode(message).finish();
|
|
5511
5731
|
|
|
5512
|
-
// Write to .jj/repo/op_store/operations/OPERATIONID
|
|
5732
|
+
// Write to .jj/repo/op_store/operations/OPERATIONID (atomically — a
|
|
5733
|
+
// crash mid-write must never leave a truncated/undecodable file, see
|
|
5734
|
+
// issue #16).
|
|
5513
5735
|
const opPath = `${this.dir}/.jj/repo/op_store/operations/${operationId}`;
|
|
5514
|
-
await this.fs
|
|
5736
|
+
await atomicWriteFile(this.fs, opPath, buffer);
|
|
5515
5737
|
}
|
|
5516
5738
|
|
|
5517
5739
|
/**
|
|
@@ -5628,9 +5850,9 @@ class JJViewStore {
|
|
|
5628
5850
|
// Encode to binary
|
|
5629
5851
|
const buffer = View.encode(message).finish();
|
|
5630
5852
|
|
|
5631
|
-
// Write to .jj/repo/op_store/views/VIEWID
|
|
5853
|
+
// Write to .jj/repo/op_store/views/VIEWID (atomically — see issue #16).
|
|
5632
5854
|
const viewPath = `${this.dir}/.jj/repo/op_store/views/${viewId}`;
|
|
5633
|
-
await this.fs
|
|
5855
|
+
await atomicWriteFile(this.fs, viewPath, buffer);
|
|
5634
5856
|
}
|
|
5635
5857
|
|
|
5636
5858
|
/**
|
|
@@ -6207,7 +6429,11 @@ class IsomorphicGitBackend {
|
|
|
6207
6429
|
* @param {boolean} [opts.force] - Allow non-fast-forward
|
|
6208
6430
|
* @param {any} [opts.onProgress] - Progress callback
|
|
6209
6431
|
* @param {any} [opts.onAuth] - Authentication callback
|
|
6210
|
-
* @returns {Promise<{pushedRefs: Array<
|
|
6432
|
+
* @returns {Promise<{pushedRefs: Array<{name: string, oid: string}>, rejectedRefs: Array<{name: string, code: string, reason: string, message: string}>}>} Push result.
|
|
6433
|
+
* Each rejected ref carries a `code` (one of `AUTH_FAILED`,
|
|
6434
|
+
* `NETWORK_ERROR`, `NON_FAST_FORWARD`, `OTHER`) and a human-readable
|
|
6435
|
+
* `reason`, so callers can tell an auth failure apart from a
|
|
6436
|
+
* non-fast-forward rejection instead of guessing (see issue #14).
|
|
6211
6437
|
*/
|
|
6212
6438
|
async push(opts) {
|
|
6213
6439
|
if (!this.http) {
|
|
@@ -6243,8 +6469,13 @@ class IsomorphicGitBackend {
|
|
|
6243
6469
|
pushedRefs.push({ name: ref, oid });
|
|
6244
6470
|
}
|
|
6245
6471
|
} catch (pushError) {
|
|
6246
|
-
// Track rejected refs
|
|
6247
|
-
|
|
6472
|
+
// Track rejected refs with the categorized reason so callers can
|
|
6473
|
+
// distinguish an auth failure from a non-fast-forward rejection
|
|
6474
|
+
// (or a network blip) instead of guessing — see issue #14. A
|
|
6475
|
+
// caller that blindly retries with force:true on every rejection
|
|
6476
|
+
// risks doing so on what was actually an auth/network problem.
|
|
6477
|
+
const { code, reason } = this._categorizePushError(pushError);
|
|
6478
|
+
rejectedRefs.push({ name: ref, code, reason, message: pushError.message });
|
|
6248
6479
|
}
|
|
6249
6480
|
}
|
|
6250
6481
|
|
|
@@ -6276,6 +6507,53 @@ class IsomorphicGitBackend {
|
|
|
6276
6507
|
}
|
|
6277
6508
|
}
|
|
6278
6509
|
|
|
6510
|
+
/**
|
|
6511
|
+
* Categorize a per-ref push failure into a stable code/reason pair so
|
|
6512
|
+
* callers can distinguish auth failures, network errors, and
|
|
6513
|
+
* non-fast-forward rejections from each other and from other/unknown
|
|
6514
|
+
* failures — see issue #14. isomorphic-git doesn't throw a single
|
|
6515
|
+
* "AuthError" class; auth failures generally surface as an HttpError with
|
|
6516
|
+
* a 401/403 status code (or a UserCanceledError if onAuth cancels).
|
|
6517
|
+
*
|
|
6518
|
+
* @private
|
|
6519
|
+
* @param {any} error - Error thrown by git.push() for a single ref
|
|
6520
|
+
* @returns {{code: string, reason: string}}
|
|
6521
|
+
*/
|
|
6522
|
+
_categorizePushError(error) {
|
|
6523
|
+
const errCode = error && error.code;
|
|
6524
|
+
|
|
6525
|
+
if (errCode === 'PushRejectedError') {
|
|
6526
|
+
const reason = error.data && error.data.reason;
|
|
6527
|
+
return {
|
|
6528
|
+
code: 'NON_FAST_FORWARD',
|
|
6529
|
+
reason: reason === 'tag-exists' ? 'tag already exists on remote' : 'non-fast-forward',
|
|
6530
|
+
};
|
|
6531
|
+
}
|
|
6532
|
+
|
|
6533
|
+
if (errCode === 'HttpError') {
|
|
6534
|
+
const status = error.data && error.data.statusCode;
|
|
6535
|
+
if (status === 401 || status === 403) {
|
|
6536
|
+
return { code: 'AUTH_FAILED', reason: `HTTP ${status}` };
|
|
6537
|
+
}
|
|
6538
|
+
return { code: 'NETWORK_ERROR', reason: status ? `HTTP ${status}` : 'HTTP error' };
|
|
6539
|
+
}
|
|
6540
|
+
|
|
6541
|
+
if (
|
|
6542
|
+
errCode === 'AuthError' ||
|
|
6543
|
+
errCode === 'UserCanceledError' ||
|
|
6544
|
+
errCode === 'MissingUsernameError' ||
|
|
6545
|
+
errCode === 'MissingPasswordTokenError'
|
|
6546
|
+
) {
|
|
6547
|
+
return { code: 'AUTH_FAILED', reason: errCode };
|
|
6548
|
+
}
|
|
6549
|
+
|
|
6550
|
+
if (errCode === 'NetworkError' || errCode === 'UnknownTransportError') {
|
|
6551
|
+
return { code: 'NETWORK_ERROR', reason: errCode };
|
|
6552
|
+
}
|
|
6553
|
+
|
|
6554
|
+
return { code: 'OTHER', reason: (error && error.message) || String(error) };
|
|
6555
|
+
}
|
|
6556
|
+
|
|
6279
6557
|
/**
|
|
6280
6558
|
* Create minimal .jj/repo structure for jj CLI compatibility
|
|
6281
6559
|
* @private
|
|
@@ -7541,6 +7819,11 @@ async function createJJ(options) {
|
|
|
7541
7819
|
const isWorkingCopy = targetChangeId === workingCopy.getCurrentChangeId();
|
|
7542
7820
|
const fileSnapshotBefore = isWorkingCopy ? await snapshotFilesystem() : null;
|
|
7543
7821
|
|
|
7822
|
+
// Snapshot the change record itself from BEFORE this operation mutates
|
|
7823
|
+
// it in place, so undo() can restore ChangeGraph content (not just
|
|
7824
|
+
// on-disk files/working-copy pointer) — mirrors fileSnapshotBefore above.
|
|
7825
|
+
const changeSnapshotBefore = structuredClone(change);
|
|
7826
|
+
|
|
7544
7827
|
// Dispatch change:updating event (preventable)
|
|
7545
7828
|
await dispatchEventAsync(jj, 'change:updating', {
|
|
7546
7829
|
operation: 'describe',
|
|
@@ -7621,6 +7904,10 @@ async function createJJ(options) {
|
|
|
7621
7904
|
},
|
|
7622
7905
|
description: `describe change ${targetChangeId.slice(0, 8)}`,
|
|
7623
7906
|
parents: [],
|
|
7907
|
+
// Snapshot of the affected ChangeGraph node from BEFORE this
|
|
7908
|
+
// operation ran, so undo() can restore in-place content mutations
|
|
7909
|
+
// (e.g. the description change below) — not just files/pointers.
|
|
7910
|
+
changeSnapshot: { [targetChangeId]: changeSnapshotBefore },
|
|
7624
7911
|
view: {
|
|
7625
7912
|
bookmarks: {},
|
|
7626
7913
|
remoteBookmarks: {},
|
|
@@ -8133,11 +8420,28 @@ async function createJJ(options) {
|
|
|
8133
8420
|
// Try to resolve as revset
|
|
8134
8421
|
try {
|
|
8135
8422
|
const changes = await revset.evaluate(args.change);
|
|
8423
|
+
if (changes.length > 1) {
|
|
8424
|
+
// An id-prefix (or other) revset that matches more than one
|
|
8425
|
+
// change must not silently pick a "winner" — see issue #13.
|
|
8426
|
+
throw new JJError(
|
|
8427
|
+
'AMBIGUOUS_ID',
|
|
8428
|
+
`"${args.change}" is ambiguous: it resolves to ${changes.length} changes (${changes.join(', ')})`,
|
|
8429
|
+
{
|
|
8430
|
+
expression: args.change,
|
|
8431
|
+
matches: changes,
|
|
8432
|
+
suggestion: 'Use a longer prefix or an exact change ID to disambiguate',
|
|
8433
|
+
}
|
|
8434
|
+
);
|
|
8435
|
+
}
|
|
8136
8436
|
if (changes.length > 0) {
|
|
8137
8437
|
changeId = changes[0];
|
|
8138
8438
|
}
|
|
8139
|
-
} catch {
|
|
8140
|
-
|
|
8439
|
+
} catch (error) {
|
|
8440
|
+
if (error instanceof JJError && error.code === 'AMBIGUOUS_ID') {
|
|
8441
|
+
throw error;
|
|
8442
|
+
}
|
|
8443
|
+
// Not a valid revset expression - use args.change as-is (e.g. a
|
|
8444
|
+
// literal change ID that revset parsing rejected).
|
|
8141
8445
|
}
|
|
8142
8446
|
}
|
|
8143
8447
|
|
|
@@ -8380,6 +8684,21 @@ async function createJJ(options) {
|
|
|
8380
8684
|
// Restore state from previous view
|
|
8381
8685
|
await workingCopy.setCurrentChange(previousView.workingCopy);
|
|
8382
8686
|
|
|
8687
|
+
// Restore ChangeGraph content mutated in place by the operation being
|
|
8688
|
+
// undone (e.g. describe() editing change.description) — see issue #12.
|
|
8689
|
+
// currentOp.changeSnapshot holds the affected change record(s) as they
|
|
8690
|
+
// were BEFORE currentOp ran, captured the same way fileSnapshot is.
|
|
8691
|
+
await graph.load();
|
|
8692
|
+
if (currentOp && currentOp.changeSnapshot) {
|
|
8693
|
+
for (const snapshot of Object.values(
|
|
8694
|
+
/** @type {Record<string, any>} */ (currentOp.changeSnapshot)
|
|
8695
|
+
)) {
|
|
8696
|
+
if (snapshot && (await graph.getChange(snapshot.changeId))) {
|
|
8697
|
+
await graph.updateChange(snapshot);
|
|
8698
|
+
}
|
|
8699
|
+
}
|
|
8700
|
+
}
|
|
8701
|
+
|
|
8383
8702
|
// Restore filesystem from previous operation's snapshot (taken BEFORE that operation)
|
|
8384
8703
|
// This is how JJ snapshots the working copy before every command
|
|
8385
8704
|
if (previousView.fileSnapshot) {
|
|
@@ -8864,6 +9183,7 @@ async function createJJ(options) {
|
|
|
8864
9183
|
// Compute the inverse changes
|
|
8865
9184
|
const inversChanges = /** @type {Record<string, any>} */ ({
|
|
8866
9185
|
bookmarks: {},
|
|
9186
|
+
tags: {},
|
|
8867
9187
|
heads: [],
|
|
8868
9188
|
});
|
|
8869
9189
|
|
|
@@ -8903,6 +9223,46 @@ async function createJJ(options) {
|
|
|
8903
9223
|
|
|
8904
9224
|
await bookmarks.save();
|
|
8905
9225
|
|
|
9226
|
+
// Revert tag changes (mirrors the bookmark diff above — issue #12).
|
|
9227
|
+
// Unlike bookmarks, TagStore has no "move" primitive (tags are meant
|
|
9228
|
+
// to be immutable once created), so a moved tag is reverted via
|
|
9229
|
+
// delete+recreate, matching how tag.set() itself updates a tag.
|
|
9230
|
+
const prevTags = previousOp.view?.tags || {};
|
|
9231
|
+
const targetTags = targetOp.view?.tags || {};
|
|
9232
|
+
|
|
9233
|
+
for (const name of Object.keys(targetTags)) {
|
|
9234
|
+
if (!prevTags[name]) {
|
|
9235
|
+
// Tag was added - delete it
|
|
9236
|
+
if (await tags.exists(name)) {
|
|
9237
|
+
await tags.delete(name);
|
|
9238
|
+
}
|
|
9239
|
+
inversChanges.tags[name] = { action: 'deleted' };
|
|
9240
|
+
} else if (prevTags[name] !== targetTags[name]) {
|
|
9241
|
+
// Tag was moved - restore old target
|
|
9242
|
+
if (await tags.exists(name)) {
|
|
9243
|
+
await tags.delete(name);
|
|
9244
|
+
}
|
|
9245
|
+
await tags.create(name, prevTags[name]);
|
|
9246
|
+
inversChanges.tags[name] = {
|
|
9247
|
+
action: 'moved',
|
|
9248
|
+
from: targetTags[name],
|
|
9249
|
+
to: prevTags[name],
|
|
9250
|
+
};
|
|
9251
|
+
}
|
|
9252
|
+
}
|
|
9253
|
+
|
|
9254
|
+
// Check for deleted tags (existed before, don't exist after)
|
|
9255
|
+
for (const name of Object.keys(prevTags)) {
|
|
9256
|
+
if (!targetTags[name] && !(await tags.exists(name))) {
|
|
9257
|
+
// Tag was deleted - restore it
|
|
9258
|
+
await tags.create(name, prevTags[name]);
|
|
9259
|
+
inversChanges.tags[name] = {
|
|
9260
|
+
action: 'restored',
|
|
9261
|
+
to: prevTags[name],
|
|
9262
|
+
};
|
|
9263
|
+
}
|
|
9264
|
+
}
|
|
9265
|
+
|
|
8906
9266
|
// Revert working copy change if it changed
|
|
8907
9267
|
if (previousOp.view?.workingCopy !== targetOp.view?.workingCopy) {
|
|
8908
9268
|
await workingCopy.load();
|
|
@@ -8917,6 +9277,7 @@ async function createJJ(options) {
|
|
|
8917
9277
|
parents: [],
|
|
8918
9278
|
view: {
|
|
8919
9279
|
bookmarks: prevBookmarks,
|
|
9280
|
+
tags: prevTags,
|
|
8920
9281
|
remoteBookmarks: previousOp.view?.remoteBookmarks || {},
|
|
8921
9282
|
heads: previousOp.view?.heads || [],
|
|
8922
9283
|
workingCopy: previousOp.view?.workingCopy,
|
|
@@ -11354,11 +11715,18 @@ async function createJJ(options) {
|
|
|
11354
11715
|
throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
|
|
11355
11716
|
}
|
|
11356
11717
|
|
|
11357
|
-
// Generate standard conflict markers
|
|
11718
|
+
// Generate standard conflict markers. Force a newline before each
|
|
11719
|
+
// boundary marker whenever there's non-empty content that doesn't
|
|
11720
|
+
// already end in one — otherwise the content fuses onto the
|
|
11721
|
+
// `=======`/`>>>>>>> theirs` marker, producing a corrupted/unparseable
|
|
11722
|
+
// line (see issue #15). Empty sides (e.g. a modify-delete conflict)
|
|
11723
|
+
// intentionally get no extra blank line before the marker.
|
|
11358
11724
|
const ours = conflict.sides.left || '';
|
|
11359
11725
|
const theirs = conflict.sides.right || '';
|
|
11726
|
+
const oursSep = ours && !ours.endsWith('\n') ? '\n' : '';
|
|
11727
|
+
const theirsSep = theirs && !theirs.endsWith('\n') ? '\n' : '';
|
|
11360
11728
|
|
|
11361
|
-
return `<<<<<<< ours\n${ours}=======\n${theirs}>>>>>>> theirs`;
|
|
11729
|
+
return `<<<<<<< ours\n${ours}${oursSep}=======\n${theirs}${theirsSep}>>>>>>> theirs`;
|
|
11362
11730
|
},
|
|
11363
11731
|
|
|
11364
11732
|
/**
|
|
@@ -11445,10 +11813,17 @@ async function createJJ(options) {
|
|
|
11445
11813
|
* Searches the tracked files of a change (defaults to the working copy)
|
|
11446
11814
|
* for lines matching a pattern. Following jj v0.41, the pattern is treated
|
|
11447
11815
|
* as a regular expression by default; pass `{ kind: 'substring' }` for a
|
|
11448
|
-
* literal search.
|
|
11816
|
+
* literal search. isomorphic-jj has always returned the matched line
|
|
11817
|
+
* (and its 1-based `lineNumber`) alongside the path, which is what jj
|
|
11818
|
+
* v0.44 made the CLI's new default (`jj file search` used to print only
|
|
11819
|
+
* file paths). Pass `{ nameOnly: true }` for the pre-v0.44 CLI
|
|
11820
|
+
* behavior — a deduplicated list of matching file paths, matching the
|
|
11821
|
+
* new `--name-only` flag.
|
|
11449
11822
|
*
|
|
11450
|
-
* @param {Record<string, any>} args - Arguments
|
|
11451
|
-
*
|
|
11823
|
+
* @param {Record<string, any>} args - Arguments. Pass `nameOnly: true`
|
|
11824
|
+
* to return only unique matching file paths instead of per-line
|
|
11825
|
+
* matches (jj v0.44 `--name-only`)
|
|
11826
|
+
* @returns {Promise<Array<{path: string, lineNumber: number, line: string}>|Array<string>>}
|
|
11452
11827
|
*/
|
|
11453
11828
|
async search(args) {
|
|
11454
11829
|
if (!args || !args.pattern) {
|
|
@@ -11499,6 +11874,10 @@ async function createJJ(options) {
|
|
|
11499
11874
|
}
|
|
11500
11875
|
});
|
|
11501
11876
|
}
|
|
11877
|
+
|
|
11878
|
+
if (args.nameOnly) {
|
|
11879
|
+
return Array.from(new Set(results.map((r) => r.path)));
|
|
11880
|
+
}
|
|
11502
11881
|
return results;
|
|
11503
11882
|
},
|
|
11504
11883
|
|
|
@@ -12580,6 +12959,10 @@ async function createJJ(options) {
|
|
|
12580
12959
|
view: {
|
|
12581
12960
|
bookmarks: {},
|
|
12582
12961
|
remoteBookmarks: {},
|
|
12962
|
+
// Tag delta for this operation (mirrors how `bookmarks` records
|
|
12963
|
+
// its delta above), so operations.revert() can diff it — see
|
|
12964
|
+
// issue #12.
|
|
12965
|
+
tags: { [args.name]: changeId },
|
|
12583
12966
|
heads: [],
|
|
12584
12967
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
12585
12968
|
},
|
|
@@ -12625,6 +13008,7 @@ async function createJJ(options) {
|
|
|
12625
13008
|
view: {
|
|
12626
13009
|
bookmarks: {},
|
|
12627
13010
|
remoteBookmarks: {},
|
|
13011
|
+
tags: { [args.name]: changeId },
|
|
12628
13012
|
heads: [],
|
|
12629
13013
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
12630
13014
|
},
|
|
@@ -12634,10 +13018,11 @@ async function createJJ(options) {
|
|
|
12634
13018
|
},
|
|
12635
13019
|
|
|
12636
13020
|
/**
|
|
12637
|
-
* List all tags
|
|
13021
|
+
* List all tags. Each entry also carries a `tracking: { remote, ref }`
|
|
13022
|
+
* field when the tag is tracking a remote (jj v0.44 `jj tag track`).
|
|
12638
13023
|
*
|
|
12639
13024
|
* @param {Record<string, any>} [args] - Optional filter arguments
|
|
12640
|
-
* @returns {Promise<Array<
|
|
13025
|
+
* @returns {Promise<Array<Record<string, any>>>} List of tags
|
|
12641
13026
|
*/
|
|
12642
13027
|
async list(args) {
|
|
12643
13028
|
const pattern = args?.pattern;
|
|
@@ -12669,6 +13054,7 @@ async function createJJ(options) {
|
|
|
12669
13054
|
view: {
|
|
12670
13055
|
bookmarks: {},
|
|
12671
13056
|
remoteBookmarks: {},
|
|
13057
|
+
tags: {},
|
|
12672
13058
|
heads: [],
|
|
12673
13059
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
12674
13060
|
},
|
|
@@ -12676,6 +13062,94 @@ async function createJJ(options) {
|
|
|
12676
13062
|
|
|
12677
13063
|
return { deleted: args.name };
|
|
12678
13064
|
},
|
|
13065
|
+
|
|
13066
|
+
/**
|
|
13067
|
+
* Associate a local tag with a remote tag so it is updated on fetch
|
|
13068
|
+
* (matches `jj tag track`, new in jj v0.44).
|
|
13069
|
+
*
|
|
13070
|
+
* jj v0.44 made tags remote-trackable just like bookmarks: fetched
|
|
13071
|
+
* tags are tracked by default, and `jj tag track`/`untrack` let you
|
|
13072
|
+
* opt individual tags in or out by hand. isomorphic-jj doesn't (yet)
|
|
13073
|
+
* auto-import remote tags during `git.fetch()` — see CHANGELOG — so
|
|
13074
|
+
* this records tracking intent/state the same way `bookmark.track()`
|
|
13075
|
+
* does, without changing fetch's own behavior.
|
|
13076
|
+
*
|
|
13077
|
+
* @param {Record<string, any>} args - Tag arguments: `{ name, remote }`
|
|
13078
|
+
* (`name` is the local tag name to track; `remote` defaults to `'origin'`)
|
|
13079
|
+
* @returns {Promise<{name: string, remote: string, tracking: boolean}>}
|
|
13080
|
+
*/
|
|
13081
|
+
async track(args) {
|
|
13082
|
+
if (!args || !args.name) {
|
|
13083
|
+
throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
|
|
13084
|
+
suggestion: 'Provide tag name: { name: "v1.0.0", remote: "origin" }',
|
|
13085
|
+
});
|
|
13086
|
+
}
|
|
13087
|
+
|
|
13088
|
+
const remote = args.remote || 'origin';
|
|
13089
|
+
const remoteTagName = `${remote}/${args.name}`;
|
|
13090
|
+
|
|
13091
|
+
const tagMap = await tags.load();
|
|
13092
|
+
/** @type {Record<string, any>} */
|
|
13093
|
+
const tracking = tags.tracking || {};
|
|
13094
|
+
tracking[args.name] = { remote, remoteName: args.name };
|
|
13095
|
+
tags.tracking = tracking;
|
|
13096
|
+
await tags.save(tagMap);
|
|
13097
|
+
|
|
13098
|
+
// Record operation
|
|
13099
|
+
await oplog.recordOperation({
|
|
13100
|
+
timestamp: new Date().toISOString(),
|
|
13101
|
+
user: await getUserOplogInfo(),
|
|
13102
|
+
description: `tag track ${args.name} from ${remote}`,
|
|
13103
|
+
parents: [],
|
|
13104
|
+
view: {
|
|
13105
|
+
bookmarks: {},
|
|
13106
|
+
remoteBookmarks: { [remoteTagName]: true },
|
|
13107
|
+
heads: [],
|
|
13108
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
13109
|
+
},
|
|
13110
|
+
});
|
|
13111
|
+
|
|
13112
|
+
return { name: args.name, remote, tracking: true };
|
|
13113
|
+
},
|
|
13114
|
+
|
|
13115
|
+
/**
|
|
13116
|
+
* Stop tracking a remote tag (matches `jj tag untrack`, new in jj v0.44).
|
|
13117
|
+
*
|
|
13118
|
+
* @param {Record<string, any>} args - Tag arguments: `{ name }`, the
|
|
13119
|
+
* local tag name to stop tracking
|
|
13120
|
+
* @returns {Promise<{name: string, tracking: boolean, wasTracking: boolean}>}
|
|
13121
|
+
*/
|
|
13122
|
+
async untrack(args) {
|
|
13123
|
+
if (!args || !args.name) {
|
|
13124
|
+
throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
|
|
13125
|
+
suggestion: 'Provide tag name: { name: "v1.0.0" }',
|
|
13126
|
+
});
|
|
13127
|
+
}
|
|
13128
|
+
|
|
13129
|
+
const tagMap = await tags.load();
|
|
13130
|
+
/** @type {Record<string, any>} */
|
|
13131
|
+
const tracking = tags.tracking || {};
|
|
13132
|
+
const wasTracking = tracking[args.name];
|
|
13133
|
+
delete tracking[args.name];
|
|
13134
|
+
tags.tracking = tracking;
|
|
13135
|
+
await tags.save(tagMap);
|
|
13136
|
+
|
|
13137
|
+
// Record operation
|
|
13138
|
+
await oplog.recordOperation({
|
|
13139
|
+
timestamp: new Date().toISOString(),
|
|
13140
|
+
user: await getUserOplogInfo(),
|
|
13141
|
+
description: `tag untrack ${args.name}`,
|
|
13142
|
+
parents: [],
|
|
13143
|
+
view: {
|
|
13144
|
+
bookmarks: {},
|
|
13145
|
+
remoteBookmarks: {},
|
|
13146
|
+
heads: [],
|
|
13147
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
13148
|
+
},
|
|
13149
|
+
});
|
|
13150
|
+
|
|
13151
|
+
return { name: args.name, tracking: false, wasTracking: !!wasTracking };
|
|
13152
|
+
},
|
|
12679
13153
|
},
|
|
12680
13154
|
|
|
12681
13155
|
/**
|