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.cjs
CHANGED
|
@@ -29,6 +29,70 @@ class JJError extends Error {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Small in-process locking utilities.
|
|
34
|
+
*
|
|
35
|
+
* These provide same-process concurrency safety only — they do not protect
|
|
36
|
+
* against races between separate OS processes sharing the same on-disk
|
|
37
|
+
* repository. Cross-process locking is a separate concern (e.g. a lockfile
|
|
38
|
+
* with retry/backoff) and is intentionally out of scope here.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A simple FIFO async mutex: `run(fn)` queues `fn` to execute only once all
|
|
43
|
+
* previously-queued work on this mutex has settled, serializing access to
|
|
44
|
+
* whatever critical section `fn` represents.
|
|
45
|
+
*/
|
|
46
|
+
class Mutex {
|
|
47
|
+
constructor() {
|
|
48
|
+
/** @type {Promise<any>} */
|
|
49
|
+
this._queue = Promise.resolve();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @template T
|
|
54
|
+
* @param {() => Promise<T>} fn
|
|
55
|
+
* @returns {Promise<T>}
|
|
56
|
+
*/
|
|
57
|
+
run(fn) {
|
|
58
|
+
const result = this._queue.then(() => fn());
|
|
59
|
+
// Keep the queue moving even if `fn` rejects — a failed critical section
|
|
60
|
+
// must not permanently wedge the lock for subsequent callers.
|
|
61
|
+
this._queue = result.then(
|
|
62
|
+
() => undefined,
|
|
63
|
+
() => undefined
|
|
64
|
+
);
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A registry of mutexes keyed by an arbitrary string (typically a file path),
|
|
71
|
+
* so unrelated paths don't contend with each other while operations on the
|
|
72
|
+
* *same* path are fully serialized.
|
|
73
|
+
*/
|
|
74
|
+
class KeyedMutex {
|
|
75
|
+
constructor() {
|
|
76
|
+
/** @type {Map<string, Mutex>} */
|
|
77
|
+
this._mutexes = new Map();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @template T
|
|
82
|
+
* @param {string} key
|
|
83
|
+
* @param {() => Promise<T>} fn
|
|
84
|
+
* @returns {Promise<T>}
|
|
85
|
+
*/
|
|
86
|
+
run(key, fn) {
|
|
87
|
+
let mutex = this._mutexes.get(key);
|
|
88
|
+
if (!mutex) {
|
|
89
|
+
mutex = new Mutex();
|
|
90
|
+
this._mutexes.set(key, mutex);
|
|
91
|
+
}
|
|
92
|
+
return mutex.run(fn);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
32
96
|
/**
|
|
33
97
|
* Storage Manager for isomorphic-jj
|
|
34
98
|
*
|
|
@@ -48,6 +112,13 @@ class Storage {
|
|
|
48
112
|
this.repoDir = `${dir}/.jj/repo`; // Core repo data
|
|
49
113
|
this.workingCopyDir = `${dir}/.jj/working_copy`; // Default workspace working copy
|
|
50
114
|
this.cache = new Map();
|
|
115
|
+
|
|
116
|
+
// Serializes write()/appendLine() critical sections per path so that
|
|
117
|
+
// concurrent same-process callers (e.g. a user command racing
|
|
118
|
+
// BackgroundOps's autosnapshot) can't interleave a read-modify-write
|
|
119
|
+
// cycle and silently lose an update. This only protects callers that
|
|
120
|
+
// share this Storage instance — cross-process locking is out of scope.
|
|
121
|
+
this._locks = new KeyedMutex();
|
|
51
122
|
}
|
|
52
123
|
|
|
53
124
|
/**
|
|
@@ -122,8 +193,28 @@ class Storage {
|
|
|
122
193
|
* @param {Object|string} data - Data to write (object will be stringified)
|
|
123
194
|
*/
|
|
124
195
|
async write(path, data) {
|
|
196
|
+
return this._locks.run(path, () => this._writeLocked(path, data));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Unlocked implementation of write(). Only call this from within a
|
|
201
|
+
* `this._locks.run(path, ...)` critical section (directly from write()
|
|
202
|
+
* itself, or from appendLine() which holds the same path's lock already).
|
|
203
|
+
*
|
|
204
|
+
* @param {string} path - Relative path from .jj directory
|
|
205
|
+
* @param {Object|string} data - Data to write (object will be stringified)
|
|
206
|
+
*/
|
|
207
|
+
async _writeLocked(path, data) {
|
|
125
208
|
const fullPath = `${this.jjDir}/${path}`;
|
|
126
|
-
|
|
209
|
+
// Random suffix (not just a millisecond timestamp) so two concurrent
|
|
210
|
+
// writes to the same path never collide on the temp filename — a
|
|
211
|
+
// collision would let one write's error-cleanup unlink() delete the
|
|
212
|
+
// other's still in-flight temp file.
|
|
213
|
+
const tmpSuffix =
|
|
214
|
+
typeof crypto !== 'undefined' && crypto.randomUUID
|
|
215
|
+
? crypto.randomUUID()
|
|
216
|
+
: `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
|
217
|
+
const tmpPath = `${fullPath}.tmp.${Date.now()}.${tmpSuffix}`;
|
|
127
218
|
const jsonData = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
128
219
|
|
|
129
220
|
try {
|
|
@@ -193,16 +284,25 @@ class Storage {
|
|
|
193
284
|
const fullPath = `${this.jjDir}/${path}`;
|
|
194
285
|
|
|
195
286
|
try {
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
287
|
+
// Hold the same per-path lock write() uses for the *entire*
|
|
288
|
+
// read-modify-write cycle (not just the write half). Without this,
|
|
289
|
+
// two concurrent appendLine() calls on the same path can both read
|
|
290
|
+
// the pre-append content, and the loser's write silently drops its
|
|
291
|
+
// line even though both calls report success.
|
|
292
|
+
await this._locks.run(path, async () => {
|
|
293
|
+
// Read existing content
|
|
294
|
+
let existing = '';
|
|
295
|
+
try {
|
|
296
|
+
existing = await this.fs.promises.readFile(fullPath, 'utf8');
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (error.code !== 'ENOENT') throw error;
|
|
299
|
+
}
|
|
203
300
|
|
|
204
|
-
|
|
205
|
-
|
|
301
|
+
// Write atomically (already inside this path's lock, so use the
|
|
302
|
+
// unlocked implementation to avoid deadlocking on a non-reentrant
|
|
303
|
+
// per-key mutex).
|
|
304
|
+
await this._writeLocked(path, existing + line + '\n');
|
|
305
|
+
});
|
|
206
306
|
} catch (error) {
|
|
207
307
|
throw new JJError('STORAGE_WRITE_FAILED', `Failed to append to ${path}: ${error.message}`, {
|
|
208
308
|
path: fullPath,
|
|
@@ -1713,18 +1813,36 @@ class TagStore {
|
|
|
1713
1813
|
this.fs = fs;
|
|
1714
1814
|
this.jjDir = jjDir;
|
|
1715
1815
|
this.tagsFile = `${jjDir}/store/tags.json`;
|
|
1816
|
+
/**
|
|
1817
|
+
* Tracking info for local tags mirrored from a remote (jj v0.44 `jj tag
|
|
1818
|
+
* track`/`untrack`). Keyed by local tag name: { remote, remoteName }.
|
|
1819
|
+
* @type {Record<string, any>}
|
|
1820
|
+
*/
|
|
1821
|
+
this.tracking = {};
|
|
1716
1822
|
}
|
|
1717
1823
|
|
|
1718
1824
|
/**
|
|
1719
|
-
* Loads tags from storage
|
|
1825
|
+
* Loads tags (and their remote-tracking info, jj v0.44) from storage
|
|
1720
1826
|
* @returns {Promise<Record<string, any>>} Map of tag name to changeId
|
|
1721
1827
|
*/
|
|
1722
1828
|
async load() {
|
|
1723
1829
|
try {
|
|
1724
1830
|
const data = await this.fs.promises.readFile(this.tagsFile, 'utf8');
|
|
1725
|
-
|
|
1831
|
+
const parsed = JSON.parse(data);
|
|
1832
|
+
|
|
1833
|
+
// v0.44: tags.json may be either the legacy flat `{ name: changeId }`
|
|
1834
|
+
// map, or the newer `{ tags: {...}, tracked: {...} }` shape that also
|
|
1835
|
+
// carries remote-tracking state. Support both for backward compat.
|
|
1836
|
+
if (parsed && typeof parsed === 'object' && 'tags' in parsed) {
|
|
1837
|
+
this.tracking = parsed.tracked || {};
|
|
1838
|
+
return parsed.tags || {};
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
this.tracking = {};
|
|
1842
|
+
return parsed || {};
|
|
1726
1843
|
} catch (error) {
|
|
1727
1844
|
if (error.code === 'ENOENT') {
|
|
1845
|
+
this.tracking = {};
|
|
1728
1846
|
return {};
|
|
1729
1847
|
}
|
|
1730
1848
|
throw error;
|
|
@@ -1732,7 +1850,7 @@ class TagStore {
|
|
|
1732
1850
|
}
|
|
1733
1851
|
|
|
1734
1852
|
/**
|
|
1735
|
-
* Saves tags to storage
|
|
1853
|
+
* Saves tags (and their remote-tracking info, jj v0.44) to storage
|
|
1736
1854
|
* @param {Object} tags - Map of tag name to changeId
|
|
1737
1855
|
* @returns {Promise<void>}
|
|
1738
1856
|
*/
|
|
@@ -1744,7 +1862,10 @@ class TagStore {
|
|
|
1744
1862
|
} catch (err) {
|
|
1745
1863
|
// Directory might already exist, ignore
|
|
1746
1864
|
}
|
|
1747
|
-
await this.fs.promises.writeFile(
|
|
1865
|
+
await this.fs.promises.writeFile(
|
|
1866
|
+
this.tagsFile,
|
|
1867
|
+
JSON.stringify({ tags, tracked: this.tracking || {} }, null, 2)
|
|
1868
|
+
);
|
|
1748
1869
|
}
|
|
1749
1870
|
|
|
1750
1871
|
/**
|
|
@@ -1773,9 +1894,11 @@ class TagStore {
|
|
|
1773
1894
|
}
|
|
1774
1895
|
|
|
1775
1896
|
/**
|
|
1776
|
-
* Lists all tags, optionally filtered by pattern
|
|
1897
|
+
* Lists all tags, optionally filtered by pattern. Each entry also carries
|
|
1898
|
+
* a `tracking: { remote, ref }` field when the tag is tracking a remote
|
|
1899
|
+
* (jj v0.44 `jj tag track`).
|
|
1777
1900
|
* @param {string} [pattern] - Optional glob pattern
|
|
1778
|
-
* @returns {Promise<Array<
|
|
1901
|
+
* @returns {Promise<Array<Record<string, any>>>}
|
|
1779
1902
|
*/
|
|
1780
1903
|
async list(pattern) {
|
|
1781
1904
|
const tags = await this.load();
|
|
@@ -1786,10 +1909,17 @@ class TagStore {
|
|
|
1786
1909
|
tagNames = tagNames.filter((name) => matchesPattern$1(name, pattern));
|
|
1787
1910
|
}
|
|
1788
1911
|
|
|
1789
|
-
return tagNames.map((name) =>
|
|
1790
|
-
|
|
1791
|
-
changeId: tags[name]
|
|
1792
|
-
|
|
1912
|
+
return tagNames.map((name) => {
|
|
1913
|
+
/** @type {Record<string, any>} */
|
|
1914
|
+
const tag = { name, changeId: tags[name] };
|
|
1915
|
+
if (this.tracking && this.tracking[name]) {
|
|
1916
|
+
tag.tracking = {
|
|
1917
|
+
remote: this.tracking[name].remote,
|
|
1918
|
+
ref: this.tracking[name].remoteName || name,
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
return tag;
|
|
1922
|
+
});
|
|
1793
1923
|
}
|
|
1794
1924
|
|
|
1795
1925
|
/**
|
|
@@ -2143,11 +2273,30 @@ class RevsetParser {
|
|
|
2143
2273
|
this.next();
|
|
2144
2274
|
const value = /** @type {string} */ (tok.value);
|
|
2145
2275
|
|
|
2146
|
-
//
|
|
2276
|
+
// `::` (bare, no operands) means "all visible changes", matching jj's
|
|
2277
|
+
// `::` alias for all().
|
|
2278
|
+
if (value === '::') {
|
|
2279
|
+
return { type: 'rangeAll' };
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
// A bare "base..tip" range, e.g. `abcd1234..ef567890` — and the
|
|
2283
|
+
// open-ended forms `..tip` (ancestors(tip)) and `base..` (everything
|
|
2284
|
+
// not an ancestor of base, approximated here as descendants(base)).
|
|
2147
2285
|
if (value.includes('..')) {
|
|
2148
2286
|
const parts = value.split('..');
|
|
2149
2287
|
if (parts.length === 2) {
|
|
2150
|
-
|
|
2288
|
+
const base = parts[0].trim();
|
|
2289
|
+
const tip = parts[1].trim();
|
|
2290
|
+
if (base === '' && tip === '') {
|
|
2291
|
+
return { type: 'rangeAll' };
|
|
2292
|
+
}
|
|
2293
|
+
if (base === '') {
|
|
2294
|
+
return { type: 'rangeAncestors', tip };
|
|
2295
|
+
}
|
|
2296
|
+
if (tip === '') {
|
|
2297
|
+
return { type: 'rangeDescendants', base };
|
|
2298
|
+
}
|
|
2299
|
+
return { type: 'range', base, tip };
|
|
2151
2300
|
}
|
|
2152
2301
|
}
|
|
2153
2302
|
|
|
@@ -2216,7 +2365,7 @@ class RevsetEngine {
|
|
|
2216
2365
|
* @returns {string}
|
|
2217
2366
|
*/
|
|
2218
2367
|
_suggestion() {
|
|
2219
|
-
return 'Use @, @-, @+, bookmark(name), all(),
|
|
2368
|
+
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)';
|
|
2220
2369
|
}
|
|
2221
2370
|
|
|
2222
2371
|
/**
|
|
@@ -2273,6 +2422,15 @@ class RevsetEngine {
|
|
|
2273
2422
|
}
|
|
2274
2423
|
case 'range':
|
|
2275
2424
|
return await this.evalRange(node.base, node.tip);
|
|
2425
|
+
// Open-ended ranges (issue #18): `..tip` / `base..` / `::` delegate to
|
|
2426
|
+
// the equivalent ancestors()/descendants()/all() functions rather than
|
|
2427
|
+
// failing with a misleading "invalid IDs" error.
|
|
2428
|
+
case 'rangeAll':
|
|
2429
|
+
return await this.evaluateFunctionCall('all', '');
|
|
2430
|
+
case 'rangeAncestors':
|
|
2431
|
+
return await this.evaluateFunctionCall('ancestors', node.tip);
|
|
2432
|
+
case 'rangeDescendants':
|
|
2433
|
+
return await this.evaluateFunctionCall('descendants', node.base);
|
|
2276
2434
|
case 'call':
|
|
2277
2435
|
return await this.evaluateFunctionCall(node.name, node.argsRaw);
|
|
2278
2436
|
default:
|
|
@@ -2346,6 +2504,16 @@ class RevsetEngine {
|
|
|
2346
2504
|
await this.graph.load();
|
|
2347
2505
|
return this.graph.getAll().map((c) => c.changeId);
|
|
2348
2506
|
|
|
2507
|
+
// `builtin_log()` (jj v0.44) is the revset the built-in `jj log` uses
|
|
2508
|
+
// when no user override is configured. isomorphic-jj's `log()` already
|
|
2509
|
+
// defaults to `all()` when no revset is given (see `jj.log()`), so
|
|
2510
|
+
// `builtin_log()` is a pure alias for that same default — this lets
|
|
2511
|
+
// callers compose it (e.g. `builtin_log() & mine()`) instead of
|
|
2512
|
+
// duplicating `all()`.
|
|
2513
|
+
case 'builtin_log':
|
|
2514
|
+
await this.graph.load();
|
|
2515
|
+
return this.graph.getAll().map((c) => c.changeId);
|
|
2516
|
+
|
|
2349
2517
|
case 'none':
|
|
2350
2518
|
return [];
|
|
2351
2519
|
|
|
@@ -2376,6 +2544,13 @@ class RevsetEngine {
|
|
|
2376
2544
|
return allChanges.filter((c) => !hasChildren.has(c.changeId)).map((c) => c.changeId);
|
|
2377
2545
|
}
|
|
2378
2546
|
|
|
2547
|
+
// `git_refs()` and `git_head()` are deprecated compatibility shims.
|
|
2548
|
+
// Upstream jj deprecated both in favor of `bookmarks()`/`tags()` and
|
|
2549
|
+
// `@`, then removed them outright in jj v0.43 (revset/template calls
|
|
2550
|
+
// to either now error in real jj). isomorphic-jj keeps them working
|
|
2551
|
+
// rather than hard-breaking existing callers, but they are no longer
|
|
2552
|
+
// advertised in `_suggestion()` — prefer `bookmarks([pattern])` /
|
|
2553
|
+
// `tags([pattern])` / `@` instead.
|
|
2379
2554
|
case 'git_refs': {
|
|
2380
2555
|
if (!this.bookmarkStore) return [];
|
|
2381
2556
|
await this.bookmarkStore.load();
|
|
@@ -5330,6 +5505,51 @@ class JJCheckout {
|
|
|
5330
5505
|
}
|
|
5331
5506
|
}
|
|
5332
5507
|
|
|
5508
|
+
/**
|
|
5509
|
+
* Shared atomic-write helper for filesystem writes.
|
|
5510
|
+
*
|
|
5511
|
+
* Writes content to a uniquely-named temp file, then renames it over the
|
|
5512
|
+
* canonical path. A crash/error between those two steps leaves the
|
|
5513
|
+
* canonical file untouched (never a truncated/partial write) — the temp
|
|
5514
|
+
* file is best-effort cleaned up on failure.
|
|
5515
|
+
*
|
|
5516
|
+
* This mirrors the pattern `Storage.write()` (src/core/storage-manager.js)
|
|
5517
|
+
* already uses for JSON stores, factored out so the protobuf-backed stores
|
|
5518
|
+
* (JJOperationStore, JJViewStore, JJTreeState) can use it too instead of
|
|
5519
|
+
* calling `writeFile()` directly on the canonical path — see issue #16.
|
|
5520
|
+
*/
|
|
5521
|
+
|
|
5522
|
+
/**
|
|
5523
|
+
* @param {any} fs - Filesystem implementation (Node fs, LightningFS, etc.)
|
|
5524
|
+
* @param {string} targetPath - Canonical path to write to
|
|
5525
|
+
* @param {string|Uint8Array} content - Content to write
|
|
5526
|
+
*/
|
|
5527
|
+
async function atomicWriteFile(fs, targetPath, content) {
|
|
5528
|
+
const tmpSuffix =
|
|
5529
|
+
typeof crypto !== 'undefined' && crypto.randomUUID
|
|
5530
|
+
? crypto.randomUUID()
|
|
5531
|
+
: `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
|
5532
|
+
const tmpPath = `${targetPath}.tmp.${Date.now()}.${tmpSuffix}`;
|
|
5533
|
+
|
|
5534
|
+
try {
|
|
5535
|
+
const dirPath = targetPath.substring(0, targetPath.lastIndexOf('/'));
|
|
5536
|
+
if (dirPath) {
|
|
5537
|
+
await fs.promises.mkdir(dirPath, { recursive: true });
|
|
5538
|
+
}
|
|
5539
|
+
|
|
5540
|
+
await fs.promises.writeFile(tmpPath, content);
|
|
5541
|
+
await fs.promises.rename(tmpPath, targetPath);
|
|
5542
|
+
} catch (error) {
|
|
5543
|
+
try {
|
|
5544
|
+
await fs.promises.unlink(tmpPath);
|
|
5545
|
+
} catch {
|
|
5546
|
+
// Best-effort cleanup; ignore if the temp file was never created (or
|
|
5547
|
+
// is already gone).
|
|
5548
|
+
}
|
|
5549
|
+
throw error;
|
|
5550
|
+
}
|
|
5551
|
+
}
|
|
5552
|
+
|
|
5333
5553
|
/**
|
|
5334
5554
|
* JJ TreeState File Manager
|
|
5335
5555
|
*
|
|
@@ -5401,9 +5621,9 @@ class JJTreeState {
|
|
|
5401
5621
|
// Encode to binary
|
|
5402
5622
|
const buffer = TreeState.encode(message).finish();
|
|
5403
5623
|
|
|
5404
|
-
// Write to .jj/working_copy/tree_state
|
|
5624
|
+
// Write to .jj/working_copy/tree_state (atomically — see issue #16).
|
|
5405
5625
|
const treeStatePath = `${this.dir}/.jj/working_copy/tree_state`;
|
|
5406
|
-
await this.fs
|
|
5626
|
+
await atomicWriteFile(this.fs, treeStatePath, buffer);
|
|
5407
5627
|
}
|
|
5408
5628
|
|
|
5409
5629
|
/**
|
|
@@ -5512,9 +5732,11 @@ class JJOperationStore {
|
|
|
5512
5732
|
// Encode to binary
|
|
5513
5733
|
const buffer = Operation.encode(message).finish();
|
|
5514
5734
|
|
|
5515
|
-
// Write to .jj/repo/op_store/operations/OPERATIONID
|
|
5735
|
+
// Write to .jj/repo/op_store/operations/OPERATIONID (atomically — a
|
|
5736
|
+
// crash mid-write must never leave a truncated/undecodable file, see
|
|
5737
|
+
// issue #16).
|
|
5516
5738
|
const opPath = `${this.dir}/.jj/repo/op_store/operations/${operationId}`;
|
|
5517
|
-
await this.fs
|
|
5739
|
+
await atomicWriteFile(this.fs, opPath, buffer);
|
|
5518
5740
|
}
|
|
5519
5741
|
|
|
5520
5742
|
/**
|
|
@@ -5631,9 +5853,9 @@ class JJViewStore {
|
|
|
5631
5853
|
// Encode to binary
|
|
5632
5854
|
const buffer = View.encode(message).finish();
|
|
5633
5855
|
|
|
5634
|
-
// Write to .jj/repo/op_store/views/VIEWID
|
|
5856
|
+
// Write to .jj/repo/op_store/views/VIEWID (atomically — see issue #16).
|
|
5635
5857
|
const viewPath = `${this.dir}/.jj/repo/op_store/views/${viewId}`;
|
|
5636
|
-
await this.fs
|
|
5858
|
+
await atomicWriteFile(this.fs, viewPath, buffer);
|
|
5637
5859
|
}
|
|
5638
5860
|
|
|
5639
5861
|
/**
|
|
@@ -6210,7 +6432,11 @@ class IsomorphicGitBackend {
|
|
|
6210
6432
|
* @param {boolean} [opts.force] - Allow non-fast-forward
|
|
6211
6433
|
* @param {any} [opts.onProgress] - Progress callback
|
|
6212
6434
|
* @param {any} [opts.onAuth] - Authentication callback
|
|
6213
|
-
* @returns {Promise<{pushedRefs: Array<
|
|
6435
|
+
* @returns {Promise<{pushedRefs: Array<{name: string, oid: string}>, rejectedRefs: Array<{name: string, code: string, reason: string, message: string}>}>} Push result.
|
|
6436
|
+
* Each rejected ref carries a `code` (one of `AUTH_FAILED`,
|
|
6437
|
+
* `NETWORK_ERROR`, `NON_FAST_FORWARD`, `OTHER`) and a human-readable
|
|
6438
|
+
* `reason`, so callers can tell an auth failure apart from a
|
|
6439
|
+
* non-fast-forward rejection instead of guessing (see issue #14).
|
|
6214
6440
|
*/
|
|
6215
6441
|
async push(opts) {
|
|
6216
6442
|
if (!this.http) {
|
|
@@ -6246,8 +6472,13 @@ class IsomorphicGitBackend {
|
|
|
6246
6472
|
pushedRefs.push({ name: ref, oid });
|
|
6247
6473
|
}
|
|
6248
6474
|
} catch (pushError) {
|
|
6249
|
-
// Track rejected refs
|
|
6250
|
-
|
|
6475
|
+
// Track rejected refs with the categorized reason so callers can
|
|
6476
|
+
// distinguish an auth failure from a non-fast-forward rejection
|
|
6477
|
+
// (or a network blip) instead of guessing — see issue #14. A
|
|
6478
|
+
// caller that blindly retries with force:true on every rejection
|
|
6479
|
+
// risks doing so on what was actually an auth/network problem.
|
|
6480
|
+
const { code, reason } = this._categorizePushError(pushError);
|
|
6481
|
+
rejectedRefs.push({ name: ref, code, reason, message: pushError.message });
|
|
6251
6482
|
}
|
|
6252
6483
|
}
|
|
6253
6484
|
|
|
@@ -6279,6 +6510,53 @@ class IsomorphicGitBackend {
|
|
|
6279
6510
|
}
|
|
6280
6511
|
}
|
|
6281
6512
|
|
|
6513
|
+
/**
|
|
6514
|
+
* Categorize a per-ref push failure into a stable code/reason pair so
|
|
6515
|
+
* callers can distinguish auth failures, network errors, and
|
|
6516
|
+
* non-fast-forward rejections from each other and from other/unknown
|
|
6517
|
+
* failures — see issue #14. isomorphic-git doesn't throw a single
|
|
6518
|
+
* "AuthError" class; auth failures generally surface as an HttpError with
|
|
6519
|
+
* a 401/403 status code (or a UserCanceledError if onAuth cancels).
|
|
6520
|
+
*
|
|
6521
|
+
* @private
|
|
6522
|
+
* @param {any} error - Error thrown by git.push() for a single ref
|
|
6523
|
+
* @returns {{code: string, reason: string}}
|
|
6524
|
+
*/
|
|
6525
|
+
_categorizePushError(error) {
|
|
6526
|
+
const errCode = error && error.code;
|
|
6527
|
+
|
|
6528
|
+
if (errCode === 'PushRejectedError') {
|
|
6529
|
+
const reason = error.data && error.data.reason;
|
|
6530
|
+
return {
|
|
6531
|
+
code: 'NON_FAST_FORWARD',
|
|
6532
|
+
reason: reason === 'tag-exists' ? 'tag already exists on remote' : 'non-fast-forward',
|
|
6533
|
+
};
|
|
6534
|
+
}
|
|
6535
|
+
|
|
6536
|
+
if (errCode === 'HttpError') {
|
|
6537
|
+
const status = error.data && error.data.statusCode;
|
|
6538
|
+
if (status === 401 || status === 403) {
|
|
6539
|
+
return { code: 'AUTH_FAILED', reason: `HTTP ${status}` };
|
|
6540
|
+
}
|
|
6541
|
+
return { code: 'NETWORK_ERROR', reason: status ? `HTTP ${status}` : 'HTTP error' };
|
|
6542
|
+
}
|
|
6543
|
+
|
|
6544
|
+
if (
|
|
6545
|
+
errCode === 'AuthError' ||
|
|
6546
|
+
errCode === 'UserCanceledError' ||
|
|
6547
|
+
errCode === 'MissingUsernameError' ||
|
|
6548
|
+
errCode === 'MissingPasswordTokenError'
|
|
6549
|
+
) {
|
|
6550
|
+
return { code: 'AUTH_FAILED', reason: errCode };
|
|
6551
|
+
}
|
|
6552
|
+
|
|
6553
|
+
if (errCode === 'NetworkError' || errCode === 'UnknownTransportError') {
|
|
6554
|
+
return { code: 'NETWORK_ERROR', reason: errCode };
|
|
6555
|
+
}
|
|
6556
|
+
|
|
6557
|
+
return { code: 'OTHER', reason: (error && error.message) || String(error) };
|
|
6558
|
+
}
|
|
6559
|
+
|
|
6282
6560
|
/**
|
|
6283
6561
|
* Create minimal .jj/repo structure for jj CLI compatibility
|
|
6284
6562
|
* @private
|
|
@@ -7544,6 +7822,11 @@ async function createJJ(options) {
|
|
|
7544
7822
|
const isWorkingCopy = targetChangeId === workingCopy.getCurrentChangeId();
|
|
7545
7823
|
const fileSnapshotBefore = isWorkingCopy ? await snapshotFilesystem() : null;
|
|
7546
7824
|
|
|
7825
|
+
// Snapshot the change record itself from BEFORE this operation mutates
|
|
7826
|
+
// it in place, so undo() can restore ChangeGraph content (not just
|
|
7827
|
+
// on-disk files/working-copy pointer) — mirrors fileSnapshotBefore above.
|
|
7828
|
+
const changeSnapshotBefore = structuredClone(change);
|
|
7829
|
+
|
|
7547
7830
|
// Dispatch change:updating event (preventable)
|
|
7548
7831
|
await dispatchEventAsync(jj, 'change:updating', {
|
|
7549
7832
|
operation: 'describe',
|
|
@@ -7624,6 +7907,10 @@ async function createJJ(options) {
|
|
|
7624
7907
|
},
|
|
7625
7908
|
description: `describe change ${targetChangeId.slice(0, 8)}`,
|
|
7626
7909
|
parents: [],
|
|
7910
|
+
// Snapshot of the affected ChangeGraph node from BEFORE this
|
|
7911
|
+
// operation ran, so undo() can restore in-place content mutations
|
|
7912
|
+
// (e.g. the description change below) — not just files/pointers.
|
|
7913
|
+
changeSnapshot: { [targetChangeId]: changeSnapshotBefore },
|
|
7627
7914
|
view: {
|
|
7628
7915
|
bookmarks: {},
|
|
7629
7916
|
remoteBookmarks: {},
|
|
@@ -8136,11 +8423,28 @@ async function createJJ(options) {
|
|
|
8136
8423
|
// Try to resolve as revset
|
|
8137
8424
|
try {
|
|
8138
8425
|
const changes = await revset.evaluate(args.change);
|
|
8426
|
+
if (changes.length > 1) {
|
|
8427
|
+
// An id-prefix (or other) revset that matches more than one
|
|
8428
|
+
// change must not silently pick a "winner" — see issue #13.
|
|
8429
|
+
throw new JJError(
|
|
8430
|
+
'AMBIGUOUS_ID',
|
|
8431
|
+
`"${args.change}" is ambiguous: it resolves to ${changes.length} changes (${changes.join(', ')})`,
|
|
8432
|
+
{
|
|
8433
|
+
expression: args.change,
|
|
8434
|
+
matches: changes,
|
|
8435
|
+
suggestion: 'Use a longer prefix or an exact change ID to disambiguate',
|
|
8436
|
+
}
|
|
8437
|
+
);
|
|
8438
|
+
}
|
|
8139
8439
|
if (changes.length > 0) {
|
|
8140
8440
|
changeId = changes[0];
|
|
8141
8441
|
}
|
|
8142
|
-
} catch {
|
|
8143
|
-
|
|
8442
|
+
} catch (error) {
|
|
8443
|
+
if (error instanceof JJError && error.code === 'AMBIGUOUS_ID') {
|
|
8444
|
+
throw error;
|
|
8445
|
+
}
|
|
8446
|
+
// Not a valid revset expression - use args.change as-is (e.g. a
|
|
8447
|
+
// literal change ID that revset parsing rejected).
|
|
8144
8448
|
}
|
|
8145
8449
|
}
|
|
8146
8450
|
|
|
@@ -8383,6 +8687,21 @@ async function createJJ(options) {
|
|
|
8383
8687
|
// Restore state from previous view
|
|
8384
8688
|
await workingCopy.setCurrentChange(previousView.workingCopy);
|
|
8385
8689
|
|
|
8690
|
+
// Restore ChangeGraph content mutated in place by the operation being
|
|
8691
|
+
// undone (e.g. describe() editing change.description) — see issue #12.
|
|
8692
|
+
// currentOp.changeSnapshot holds the affected change record(s) as they
|
|
8693
|
+
// were BEFORE currentOp ran, captured the same way fileSnapshot is.
|
|
8694
|
+
await graph.load();
|
|
8695
|
+
if (currentOp && currentOp.changeSnapshot) {
|
|
8696
|
+
for (const snapshot of Object.values(
|
|
8697
|
+
/** @type {Record<string, any>} */ (currentOp.changeSnapshot)
|
|
8698
|
+
)) {
|
|
8699
|
+
if (snapshot && (await graph.getChange(snapshot.changeId))) {
|
|
8700
|
+
await graph.updateChange(snapshot);
|
|
8701
|
+
}
|
|
8702
|
+
}
|
|
8703
|
+
}
|
|
8704
|
+
|
|
8386
8705
|
// Restore filesystem from previous operation's snapshot (taken BEFORE that operation)
|
|
8387
8706
|
// This is how JJ snapshots the working copy before every command
|
|
8388
8707
|
if (previousView.fileSnapshot) {
|
|
@@ -8867,6 +9186,7 @@ async function createJJ(options) {
|
|
|
8867
9186
|
// Compute the inverse changes
|
|
8868
9187
|
const inversChanges = /** @type {Record<string, any>} */ ({
|
|
8869
9188
|
bookmarks: {},
|
|
9189
|
+
tags: {},
|
|
8870
9190
|
heads: [],
|
|
8871
9191
|
});
|
|
8872
9192
|
|
|
@@ -8906,6 +9226,46 @@ async function createJJ(options) {
|
|
|
8906
9226
|
|
|
8907
9227
|
await bookmarks.save();
|
|
8908
9228
|
|
|
9229
|
+
// Revert tag changes (mirrors the bookmark diff above — issue #12).
|
|
9230
|
+
// Unlike bookmarks, TagStore has no "move" primitive (tags are meant
|
|
9231
|
+
// to be immutable once created), so a moved tag is reverted via
|
|
9232
|
+
// delete+recreate, matching how tag.set() itself updates a tag.
|
|
9233
|
+
const prevTags = previousOp.view?.tags || {};
|
|
9234
|
+
const targetTags = targetOp.view?.tags || {};
|
|
9235
|
+
|
|
9236
|
+
for (const name of Object.keys(targetTags)) {
|
|
9237
|
+
if (!prevTags[name]) {
|
|
9238
|
+
// Tag was added - delete it
|
|
9239
|
+
if (await tags.exists(name)) {
|
|
9240
|
+
await tags.delete(name);
|
|
9241
|
+
}
|
|
9242
|
+
inversChanges.tags[name] = { action: 'deleted' };
|
|
9243
|
+
} else if (prevTags[name] !== targetTags[name]) {
|
|
9244
|
+
// Tag was moved - restore old target
|
|
9245
|
+
if (await tags.exists(name)) {
|
|
9246
|
+
await tags.delete(name);
|
|
9247
|
+
}
|
|
9248
|
+
await tags.create(name, prevTags[name]);
|
|
9249
|
+
inversChanges.tags[name] = {
|
|
9250
|
+
action: 'moved',
|
|
9251
|
+
from: targetTags[name],
|
|
9252
|
+
to: prevTags[name],
|
|
9253
|
+
};
|
|
9254
|
+
}
|
|
9255
|
+
}
|
|
9256
|
+
|
|
9257
|
+
// Check for deleted tags (existed before, don't exist after)
|
|
9258
|
+
for (const name of Object.keys(prevTags)) {
|
|
9259
|
+
if (!targetTags[name] && !(await tags.exists(name))) {
|
|
9260
|
+
// Tag was deleted - restore it
|
|
9261
|
+
await tags.create(name, prevTags[name]);
|
|
9262
|
+
inversChanges.tags[name] = {
|
|
9263
|
+
action: 'restored',
|
|
9264
|
+
to: prevTags[name],
|
|
9265
|
+
};
|
|
9266
|
+
}
|
|
9267
|
+
}
|
|
9268
|
+
|
|
8909
9269
|
// Revert working copy change if it changed
|
|
8910
9270
|
if (previousOp.view?.workingCopy !== targetOp.view?.workingCopy) {
|
|
8911
9271
|
await workingCopy.load();
|
|
@@ -8920,6 +9280,7 @@ async function createJJ(options) {
|
|
|
8920
9280
|
parents: [],
|
|
8921
9281
|
view: {
|
|
8922
9282
|
bookmarks: prevBookmarks,
|
|
9283
|
+
tags: prevTags,
|
|
8923
9284
|
remoteBookmarks: previousOp.view?.remoteBookmarks || {},
|
|
8924
9285
|
heads: previousOp.view?.heads || [],
|
|
8925
9286
|
workingCopy: previousOp.view?.workingCopy,
|
|
@@ -11357,11 +11718,18 @@ async function createJJ(options) {
|
|
|
11357
11718
|
throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
|
|
11358
11719
|
}
|
|
11359
11720
|
|
|
11360
|
-
// Generate standard conflict markers
|
|
11721
|
+
// Generate standard conflict markers. Force a newline before each
|
|
11722
|
+
// boundary marker whenever there's non-empty content that doesn't
|
|
11723
|
+
// already end in one — otherwise the content fuses onto the
|
|
11724
|
+
// `=======`/`>>>>>>> theirs` marker, producing a corrupted/unparseable
|
|
11725
|
+
// line (see issue #15). Empty sides (e.g. a modify-delete conflict)
|
|
11726
|
+
// intentionally get no extra blank line before the marker.
|
|
11361
11727
|
const ours = conflict.sides.left || '';
|
|
11362
11728
|
const theirs = conflict.sides.right || '';
|
|
11729
|
+
const oursSep = ours && !ours.endsWith('\n') ? '\n' : '';
|
|
11730
|
+
const theirsSep = theirs && !theirs.endsWith('\n') ? '\n' : '';
|
|
11363
11731
|
|
|
11364
|
-
return `<<<<<<< ours\n${ours}=======\n${theirs}>>>>>>> theirs`;
|
|
11732
|
+
return `<<<<<<< ours\n${ours}${oursSep}=======\n${theirs}${theirsSep}>>>>>>> theirs`;
|
|
11365
11733
|
},
|
|
11366
11734
|
|
|
11367
11735
|
/**
|
|
@@ -11448,10 +11816,17 @@ async function createJJ(options) {
|
|
|
11448
11816
|
* Searches the tracked files of a change (defaults to the working copy)
|
|
11449
11817
|
* for lines matching a pattern. Following jj v0.41, the pattern is treated
|
|
11450
11818
|
* as a regular expression by default; pass `{ kind: 'substring' }` for a
|
|
11451
|
-
* literal search.
|
|
11819
|
+
* literal search. isomorphic-jj has always returned the matched line
|
|
11820
|
+
* (and its 1-based `lineNumber`) alongside the path, which is what jj
|
|
11821
|
+
* v0.44 made the CLI's new default (`jj file search` used to print only
|
|
11822
|
+
* file paths). Pass `{ nameOnly: true }` for the pre-v0.44 CLI
|
|
11823
|
+
* behavior — a deduplicated list of matching file paths, matching the
|
|
11824
|
+
* new `--name-only` flag.
|
|
11452
11825
|
*
|
|
11453
|
-
* @param {Record<string, any>} args - Arguments
|
|
11454
|
-
*
|
|
11826
|
+
* @param {Record<string, any>} args - Arguments. Pass `nameOnly: true`
|
|
11827
|
+
* to return only unique matching file paths instead of per-line
|
|
11828
|
+
* matches (jj v0.44 `--name-only`)
|
|
11829
|
+
* @returns {Promise<Array<{path: string, lineNumber: number, line: string}>|Array<string>>}
|
|
11455
11830
|
*/
|
|
11456
11831
|
async search(args) {
|
|
11457
11832
|
if (!args || !args.pattern) {
|
|
@@ -11502,6 +11877,10 @@ async function createJJ(options) {
|
|
|
11502
11877
|
}
|
|
11503
11878
|
});
|
|
11504
11879
|
}
|
|
11880
|
+
|
|
11881
|
+
if (args.nameOnly) {
|
|
11882
|
+
return Array.from(new Set(results.map((r) => r.path)));
|
|
11883
|
+
}
|
|
11505
11884
|
return results;
|
|
11506
11885
|
},
|
|
11507
11886
|
|
|
@@ -12583,6 +12962,10 @@ async function createJJ(options) {
|
|
|
12583
12962
|
view: {
|
|
12584
12963
|
bookmarks: {},
|
|
12585
12964
|
remoteBookmarks: {},
|
|
12965
|
+
// Tag delta for this operation (mirrors how `bookmarks` records
|
|
12966
|
+
// its delta above), so operations.revert() can diff it — see
|
|
12967
|
+
// issue #12.
|
|
12968
|
+
tags: { [args.name]: changeId },
|
|
12586
12969
|
heads: [],
|
|
12587
12970
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
12588
12971
|
},
|
|
@@ -12628,6 +13011,7 @@ async function createJJ(options) {
|
|
|
12628
13011
|
view: {
|
|
12629
13012
|
bookmarks: {},
|
|
12630
13013
|
remoteBookmarks: {},
|
|
13014
|
+
tags: { [args.name]: changeId },
|
|
12631
13015
|
heads: [],
|
|
12632
13016
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
12633
13017
|
},
|
|
@@ -12637,10 +13021,11 @@ async function createJJ(options) {
|
|
|
12637
13021
|
},
|
|
12638
13022
|
|
|
12639
13023
|
/**
|
|
12640
|
-
* List all tags
|
|
13024
|
+
* List all tags. Each entry also carries a `tracking: { remote, ref }`
|
|
13025
|
+
* field when the tag is tracking a remote (jj v0.44 `jj tag track`).
|
|
12641
13026
|
*
|
|
12642
13027
|
* @param {Record<string, any>} [args] - Optional filter arguments
|
|
12643
|
-
* @returns {Promise<Array<
|
|
13028
|
+
* @returns {Promise<Array<Record<string, any>>>} List of tags
|
|
12644
13029
|
*/
|
|
12645
13030
|
async list(args) {
|
|
12646
13031
|
const pattern = args?.pattern;
|
|
@@ -12672,6 +13057,7 @@ async function createJJ(options) {
|
|
|
12672
13057
|
view: {
|
|
12673
13058
|
bookmarks: {},
|
|
12674
13059
|
remoteBookmarks: {},
|
|
13060
|
+
tags: {},
|
|
12675
13061
|
heads: [],
|
|
12676
13062
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
12677
13063
|
},
|
|
@@ -12679,6 +13065,94 @@ async function createJJ(options) {
|
|
|
12679
13065
|
|
|
12680
13066
|
return { deleted: args.name };
|
|
12681
13067
|
},
|
|
13068
|
+
|
|
13069
|
+
/**
|
|
13070
|
+
* Associate a local tag with a remote tag so it is updated on fetch
|
|
13071
|
+
* (matches `jj tag track`, new in jj v0.44).
|
|
13072
|
+
*
|
|
13073
|
+
* jj v0.44 made tags remote-trackable just like bookmarks: fetched
|
|
13074
|
+
* tags are tracked by default, and `jj tag track`/`untrack` let you
|
|
13075
|
+
* opt individual tags in or out by hand. isomorphic-jj doesn't (yet)
|
|
13076
|
+
* auto-import remote tags during `git.fetch()` — see CHANGELOG — so
|
|
13077
|
+
* this records tracking intent/state the same way `bookmark.track()`
|
|
13078
|
+
* does, without changing fetch's own behavior.
|
|
13079
|
+
*
|
|
13080
|
+
* @param {Record<string, any>} args - Tag arguments: `{ name, remote }`
|
|
13081
|
+
* (`name` is the local tag name to track; `remote` defaults to `'origin'`)
|
|
13082
|
+
* @returns {Promise<{name: string, remote: string, tracking: boolean}>}
|
|
13083
|
+
*/
|
|
13084
|
+
async track(args) {
|
|
13085
|
+
if (!args || !args.name) {
|
|
13086
|
+
throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
|
|
13087
|
+
suggestion: 'Provide tag name: { name: "v1.0.0", remote: "origin" }',
|
|
13088
|
+
});
|
|
13089
|
+
}
|
|
13090
|
+
|
|
13091
|
+
const remote = args.remote || 'origin';
|
|
13092
|
+
const remoteTagName = `${remote}/${args.name}`;
|
|
13093
|
+
|
|
13094
|
+
const tagMap = await tags.load();
|
|
13095
|
+
/** @type {Record<string, any>} */
|
|
13096
|
+
const tracking = tags.tracking || {};
|
|
13097
|
+
tracking[args.name] = { remote, remoteName: args.name };
|
|
13098
|
+
tags.tracking = tracking;
|
|
13099
|
+
await tags.save(tagMap);
|
|
13100
|
+
|
|
13101
|
+
// Record operation
|
|
13102
|
+
await oplog.recordOperation({
|
|
13103
|
+
timestamp: new Date().toISOString(),
|
|
13104
|
+
user: await getUserOplogInfo(),
|
|
13105
|
+
description: `tag track ${args.name} from ${remote}`,
|
|
13106
|
+
parents: [],
|
|
13107
|
+
view: {
|
|
13108
|
+
bookmarks: {},
|
|
13109
|
+
remoteBookmarks: { [remoteTagName]: true },
|
|
13110
|
+
heads: [],
|
|
13111
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
13112
|
+
},
|
|
13113
|
+
});
|
|
13114
|
+
|
|
13115
|
+
return { name: args.name, remote, tracking: true };
|
|
13116
|
+
},
|
|
13117
|
+
|
|
13118
|
+
/**
|
|
13119
|
+
* Stop tracking a remote tag (matches `jj tag untrack`, new in jj v0.44).
|
|
13120
|
+
*
|
|
13121
|
+
* @param {Record<string, any>} args - Tag arguments: `{ name }`, the
|
|
13122
|
+
* local tag name to stop tracking
|
|
13123
|
+
* @returns {Promise<{name: string, tracking: boolean, wasTracking: boolean}>}
|
|
13124
|
+
*/
|
|
13125
|
+
async untrack(args) {
|
|
13126
|
+
if (!args || !args.name) {
|
|
13127
|
+
throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
|
|
13128
|
+
suggestion: 'Provide tag name: { name: "v1.0.0" }',
|
|
13129
|
+
});
|
|
13130
|
+
}
|
|
13131
|
+
|
|
13132
|
+
const tagMap = await tags.load();
|
|
13133
|
+
/** @type {Record<string, any>} */
|
|
13134
|
+
const tracking = tags.tracking || {};
|
|
13135
|
+
const wasTracking = tracking[args.name];
|
|
13136
|
+
delete tracking[args.name];
|
|
13137
|
+
tags.tracking = tracking;
|
|
13138
|
+
await tags.save(tagMap);
|
|
13139
|
+
|
|
13140
|
+
// Record operation
|
|
13141
|
+
await oplog.recordOperation({
|
|
13142
|
+
timestamp: new Date().toISOString(),
|
|
13143
|
+
user: await getUserOplogInfo(),
|
|
13144
|
+
description: `tag untrack ${args.name}`,
|
|
13145
|
+
parents: [],
|
|
13146
|
+
view: {
|
|
13147
|
+
bookmarks: {},
|
|
13148
|
+
remoteBookmarks: {},
|
|
13149
|
+
heads: [],
|
|
13150
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
13151
|
+
},
|
|
13152
|
+
});
|
|
13153
|
+
|
|
13154
|
+
return { name: args.name, tracking: false, wasTracking: !!wasTracking };
|
|
13155
|
+
},
|
|
12682
13156
|
},
|
|
12683
13157
|
|
|
12684
13158
|
/**
|