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/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
- const tmpPath = `${fullPath}.tmp.${Date.now()}`;
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
- // Read existing content
194
- let existing = '';
195
- try {
196
- existing = await this.fs.promises.readFile(fullPath, 'utf8');
197
- } catch (error) {
198
- if (error.code !== 'ENOENT') throw error;
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
- // Write atomically
202
- await this.write(path, existing + line + '\n');
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
- return JSON.parse(data);
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(this.tagsFile, JSON.stringify(tags, null, 2));
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<{name: string, changeId: string}>>}
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
- name,
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
- // A bare "base..tip" range, e.g. `abcd1234..ef567890`.
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
- return { type: 'range', base: parts[0].trim(), tip: parts[1].trim() };
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(), none(), root(), visible_heads(), git_refs(), git_head(), 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';
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.promises.writeFile(treeStatePath, buffer);
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.promises.writeFile(opPath, buffer);
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.promises.writeFile(viewPath, buffer);
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<any>, rejectedRefs: Array<any>}>} Push result
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
- rejectedRefs.push(ref);
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
- // Use as-is
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
- * @returns {Promise<Array<{path: string, lineNumber: number, line: string}>>}
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<{name: string, changeId: string}>>} List of tags
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
  /**