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