isomorphic-jj 1.7.0 → 1.9.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
@@ -1,8 +1,5 @@
1
- import path from 'path';
2
1
  import git from 'isomorphic-git';
3
- import protobuf from 'protobufjs';
4
- import { fileURLToPath } from 'url';
5
- import crypto$1 from 'crypto';
2
+ import protobuf from 'protobufjs/light.js';
6
3
 
7
4
  /**
8
5
  * JJError - Custom error class for isomorphic-jj
@@ -26,6 +23,112 @@ class JJError extends Error {
26
23
  }
27
24
  }
28
25
 
26
+ /**
27
+ * Small in-process locking utilities.
28
+ *
29
+ * These provide same-process concurrency safety only — they do not protect
30
+ * against races between separate OS processes sharing the same on-disk
31
+ * repository. Cross-process locking is a separate concern (e.g. a lockfile
32
+ * with retry/backoff) and is intentionally out of scope here.
33
+ */
34
+
35
+ /**
36
+ * A simple FIFO async mutex: `run(fn)` queues `fn` to execute only once all
37
+ * previously-queued work on this mutex has settled, serializing access to
38
+ * whatever critical section `fn` represents.
39
+ */
40
+ class Mutex {
41
+ constructor() {
42
+ /** @type {Promise<any>} */
43
+ this._queue = Promise.resolve();
44
+ }
45
+
46
+ /**
47
+ * @template T
48
+ * @param {() => Promise<T>} fn
49
+ * @returns {Promise<T>}
50
+ */
51
+ run(fn) {
52
+ const result = this._queue.then(() => fn());
53
+ // Keep the queue moving even if `fn` rejects — a failed critical section
54
+ // must not permanently wedge the lock for subsequent callers.
55
+ this._queue = result.then(
56
+ () => undefined,
57
+ () => undefined
58
+ );
59
+ return result;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * A registry of mutexes keyed by an arbitrary string (typically a file path),
65
+ * so unrelated paths don't contend with each other while operations on the
66
+ * *same* path are fully serialized.
67
+ */
68
+ class KeyedMutex {
69
+ constructor() {
70
+ /** @type {Map<string, Mutex>} */
71
+ this._mutexes = new Map();
72
+ }
73
+
74
+ /**
75
+ * @template T
76
+ * @param {string} key
77
+ * @param {() => Promise<T>} fn
78
+ * @returns {Promise<T>}
79
+ */
80
+ run(key, fn) {
81
+ let mutex = this._mutexes.get(key);
82
+ if (!mutex) {
83
+ mutex = new Mutex();
84
+ this._mutexes.set(key, mutex);
85
+ }
86
+ return mutex.run(fn);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Recursive directory creation that works across filesystem backends.
92
+ *
93
+ * Why not `fs.promises.mkdir(path, { recursive: true })`: that's a Node
94
+ * `fs` feature. `@isomorphic-git/lightning-fs` (the filesystem this library
95
+ * documents for browser use — see issue #28) implements `mkdir()` but
96
+ * silently ignores the `recursive` option and throws `ENOENT` if any
97
+ * intermediate directory is missing, so code that assumed Node's recursive
98
+ * mkdir semantics would work identically against LightningFS actually
99
+ * broke the very first real repo init in a browser. This walks the path
100
+ * one segment at a time and creates each directory that doesn't exist yet,
101
+ * which is correct against both Node's `fs` and LightningFS (and is a
102
+ * harmless no-op-per-segment against Node's `fs` too).
103
+ *
104
+ * @param {any} fs - Filesystem implementation (isomorphic-git compatible;
105
+ * needs `fs.promises.mkdir(path)`)
106
+ * @param {string} dirPath - Absolute, POSIX-style directory path to create
107
+ * @returns {Promise<void>}
108
+ */
109
+ async function mkdirp(fs, dirPath) {
110
+ const isAbsolute = dirPath.charCodeAt(0) === 47; // '/'
111
+ const prefix = isAbsolute ? '/' : '';
112
+ const segments = dirPath.split('/').filter(Boolean);
113
+
114
+ // Compare against `current === ''` (not a truthiness check) below: the
115
+ // first accumulated segment of an absolute path is itself just `/x`,
116
+ // which parses fine, but a naive `current ? ... : ...` truthiness check
117
+ // would treat the *string* `''` the same as "unset" forever and never
118
+ // prepend the leading `/`.
119
+ let current = '';
120
+ for (const segment of segments) {
121
+ current = current === '' ? prefix + segment : `${current}/${segment}`;
122
+ try {
123
+ await fs.promises.mkdir(current);
124
+ } catch (err) {
125
+ // Already exists — fine, that's exactly what "recursive" means.
126
+ if (err && (err.code === 'EEXIST' || err.code === 'EISDIR')) continue;
127
+ throw err;
128
+ }
129
+ }
130
+ }
131
+
29
132
  /**
30
133
  * Storage Manager for isomorphic-jj
31
134
  *
@@ -45,6 +148,13 @@ class Storage {
45
148
  this.repoDir = `${dir}/.jj/repo`; // Core repo data
46
149
  this.workingCopyDir = `${dir}/.jj/working_copy`; // Default workspace working copy
47
150
  this.cache = new Map();
151
+
152
+ // Serializes write()/appendLine() critical sections per path so that
153
+ // concurrent same-process callers (e.g. a user command racing
154
+ // BackgroundOps's autosnapshot) can't interleave a read-modify-write
155
+ // cycle and silently lose an update. This only protects callers that
156
+ // share this Storage instance — cross-process locking is out of scope.
157
+ this._locks = new KeyedMutex();
48
158
  }
49
159
 
50
160
  /**
@@ -59,17 +169,17 @@ class Storage {
59
169
  async init() {
60
170
  try {
61
171
  // Create main directories
62
- await this.fs.promises.mkdir(this.jjDir, { recursive: true });
63
- await this.fs.promises.mkdir(this.repoDir, { recursive: true });
64
- await this.fs.promises.mkdir(this.workingCopyDir, { recursive: true });
172
+ await mkdirp(this.fs, this.jjDir);
173
+ await mkdirp(this.fs, this.repoDir);
174
+ await mkdirp(this.fs, this.workingCopyDir);
65
175
 
66
176
  // Create repo subdirectories
67
- await this.fs.promises.mkdir(`${this.repoDir}/store`, { recursive: true });
68
- await this.fs.promises.mkdir(`${this.repoDir}/op_log`, { recursive: true });
69
- await this.fs.promises.mkdir(`${this.repoDir}/conflicts`, { recursive: true });
177
+ await mkdirp(this.fs, `${this.repoDir}/store`);
178
+ await mkdirp(this.fs, `${this.repoDir}/op_log`);
179
+ await mkdirp(this.fs, `${this.repoDir}/conflicts`);
70
180
 
71
181
  // Create default workspace directory
72
- await this.fs.promises.mkdir(`${this.workingCopyDir}/default`, { recursive: true });
182
+ await mkdirp(this.fs, `${this.workingCopyDir}/default`);
73
183
  } catch (error) {
74
184
  throw new JJError(
75
185
  'STORAGE_INIT_FAILED',
@@ -119,14 +229,34 @@ class Storage {
119
229
  * @param {Object|string} data - Data to write (object will be stringified)
120
230
  */
121
231
  async write(path, data) {
232
+ return this._locks.run(path, () => this._writeLocked(path, data));
233
+ }
234
+
235
+ /**
236
+ * Unlocked implementation of write(). Only call this from within a
237
+ * `this._locks.run(path, ...)` critical section (directly from write()
238
+ * itself, or from appendLine() which holds the same path's lock already).
239
+ *
240
+ * @param {string} path - Relative path from .jj directory
241
+ * @param {Object|string} data - Data to write (object will be stringified)
242
+ */
243
+ async _writeLocked(path, data) {
122
244
  const fullPath = `${this.jjDir}/${path}`;
123
- const tmpPath = `${fullPath}.tmp.${Date.now()}`;
245
+ // Random suffix (not just a millisecond timestamp) so two concurrent
246
+ // writes to the same path never collide on the temp filename — a
247
+ // collision would let one write's error-cleanup unlink() delete the
248
+ // other's still in-flight temp file.
249
+ const tmpSuffix =
250
+ typeof crypto !== 'undefined' && crypto.randomUUID
251
+ ? crypto.randomUUID()
252
+ : `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
253
+ const tmpPath = `${fullPath}.tmp.${Date.now()}.${tmpSuffix}`;
124
254
  const jsonData = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
125
255
 
126
256
  try {
127
257
  // Ensure parent directory exists
128
258
  const dirPath = fullPath.substring(0, fullPath.lastIndexOf('/'));
129
- await this.fs.promises.mkdir(dirPath, { recursive: true });
259
+ await mkdirp(this.fs, dirPath);
130
260
 
131
261
  // Write to temp file
132
262
  await this.fs.promises.writeFile(tmpPath, jsonData, 'utf8');
@@ -190,16 +320,25 @@ class Storage {
190
320
  const fullPath = `${this.jjDir}/${path}`;
191
321
 
192
322
  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
- }
323
+ // Hold the same per-path lock write() uses for the *entire*
324
+ // read-modify-write cycle (not just the write half). Without this,
325
+ // two concurrent appendLine() calls on the same path can both read
326
+ // the pre-append content, and the loser's write silently drops its
327
+ // line even though both calls report success.
328
+ await this._locks.run(path, async () => {
329
+ // Read existing content
330
+ let existing = '';
331
+ try {
332
+ existing = await this.fs.promises.readFile(fullPath, 'utf8');
333
+ } catch (error) {
334
+ if (error.code !== 'ENOENT') throw error;
335
+ }
200
336
 
201
- // Write atomically
202
- await this.write(path, existing + line + '\n');
337
+ // Write atomically (already inside this path's lock, so use the
338
+ // unlocked implementation to avoid deadlocking on a non-reentrant
339
+ // per-key mutex).
340
+ await this._writeLocked(path, existing + line + '\n');
341
+ });
203
342
  } catch (error) {
204
343
  throw new JJError('STORAGE_WRITE_FAILED', `Failed to append to ${path}: ${error.message}`, {
205
344
  path: fullPath,
@@ -373,6 +512,75 @@ function validateBookmarkName(name) {
373
512
  }
374
513
  }
375
514
 
515
+ /**
516
+ * ID Generation utilities for isomorphic-jj
517
+ *
518
+ * Generates stable change IDs and operation IDs using cryptographically secure random values.
519
+ */
520
+
521
+ /**
522
+ * Generate `byteLength` cryptographically random bytes as a lowercase hex string.
523
+ *
524
+ * Uses the standard Web Crypto API (`crypto.getRandomValues`), available as
525
+ * a global in both Node.js (18.14+) and every browser — unlike Node's
526
+ * `crypto.randomBytes`, which requires `import 'crypto'` and does not exist
527
+ * in a browser bundle (see issue #28).
528
+ *
529
+ * @param {number} byteLength - Number of random bytes to generate
530
+ * @returns {string} `byteLength * 2`-character lowercase hex string
531
+ */
532
+ function randomHex(byteLength) {
533
+ const bytes = crypto.getRandomValues(new Uint8Array(byteLength));
534
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
535
+ }
536
+
537
+ /**
538
+ * Generate a stable change ID
539
+ *
540
+ * Uses 128-bit cryptographically random value encoded as 32-character hex string.
541
+ * Collision probability is negligible (2^128 space).
542
+ *
543
+ * @returns {string} 32-character lowercase hex string
544
+ */
545
+ function generateChangeId() {
546
+ return randomHex(16);
547
+ }
548
+
549
+ /**
550
+ * Generate an operation ID from operation content
551
+ *
552
+ * Uses SHA-256 hash of operation content for integrity verification.
553
+ *
554
+ * @param {Object.<string, any>} operation - Operation data
555
+ * @returns {Promise<string>} 64-character lowercase hex string (SHA-256)
556
+ */
557
+ async function generateOperationId(operation) {
558
+ const content = JSON.stringify({
559
+ timestamp: operation.timestamp,
560
+ user: operation.user,
561
+ description: operation.description,
562
+ parents: operation.parents,
563
+ view: operation.view,
564
+ });
565
+
566
+ const encoder = new TextEncoder();
567
+ const data = encoder.encode(content);
568
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
569
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
570
+ return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
571
+ }
572
+
573
+ /**
574
+ * Generate a generic ID with optional prefix
575
+ *
576
+ * @param {string} prefix - Optional prefix (e.g., 'conflict', 'bookmark')
577
+ * @returns {string} ID string
578
+ */
579
+ function generateId(prefix = '') {
580
+ const id = randomHex(16);
581
+ return prefix ? `${prefix}-${id}` : id;
582
+ }
583
+
376
584
  /**
377
585
  * ChangeGraph - Manages the change graph with stable change IDs
378
586
  *
@@ -468,6 +676,68 @@ class ChangeGraph {
468
676
  await this.save();
469
677
  }
470
678
 
679
+ /**
680
+ * Add a DIVERGENT copy of an existing change: another commit that also
681
+ * claims `change.changeId` (see issue #32 / the `divergent()` revset —
682
+ * "multiple visible commits sharing one change id"). `addChange()`
683
+ * can't represent this: `nodes` is a `Map<changeId, change>`, one entry
684
+ * per key, by design — every other API (getChange, getParents,
685
+ * getChildren, ...) keys on plain changeId and would be ambiguous if
686
+ * that stopped being unique.
687
+ *
688
+ * Divergent copies are therefore stored under a synthetic internal key
689
+ * (`${changeId}\0${commitId}`) so the `nodes` Map stays a true map, but
690
+ * each copy's *own* `changeId` field is unchanged — so `getAll()` (and
691
+ * therefore the existing `divergent()` revset filter, which counts
692
+ * occurrences of `c.changeId` across `getAll()`) sees both/all of them
693
+ * and correctly reports the change as divergent. `getChange(changeId)`
694
+ * deliberately keeps resolving to just the primary copy (the one
695
+ * actually stored under the plain `changeId` key) — for anything that
696
+ * needs every copy, see getDivergentSiblings().
697
+ *
698
+ * @param {Record<string, any>} change - The divergent copy (must have
699
+ * the same `changeId` as an existing change, and its own `commitId`)
700
+ */
701
+ async addDivergentCopy(change) {
702
+ validateChangeId(change.changeId);
703
+
704
+ if (!this.nodes.has(change.changeId)) {
705
+ throw new JJError(
706
+ 'CHANGE_NOT_FOUND',
707
+ `Cannot add a divergent copy of ${change.changeId}: no existing change with that id`,
708
+ { changeId: change.changeId, suggestion: 'Use addChange() to create the first copy' }
709
+ );
710
+ }
711
+
712
+ const key = `${change.changeId}\0${change.commitId}`;
713
+ if (this.nodes.has(key)) {
714
+ throw new JJError(
715
+ 'CHANGE_EXISTS',
716
+ `Change ${change.changeId} already has a divergent copy with commit ${change.commitId}`,
717
+ { changeId: change.changeId, commitId: change.commitId }
718
+ );
719
+ }
720
+
721
+ change.divergent = true;
722
+ this.nodes.set(key, change);
723
+ this.commitIndex.set(change.commitId, change.changeId);
724
+
725
+ await this.save();
726
+ }
727
+
728
+ /**
729
+ * Get every visible copy of a (possibly divergent) change id — just the
730
+ * one copy in the common case, or the primary plus every divergent copy
731
+ * added via addDivergentCopy().
732
+ *
733
+ * @param {string} changeId - Change ID
734
+ * @returns {Array<any>} All copies sharing this changeId, primary first
735
+ */
736
+ getDivergentSiblings(changeId) {
737
+ validateChangeId(changeId);
738
+ return this.getAll().filter((change) => change.changeId === changeId);
739
+ }
740
+
471
741
  /**
472
742
  * Get a change by ID
473
743
  *
@@ -579,6 +849,65 @@ class ChangeGraph {
579
849
  await this.save();
580
850
  }
581
851
 
852
+ /**
853
+ * Remove one divergent copy added via addDivergentCopy() — e.g. after
854
+ * converge() (issue #32) resolves a divergence and only needs to drop
855
+ * the now-superseded copy. The *primary* copy (stored under the plain
856
+ * `changeId` key) is never touched by this — use deleteChange() (or
857
+ * updateChange() to overwrite it with a resolved result) for that.
858
+ *
859
+ * @param {string} changeId
860
+ * @param {string} commitId - The divergent copy's own commit id
861
+ * @returns {Promise<boolean>} Whether a copy was actually removed
862
+ */
863
+ async deleteDivergentCopy(changeId, commitId) {
864
+ validateChangeId(changeId);
865
+
866
+ const key = `${changeId}\0${commitId}`;
867
+ const change = this.nodes.get(key);
868
+ if (!change) {
869
+ return false;
870
+ }
871
+
872
+ this.nodes.delete(key);
873
+ if (this.commitIndex.get(commitId) === changeId) {
874
+ this.commitIndex.delete(commitId);
875
+ }
876
+
877
+ await this.save();
878
+ return true;
879
+ }
880
+
881
+ /**
882
+ * Remove a change from the graph outright.
883
+ *
884
+ * Used to reverse an addChange() — e.g. undoing an operation that created
885
+ * a change (new(), or squash()'s synthetic empty working-copy change) —
886
+ * where restoring a "before" snapshot doesn't apply because there was no
887
+ * "before": the change simply didn't exist yet. A soft-delete (setting
888
+ * `abandoned: true` via updateChange()) is NOT the same thing and is used
889
+ * separately for abandon() itself.
890
+ *
891
+ * @param {string} changeId - Change ID to remove
892
+ * @returns {Promise<boolean>} Whether a change was actually removed
893
+ */
894
+ async deleteChange(changeId) {
895
+ validateChangeId(changeId);
896
+
897
+ const change = this.nodes.get(changeId);
898
+ if (!change) {
899
+ return false;
900
+ }
901
+
902
+ this.nodes.delete(changeId);
903
+ if (this.commitIndex.get(change.commitId) === changeId) {
904
+ this.commitIndex.delete(change.commitId);
905
+ }
906
+
907
+ await this.save();
908
+ return true;
909
+ }
910
+
582
911
  /**
583
912
  * Get all ancestors of a change (recursive parent traversal)
584
913
  *
@@ -616,10 +945,9 @@ class ChangeGraph {
616
945
  * @returns {Promise<Record<string, any>>} Created change object
617
946
  */
618
947
  async createChange(params = {}) {
619
- const crypto = await import('crypto');
620
- const changeId = params.changeId || crypto.randomBytes(16).toString('hex');
621
- const commitId = params.commitId || crypto.randomBytes(20).toString('hex');
622
- const tree = params.tree || crypto.randomBytes(20).toString('hex');
948
+ const changeId = params.changeId || randomHex(16);
949
+ const commitId = params.commitId || randomHex(20);
950
+ const tree = params.tree || randomHex(20);
623
951
  const timestamp = params.timestamp || new Date().toISOString();
624
952
 
625
953
  /** @type {Record<string, any>} */
@@ -1083,61 +1411,6 @@ class WorkingCopy {
1083
1411
  }
1084
1412
  }
1085
1413
 
1086
- /**
1087
- * ID Generation utilities for isomorphic-jj
1088
- *
1089
- * Generates stable change IDs and operation IDs using cryptographically secure random values.
1090
- */
1091
-
1092
- /**
1093
- * Generate a stable change ID
1094
- *
1095
- * Uses 128-bit cryptographically random value encoded as 32-character hex string.
1096
- * Collision probability is negligible (2^128 space).
1097
- *
1098
- * @returns {string} 32-character lowercase hex string
1099
- */
1100
- function generateChangeId() {
1101
- const bytes = crypto.getRandomValues(new Uint8Array(16));
1102
- return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
1103
- }
1104
-
1105
- /**
1106
- * Generate an operation ID from operation content
1107
- *
1108
- * Uses SHA-256 hash of operation content for integrity verification.
1109
- *
1110
- * @param {Object.<string, any>} operation - Operation data
1111
- * @returns {Promise<string>} 64-character lowercase hex string (SHA-256)
1112
- */
1113
- async function generateOperationId(operation) {
1114
- const content = JSON.stringify({
1115
- timestamp: operation.timestamp,
1116
- user: operation.user,
1117
- description: operation.description,
1118
- parents: operation.parents,
1119
- view: operation.view,
1120
- });
1121
-
1122
- const encoder = new TextEncoder();
1123
- const data = encoder.encode(content);
1124
- const hashBuffer = await crypto.subtle.digest('SHA-256', data);
1125
- const hashArray = Array.from(new Uint8Array(hashBuffer));
1126
- return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
1127
- }
1128
-
1129
- /**
1130
- * Generate a generic ID with optional prefix
1131
- *
1132
- * @param {string} prefix - Optional prefix (e.g., 'conflict', 'bookmark')
1133
- * @returns {string} ID string
1134
- */
1135
- function generateId(prefix = '') {
1136
- const bytes = crypto.getRandomValues(new Uint8Array(16));
1137
- const id = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
1138
- return prefix ? `${prefix}-${id}` : id;
1139
- }
1140
-
1141
1414
  /**
1142
1415
  * OperationLog - Manages the append-only operation log
1143
1416
  *
@@ -1710,18 +1983,36 @@ class TagStore {
1710
1983
  this.fs = fs;
1711
1984
  this.jjDir = jjDir;
1712
1985
  this.tagsFile = `${jjDir}/store/tags.json`;
1986
+ /**
1987
+ * Tracking info for local tags mirrored from a remote (jj v0.44 `jj tag
1988
+ * track`/`untrack`). Keyed by local tag name: { remote, remoteName }.
1989
+ * @type {Record<string, any>}
1990
+ */
1991
+ this.tracking = {};
1713
1992
  }
1714
1993
 
1715
1994
  /**
1716
- * Loads tags from storage
1995
+ * Loads tags (and their remote-tracking info, jj v0.44) from storage
1717
1996
  * @returns {Promise<Record<string, any>>} Map of tag name to changeId
1718
1997
  */
1719
1998
  async load() {
1720
1999
  try {
1721
2000
  const data = await this.fs.promises.readFile(this.tagsFile, 'utf8');
1722
- return JSON.parse(data);
2001
+ const parsed = JSON.parse(data);
2002
+
2003
+ // v0.44: tags.json may be either the legacy flat `{ name: changeId }`
2004
+ // map, or the newer `{ tags: {...}, tracked: {...} }` shape that also
2005
+ // carries remote-tracking state. Support both for backward compat.
2006
+ if (parsed && typeof parsed === 'object' && 'tags' in parsed) {
2007
+ this.tracking = parsed.tracked || {};
2008
+ return parsed.tags || {};
2009
+ }
2010
+
2011
+ this.tracking = {};
2012
+ return parsed || {};
1723
2013
  } catch (error) {
1724
2014
  if (error.code === 'ENOENT') {
2015
+ this.tracking = {};
1725
2016
  return {};
1726
2017
  }
1727
2018
  throw error;
@@ -1729,19 +2020,18 @@ class TagStore {
1729
2020
  }
1730
2021
 
1731
2022
  /**
1732
- * Saves tags to storage
2023
+ * Saves tags (and their remote-tracking info, jj v0.44) to storage
1733
2024
  * @param {Object} tags - Map of tag name to changeId
1734
2025
  * @returns {Promise<void>}
1735
2026
  */
1736
2027
  async save(tags) {
1737
2028
  // Ensure the store directory exists
1738
2029
  const storeDir = `${this.jjDir}/store`;
1739
- try {
1740
- await this.fs.promises.mkdir(storeDir, { recursive: true });
1741
- } catch (err) {
1742
- // Directory might already exist, ignore
1743
- }
1744
- await this.fs.promises.writeFile(this.tagsFile, JSON.stringify(tags, null, 2));
2030
+ await mkdirp(this.fs, storeDir);
2031
+ await this.fs.promises.writeFile(
2032
+ this.tagsFile,
2033
+ JSON.stringify({ tags, tracked: this.tracking || {} }, null, 2)
2034
+ );
1745
2035
  }
1746
2036
 
1747
2037
  /**
@@ -1770,9 +2060,11 @@ class TagStore {
1770
2060
  }
1771
2061
 
1772
2062
  /**
1773
- * Lists all tags, optionally filtered by pattern
2063
+ * Lists all tags, optionally filtered by pattern. Each entry also carries
2064
+ * a `tracking: { remote, ref }` field when the tag is tracking a remote
2065
+ * (jj v0.44 `jj tag track`).
1774
2066
  * @param {string} [pattern] - Optional glob pattern
1775
- * @returns {Promise<Array<{name: string, changeId: string}>>}
2067
+ * @returns {Promise<Array<Record<string, any>>>}
1776
2068
  */
1777
2069
  async list(pattern) {
1778
2070
  const tags = await this.load();
@@ -1783,10 +2075,17 @@ class TagStore {
1783
2075
  tagNames = tagNames.filter((name) => matchesPattern$1(name, pattern));
1784
2076
  }
1785
2077
 
1786
- return tagNames.map((name) => ({
1787
- name,
1788
- changeId: tags[name],
1789
- }));
2078
+ return tagNames.map((name) => {
2079
+ /** @type {Record<string, any>} */
2080
+ const tag = { name, changeId: tags[name] };
2081
+ if (this.tracking && this.tracking[name]) {
2082
+ tag.tracking = {
2083
+ remote: this.tracking[name].remote,
2084
+ ref: this.tracking[name].remoteName || name,
2085
+ };
2086
+ }
2087
+ return tag;
2088
+ });
1790
2089
  }
1791
2090
 
1792
2091
  /**
@@ -2140,11 +2439,30 @@ class RevsetParser {
2140
2439
  this.next();
2141
2440
  const value = /** @type {string} */ (tok.value);
2142
2441
 
2143
- // A bare "base..tip" range, e.g. `abcd1234..ef567890`.
2442
+ // `::` (bare, no operands) means "all visible changes", matching jj's
2443
+ // `::` alias for all().
2444
+ if (value === '::') {
2445
+ return { type: 'rangeAll' };
2446
+ }
2447
+
2448
+ // A bare "base..tip" range, e.g. `abcd1234..ef567890` — and the
2449
+ // open-ended forms `..tip` (ancestors(tip)) and `base..` (everything
2450
+ // not an ancestor of base, approximated here as descendants(base)).
2144
2451
  if (value.includes('..')) {
2145
2452
  const parts = value.split('..');
2146
2453
  if (parts.length === 2) {
2147
- return { type: 'range', base: parts[0].trim(), tip: parts[1].trim() };
2454
+ const base = parts[0].trim();
2455
+ const tip = parts[1].trim();
2456
+ if (base === '' && tip === '') {
2457
+ return { type: 'rangeAll' };
2458
+ }
2459
+ if (base === '') {
2460
+ return { type: 'rangeAncestors', tip };
2461
+ }
2462
+ if (tip === '') {
2463
+ return { type: 'rangeDescendants', base };
2464
+ }
2465
+ return { type: 'range', base, tip };
2148
2466
  }
2149
2467
  }
2150
2468
 
@@ -2213,7 +2531,7 @@ class RevsetEngine {
2213
2531
  * @returns {string}
2214
2532
  */
2215
2533
  _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';
2534
+ 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
2535
  }
2218
2536
 
2219
2537
  /**
@@ -2270,6 +2588,15 @@ class RevsetEngine {
2270
2588
  }
2271
2589
  case 'range':
2272
2590
  return await this.evalRange(node.base, node.tip);
2591
+ // Open-ended ranges (issue #18): `..tip` / `base..` / `::` delegate to
2592
+ // the equivalent ancestors()/descendants()/all() functions rather than
2593
+ // failing with a misleading "invalid IDs" error.
2594
+ case 'rangeAll':
2595
+ return await this.evaluateFunctionCall('all', '');
2596
+ case 'rangeAncestors':
2597
+ return await this.evaluateFunctionCall('ancestors', node.tip);
2598
+ case 'rangeDescendants':
2599
+ return await this.evaluateFunctionCall('descendants', node.base);
2273
2600
  case 'call':
2274
2601
  return await this.evaluateFunctionCall(node.name, node.argsRaw);
2275
2602
  default:
@@ -2343,6 +2670,16 @@ class RevsetEngine {
2343
2670
  await this.graph.load();
2344
2671
  return this.graph.getAll().map((c) => c.changeId);
2345
2672
 
2673
+ // `builtin_log()` (jj v0.44) is the revset the built-in `jj log` uses
2674
+ // when no user override is configured. isomorphic-jj's `log()` already
2675
+ // defaults to `all()` when no revset is given (see `jj.log()`), so
2676
+ // `builtin_log()` is a pure alias for that same default — this lets
2677
+ // callers compose it (e.g. `builtin_log() & mine()`) instead of
2678
+ // duplicating `all()`.
2679
+ case 'builtin_log':
2680
+ await this.graph.load();
2681
+ return this.graph.getAll().map((c) => c.changeId);
2682
+
2346
2683
  case 'none':
2347
2684
  return [];
2348
2685
 
@@ -2373,6 +2710,13 @@ class RevsetEngine {
2373
2710
  return allChanges.filter((c) => !hasChildren.has(c.changeId)).map((c) => c.changeId);
2374
2711
  }
2375
2712
 
2713
+ // `git_refs()` and `git_head()` are deprecated compatibility shims.
2714
+ // Upstream jj deprecated both in favor of `bookmarks()`/`tags()` and
2715
+ // `@`, then removed them outright in jj v0.43 (revset/template calls
2716
+ // to either now error in real jj). isomorphic-jj keeps them working
2717
+ // rather than hard-breaking existing callers, but they are no longer
2718
+ // advertised in `_suggestion()` — prefer `bookmarks([pattern])` /
2719
+ // `tags([pattern])` / `@` instead.
2376
2720
  case 'git_refs': {
2377
2721
  if (!this.bookmarkStore) return [];
2378
2722
  await this.bookmarkStore.load();
@@ -2449,15 +2793,27 @@ class RevsetEngine {
2449
2793
  }
2450
2794
 
2451
2795
  case 'divergent': {
2796
+ // See issue #32 / converge(): a divergent change has more than one
2797
+ // visible commit sharing its changeId (added via
2798
+ // ChangeGraph.addDivergentCopy(), which is why counting `changeId`
2799
+ // occurrences across getAll() actually finds something now —
2800
+ // every other change has exactly one entry here). `c.divergent ===
2801
+ // true` also matches directly, since addDivergentCopy() sets that
2802
+ // flag on the copy it adds; the count check catches the *primary*
2803
+ // copy too (it's never itself flagged, since nothing about IT
2804
+ // changed) so both/all copies of a divergent changeId are
2805
+ // reported, not just the one(s) added after the first.
2452
2806
  await this.graph.load();
2453
2807
  const all = this.graph.getAll();
2454
2808
  const counts = new Map();
2455
2809
  for (const c of all) {
2456
2810
  counts.set(c.changeId, (counts.get(c.changeId) || 0) + 1);
2457
2811
  }
2458
- return all
2459
- .filter((c) => c.divergent === true || counts.get(c.changeId) > 1)
2460
- .map((c) => c.changeId);
2812
+ const matched = all.filter((c) => c.divergent === true || counts.get(c.changeId) > 1);
2813
+ // De-duplicate: multiple copies of the same divergent changeId
2814
+ // would otherwise each contribute their own (identical) changeId
2815
+ // to the result.
2816
+ return Array.from(new Set(matched.map((c) => c.changeId)));
2461
2817
  }
2462
2818
 
2463
2819
  case 'merges':
@@ -3511,23 +3867,201 @@ class RevsetEngine {
3511
3867
  }
3512
3868
 
3513
3869
  /**
3514
- * ConflictModel - First-class conflict handling
3870
+ * Minimal POSIX-only path utilities (join / dirname / resolve).
3515
3871
  *
3516
- * In JJ, conflicts are not errors but structured data that can be:
3517
- * - Stored in commits
3518
- * - Rebased along with changes
3519
- * - Resolved incrementally
3520
- * - Committed and shared
3872
+ * Why not Node's `path` module: this library is isomorphic — it also needs
3873
+ * to run in browser bundles (see issue #28), where bare `import path from
3874
+ * 'path'` fails unless a bundler-specific polyfill is configured. Git/jj
3875
+ * repository paths are always POSIX-style (forward slashes), regardless of
3876
+ * host OS, so depending on Node's `path` module was also subtly wrong on
3877
+ * Windows: `path.join()` there uses `path.win32` semantics and would
3878
+ * produce backslash-joined paths for repository-relative paths, which is
3879
+ * not what git/jj object storage or working-copy paths want.
3521
3880
  *
3522
- * This matches JJ's philosophy: conflicts never block you.
3881
+ * These implement the same normalization rules as Node's `path.posix` for
3882
+ * the operations this codebase actually uses.
3523
3883
  */
3524
3884
 
3525
-
3526
3885
  /**
3527
- * Conflict types supported by JJ
3886
+ * @param {string} path
3887
+ * @returns {string} `path` with any run of `/` collapsed to a single `/`,
3888
+ * and `.`/`..` segments resolved. Preserves a leading `/` (absolute) and
3889
+ * a trailing `/` when present in the input.
3528
3890
  */
3529
- const ConflictType = {
3530
- CONTENT: 'content', // File content conflicts (3-way merge)
3891
+ function normalize(path) {
3892
+ if (path === '') return '.';
3893
+
3894
+ const isAbsolute = path.charCodeAt(0) === 47; // '/'
3895
+ const trailingSlash = path.charCodeAt(path.length - 1) === 47;
3896
+
3897
+ const segments = path.split('/');
3898
+ /** @type {string[]} */
3899
+ const out = [];
3900
+ for (const segment of segments) {
3901
+ if (segment === '' || segment === '.') continue;
3902
+ if (segment === '..') {
3903
+ if (out.length && out[out.length - 1] !== '..') {
3904
+ out.pop();
3905
+ } else if (!isAbsolute) {
3906
+ out.push('..');
3907
+ }
3908
+ continue;
3909
+ }
3910
+ out.push(segment);
3911
+ }
3912
+
3913
+ let result = out.join('/');
3914
+ if (!result && !isAbsolute) result = '.';
3915
+ if (isAbsolute) result = '/' + result;
3916
+ if (trailingSlash && result.charCodeAt(result.length - 1) !== 47) result += '/';
3917
+ return result;
3918
+ }
3919
+
3920
+ /**
3921
+ * @param {...string} parts
3922
+ * @returns {string} All `parts` joined with `/` and normalized.
3923
+ */
3924
+ function join(...parts) {
3925
+ if (parts.length === 0) return '.';
3926
+ const joined = parts.filter((p) => p !== '').join('/');
3927
+ return joined === '' ? '.' : normalize(joined);
3928
+ }
3929
+
3930
+ /**
3931
+ * @param {string} path
3932
+ * @returns {string} The directory portion of `path` (Node `path.dirname` semantics).
3933
+ */
3934
+ function dirname(path) {
3935
+ if (path.length === 0) return '.';
3936
+ const isAbsolute = path.charCodeAt(0) === 47;
3937
+ let end = -1;
3938
+ let matchedSlash = true;
3939
+ for (let i = path.length - 1; i >= 1; i--) {
3940
+ if (path.charCodeAt(i) === 47) {
3941
+ if (!matchedSlash) {
3942
+ end = i;
3943
+ break;
3944
+ }
3945
+ } else {
3946
+ matchedSlash = false;
3947
+ }
3948
+ }
3949
+
3950
+ if (end === -1) return isAbsolute ? '/' : '.';
3951
+ if (isAbsolute && end === 0) return '/';
3952
+ return path.slice(0, end);
3953
+ }
3954
+
3955
+ /**
3956
+ * Resolves a sequence of path segments into an absolute path.
3957
+ *
3958
+ * Unlike Node's `path.resolve`, this never consults the real process cwd —
3959
+ * segments are resolved right-to-left, and if no absolute segment is found
3960
+ * the result is left relative to a virtual `/` root (this library always
3961
+ * deals in absolute repository-relative paths, so callers are expected to
3962
+ * supply an absolute base).
3963
+ *
3964
+ * @param {...string} parts
3965
+ * @returns {string}
3966
+ */
3967
+ function resolve(...parts) {
3968
+ let resolved = '';
3969
+ let isAbsolute = false;
3970
+
3971
+ for (let i = parts.length - 1; i >= -1 && !isAbsolute; i--) {
3972
+ const path = i >= 0 ? parts[i] : '/';
3973
+ if (path.length === 0) continue;
3974
+ resolved = path + '/' + resolved;
3975
+ isAbsolute = path.charCodeAt(0) === 47;
3976
+ }
3977
+
3978
+ resolved = normalize(resolved);
3979
+ // normalize() preserves a trailing slash; path.resolve() never returns one
3980
+ // (except for the root itself).
3981
+ if (resolved.length > 1 && resolved.endsWith('/')) {
3982
+ resolved = resolved.slice(0, -1);
3983
+ }
3984
+ return isAbsolute ? resolved : '/' + resolved;
3985
+ }
3986
+
3987
+ /**
3988
+ * Cross-environment byte/string conversion helpers.
3989
+ *
3990
+ * Why not just Node's `Buffer`: it's a Node global that most browser
3991
+ * bundlers (Vite, esbuild, webpack 5+) do not polyfill by default, so code
3992
+ * that calls `Buffer.from(...)` compiles fine but throws `ReferenceError:
3993
+ * Buffer is not defined` at runtime in a browser bundle (see issue #28).
3994
+ *
3995
+ * `utf8Encode`/`utf8Decode` still prefer the real `Buffer` when it's present
3996
+ * (i.e. under Node) so existing behavior — including `.toString()` producing
3997
+ * the original text back, which a plain `Uint8Array` cannot do — is
3998
+ * unchanged there; `typeof Buffer` is safe to evaluate even where `Buffer`
3999
+ * was never declared (unlike referencing it directly), so this doesn't
4000
+ * reintroduce the crash outside Node. `hexToBytes`/`bytesToHex`/`isBytes`
4001
+ * only ever need `Uint8Array`, so they don't need the same branch.
4002
+ */
4003
+ const hasBuffer = typeof Buffer !== 'undefined';
4004
+
4005
+ /**
4006
+ * @param {string} hex - Even-length hex string
4007
+ * @returns {Uint8Array}
4008
+ */
4009
+ function hexToBytes(hex) {
4010
+ const length = hex.length / 2;
4011
+ const bytes = new Uint8Array(length);
4012
+ for (let i = 0; i < length; i++) {
4013
+ bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
4014
+ }
4015
+ return bytes;
4016
+ }
4017
+
4018
+ /**
4019
+ * @param {string} str
4020
+ * @returns {Uint8Array} UTF-8 encoded bytes (a real `Buffer` under Node)
4021
+ */
4022
+ function utf8Encode(str) {
4023
+ return hasBuffer ? Buffer.from(str, 'utf-8') : new TextEncoder().encode(str);
4024
+ }
4025
+
4026
+ /**
4027
+ * @param {Uint8Array} bytes
4028
+ * @returns {string} UTF-8 decoded string
4029
+ */
4030
+ function utf8Decode(bytes) {
4031
+ return hasBuffer ? Buffer.from(bytes).toString('utf-8') : new TextDecoder('utf-8').decode(bytes);
4032
+ }
4033
+
4034
+ /**
4035
+ * True for anything that walks/quacks like binary content — a real Node
4036
+ * `Buffer` (which is itself a `Uint8Array` subclass) or a plain
4037
+ * `Uint8Array`/typed array, as produced by protobufjs in environments
4038
+ * without `Buffer` (e.g. a browser bundle).
4039
+ *
4040
+ * @param {any} value
4041
+ * @returns {boolean}
4042
+ */
4043
+ function isBytes(value) {
4044
+ return value instanceof Uint8Array;
4045
+ }
4046
+
4047
+ /**
4048
+ * ConflictModel - First-class conflict handling
4049
+ *
4050
+ * In JJ, conflicts are not errors but structured data that can be:
4051
+ * - Stored in commits
4052
+ * - Rebased along with changes
4053
+ * - Resolved incrementally
4054
+ * - Committed and shared
4055
+ *
4056
+ * This matches JJ's philosophy: conflicts never block you.
4057
+ */
4058
+
4059
+
4060
+ /**
4061
+ * Conflict types supported by JJ
4062
+ */
4063
+ const ConflictType = {
4064
+ CONTENT: 'content', // File content conflicts (3-way merge)
3531
4065
  DELETE_MODIFY: 'delete-modify', // File deleted in one side, modified in other
3532
4066
  MODIFY_DELETE: 'modify-delete', // File modified in one side, deleted in other
3533
4067
  ADD_ADD: 'add-add', // Same path added in both sides
@@ -3682,6 +4216,15 @@ class ConflictModel {
3682
4216
  const conflict = this._detectPathConflict(path, baseContent, leftContent, rightContent);
3683
4217
  if (conflict) {
3684
4218
  conflicts.push(conflict);
4219
+ } else if (workingCopyDir) {
4220
+ // No conflict, but "no conflict" doesn't mean "nothing to do": of
4221
+ // _detectPathConflict's null-returning cases, every one *except*
4222
+ // "right side changed, left didn't" already matches what's on disk,
4223
+ // because `leftContent` **is** what's on disk (read straight from
4224
+ // the working copy above) -- so only that one case needs a write.
4225
+ if (baseContent === leftContent && baseContent !== rightContent) {
4226
+ await this._applyCleanResolution(workingCopyDir, path, rightContent);
4227
+ }
3685
4228
  }
3686
4229
  }
3687
4230
 
@@ -3769,6 +4312,30 @@ class ConflictModel {
3769
4312
  return null;
3770
4313
  }
3771
4314
 
4315
+ /**
4316
+ * Create a content conflict with more than two disagreeing versions —
4317
+ * every other conflict in this module has exactly two sides (`left`/
4318
+ * `right`, or `left`/`right` on an add-add), because every OTHER caller
4319
+ * (merge, rebase) only ever three-way-merges two changes at a time.
4320
+ * converge() (issue #32) can face more than two divergent copies of one
4321
+ * change in a single call, so `sides` here is `{ base, versions }`
4322
+ * instead: `versions` is every distinct value found across all copies
4323
+ * that differs from `base`, each paired with (one of) the commitId(s)
4324
+ * that has it.
4325
+ *
4326
+ * @param {string} path
4327
+ * @param {any} base
4328
+ * @param {Array<{commitId: string, content: any}>} versions
4329
+ */
4330
+ createNWayConflict(path, base, versions) {
4331
+ return this._createConflict({
4332
+ type: ConflictType.CONTENT,
4333
+ path,
4334
+ sides: { base, versions },
4335
+ message: `${versions.length} divergent copies disagree on this file's content`,
4336
+ });
4337
+ }
4338
+
3772
4339
  /**
3773
4340
  * Create a conflict object
3774
4341
  *
@@ -4012,6 +4579,36 @@ class ConflictModel {
4012
4579
  return result;
4013
4580
  }
4014
4581
 
4582
+ /**
4583
+ * Apply a clean (non-conflicting) three-way resolution to the working
4584
+ * copy, for the default (no merge driver) path in detectConflicts(): the
4585
+ * "right side wins" case, where the resolved content differs from what's
4586
+ * currently on disk (see the call site for why this is the only clean
4587
+ * case that needs one).
4588
+ *
4589
+ * @param {string} workingCopyDir - Working copy directory
4590
+ * @param {string} filePath - File path
4591
+ * @param {any} content - The winning (right) side's content, or
4592
+ * `undefined` to mean the file was deleted on that side
4593
+ */
4594
+ async _applyCleanResolution(workingCopyDir, filePath, content) {
4595
+ const fullPath = join(workingCopyDir, filePath);
4596
+
4597
+ if (content === undefined) {
4598
+ await this.fs.promises.unlink(fullPath).catch(() => {});
4599
+ return;
4600
+ }
4601
+
4602
+ const dir = dirname(fullPath);
4603
+ await mkdirp(this.fs, dir);
4604
+
4605
+ if (isBytes(content)) {
4606
+ await this.fs.promises.writeFile(fullPath, content);
4607
+ } else {
4608
+ await this.fs.promises.writeFile(fullPath, content, 'utf8');
4609
+ }
4610
+ }
4611
+
4015
4612
  /**
4016
4613
  * Write merge driver result to working copy (v0.5)
4017
4614
  *
@@ -4020,32 +4617,32 @@ class ConflictModel {
4020
4617
  * @param {Record<string, any>} result - Merge result
4021
4618
  */
4022
4619
  async _writeDriverResult(workingCopyDir, filePath, result) {
4023
- const fullPath = path.join(workingCopyDir, filePath);
4620
+ const fullPath = join(workingCopyDir, filePath);
4024
4621
 
4025
4622
  // Ensure directory exists
4026
- const dir = path.dirname(fullPath);
4027
- await this.fs.promises.mkdir(dir, { recursive: true });
4623
+ const dir = dirname(fullPath);
4624
+ await mkdirp(this.fs, dir);
4028
4625
 
4029
4626
  // Write main file
4030
4627
  if (result.content !== null && result.content !== undefined) {
4031
- if (Buffer.isBuffer(result.content)) {
4628
+ if (isBytes(result.content)) {
4032
4629
  await this.fs.promises.writeFile(fullPath, result.content);
4033
4630
  } else {
4034
- await this.fs.promises.writeFile(fullPath, result.content, 'utf-8');
4631
+ await this.fs.promises.writeFile(fullPath, result.content, 'utf8');
4035
4632
  }
4036
4633
  }
4037
4634
 
4038
4635
  // Write additional files if driver provided them
4039
4636
  if (result.additionalFiles) {
4040
4637
  for (const [additionalPath, additionalContent] of Object.entries(result.additionalFiles)) {
4041
- const additionalFullPath = path.join(workingCopyDir, additionalPath);
4042
- const additionalDir = path.dirname(additionalFullPath);
4043
- await this.fs.promises.mkdir(additionalDir, { recursive: true });
4638
+ const additionalFullPath = join(workingCopyDir, additionalPath);
4639
+ const additionalDir = dirname(additionalFullPath);
4640
+ await mkdirp(this.fs, additionalDir);
4044
4641
 
4045
- if (Buffer.isBuffer(additionalContent)) {
4642
+ if (isBytes(additionalContent)) {
4046
4643
  await this.fs.promises.writeFile(additionalFullPath, additionalContent);
4047
4644
  } else {
4048
- await this.fs.promises.writeFile(additionalFullPath, additionalContent, 'utf-8');
4645
+ await this.fs.promises.writeFile(additionalFullPath, additionalContent, 'utf8');
4049
4646
  }
4050
4647
  }
4051
4648
  }
@@ -4091,11 +4688,11 @@ function matchesPattern(path, pattern) {
4091
4688
  /**
4092
4689
  * Check if a file is likely binary based on content
4093
4690
  *
4094
- * @param {string|Buffer} content - File content
4691
+ * @param {string|Uint8Array} content - File content
4095
4692
  * @returns {boolean} Whether content appears binary
4096
4693
  */
4097
4694
  function isBinaryContent(content) {
4098
- if (Buffer.isBuffer(content)) return true;
4695
+ if (isBytes(content)) return true;
4099
4696
  if (typeof content !== 'string') return false;
4100
4697
 
4101
4698
  // Check for null bytes (common in binary files)
@@ -4410,9 +5007,9 @@ class MergeDriverRegistry {
4410
5007
  /**
4411
5008
  * Prepare content for driver based on binary/text
4412
5009
  *
4413
- * @param {string|Buffer|null} content - Raw content
4414
- * @param {boolean} isBinary - Whether to return as Buffer
4415
- * @returns {string|Buffer|null} Prepared content
5010
+ * @param {string|Uint8Array|null} content - Raw content
5011
+ * @param {boolean} isBinary - Whether to return as bytes
5012
+ * @returns {string|Uint8Array|null} Prepared content
4416
5013
  */
4417
5014
  prepareContent(content, isBinary) {
4418
5015
  if (content === null || content === undefined) {
@@ -4420,14 +5017,14 @@ class MergeDriverRegistry {
4420
5017
  }
4421
5018
 
4422
5019
  if (isBinary) {
4423
- // Convert to Buffer if needed
4424
- if (Buffer.isBuffer(content)) return content;
4425
- if (typeof content === 'string') return Buffer.from(content, 'utf-8');
4426
- return Buffer.from(String(content));
5020
+ // Convert to bytes if needed
5021
+ if (isBytes(content)) return content;
5022
+ if (typeof content === 'string') return utf8Encode(content);
5023
+ return utf8Encode(String(content));
4427
5024
  } else {
4428
5025
  // Convert to string if needed
4429
5026
  if (typeof content === 'string') return content;
4430
- if (Buffer.isBuffer(content)) return content.toString('utf-8');
5027
+ if (isBytes(content)) return utf8Decode(content);
4431
5028
  return String(content);
4432
5029
  }
4433
5030
  }
@@ -4588,25 +5185,25 @@ class WorkspaceManager {
4588
5185
  };
4589
5186
 
4590
5187
  // Create workspace directory
4591
- await this.fs.promises.mkdir(args.path, { recursive: true });
5188
+ await mkdirp(this.fs, args.path);
4592
5189
 
4593
5190
  // Create workspace-specific working_copy directory in .jj
4594
- const workspaceCopyDir = path.join(this.repoDir, '.jj', 'working_copy', workspaceId);
4595
- await this.fs.promises.mkdir(workspaceCopyDir, { recursive: true });
5191
+ const workspaceCopyDir = join(this.repoDir, '.jj', 'working_copy', workspaceId);
5192
+ await mkdirp(this.fs, workspaceCopyDir);
4596
5193
 
4597
5194
  // Create .git file pointing to main repo's .git directory
4598
5195
  // This allows Git tools to work in the workspace
4599
5196
  // IMPORTANT: Must use absolute paths for Git compatibility
4600
- const gitFilePath = path.join(args.path, '.git');
4601
- const mainGitDir = path.resolve(this.repoDir, '.git');
5197
+ const gitFilePath = join(args.path, '.git');
5198
+ const mainGitDir = resolve(this.repoDir, '.git');
4602
5199
  const gitFileContent = `gitdir: ${mainGitDir}\n`;
4603
5200
  await this.fs.promises.writeFile(gitFilePath, gitFileContent, 'utf8');
4604
5201
 
4605
5202
  // Create .jj file pointing to main repo's .jj directory
4606
5203
  // This allows JJ tools to work in the workspace
4607
5204
  // IMPORTANT: Must use absolute paths for JJ CLI compatibility
4608
- const jjFilePath = path.join(args.path, '.jj');
4609
- const mainJJDir = path.resolve(this.repoDir, '.jj');
5205
+ const jjFilePath = join(args.path, '.jj');
5206
+ const mainJJDir = resolve(this.repoDir, '.jj');
4610
5207
  const jjFileContent = `jjdir: ${mainJJDir}\n`;
4611
5208
  await this.fs.promises.writeFile(jjFilePath, jjFileContent, 'utf8');
4612
5209
 
@@ -5241,6 +5838,575 @@ class UserConfig {
5241
5838
  }
5242
5839
  }
5243
5840
 
5841
+ /**
5842
+ * Precompiled protobuf reflection JSON for local_working_copy.proto
5843
+ *
5844
+ * Generated from src/protos/local_working_copy.proto by scripts/build-protos.mjs. This
5845
+ * is bundled directly (instead of loaded from a filesystem path at runtime)
5846
+ * so that protobuf schema loading works identically in Node and in browser
5847
+ * bundles — protobuf.load() requires filesystem/network access that does
5848
+ * not exist in a browser bundle (see issue #28). Regenerate with:
5849
+ * node scripts/build-protos.mjs
5850
+ */
5851
+
5852
+ var localWorkingCopyJson = {
5853
+ nested: {
5854
+ local_working_copy: {
5855
+ nested: {
5856
+ FileType: {
5857
+ valuesOptions: {
5858
+ Conflict: {
5859
+ deprecated: true,
5860
+ },
5861
+ },
5862
+ values: {
5863
+ Normal: 0,
5864
+ Symlink: 1,
5865
+ Executable: 2,
5866
+ Conflict: 3,
5867
+ GitSubmodule: 4,
5868
+ },
5869
+ },
5870
+ MaterializedConflictData: {
5871
+ fields: {
5872
+ conflictMarkerLen: {
5873
+ type: 'uint32',
5874
+ id: 1,
5875
+ },
5876
+ },
5877
+ },
5878
+ FileState: {
5879
+ fields: {
5880
+ mtimeMillisSinceEpoch: {
5881
+ type: 'int64',
5882
+ id: 1,
5883
+ },
5884
+ size: {
5885
+ type: 'uint64',
5886
+ id: 2,
5887
+ },
5888
+ fileType: {
5889
+ type: 'FileType',
5890
+ id: 3,
5891
+ },
5892
+ materializedConflictData: {
5893
+ type: 'MaterializedConflictData',
5894
+ id: 5,
5895
+ },
5896
+ },
5897
+ reserved: [[4, 4]],
5898
+ },
5899
+ FileStateEntry: {
5900
+ fields: {
5901
+ path: {
5902
+ type: 'string',
5903
+ id: 1,
5904
+ },
5905
+ state: {
5906
+ type: 'FileState',
5907
+ id: 2,
5908
+ },
5909
+ },
5910
+ },
5911
+ SparsePatterns: {
5912
+ fields: {
5913
+ prefixes: {
5914
+ rule: 'repeated',
5915
+ type: 'string',
5916
+ id: 1,
5917
+ },
5918
+ },
5919
+ },
5920
+ TreeState: {
5921
+ fields: {
5922
+ legacyTreeId: {
5923
+ type: 'bytes',
5924
+ id: 1,
5925
+ options: {
5926
+ deprecated: true,
5927
+ },
5928
+ },
5929
+ treeIds: {
5930
+ rule: 'repeated',
5931
+ type: 'bytes',
5932
+ id: 5,
5933
+ },
5934
+ fileStates: {
5935
+ rule: 'repeated',
5936
+ type: 'FileStateEntry',
5937
+ id: 2,
5938
+ },
5939
+ isFileStatesSorted: {
5940
+ type: 'bool',
5941
+ id: 6,
5942
+ },
5943
+ sparsePatterns: {
5944
+ type: 'SparsePatterns',
5945
+ id: 3,
5946
+ },
5947
+ watchmanClock: {
5948
+ type: 'WatchmanClock',
5949
+ id: 4,
5950
+ },
5951
+ },
5952
+ },
5953
+ WatchmanClock: {
5954
+ oneofs: {
5955
+ watchmanClock: {
5956
+ oneof: ['stringClock', 'unixTimestamp'],
5957
+ },
5958
+ },
5959
+ fields: {
5960
+ stringClock: {
5961
+ type: 'string',
5962
+ id: 1,
5963
+ },
5964
+ unixTimestamp: {
5965
+ type: 'int64',
5966
+ id: 2,
5967
+ },
5968
+ },
5969
+ },
5970
+ Checkout: {
5971
+ fields: {
5972
+ operationId: {
5973
+ type: 'bytes',
5974
+ id: 2,
5975
+ },
5976
+ workspaceName: {
5977
+ type: 'string',
5978
+ id: 3,
5979
+ },
5980
+ },
5981
+ reserved: [[1, 1]],
5982
+ },
5983
+ },
5984
+ },
5985
+ },
5986
+ };
5987
+
5988
+ /**
5989
+ * Precompiled protobuf reflection JSON for simple_op_store.proto
5990
+ *
5991
+ * Generated from src/protos/simple_op_store.proto by scripts/build-protos.mjs. This
5992
+ * is bundled directly (instead of loaded from a filesystem path at runtime)
5993
+ * so that protobuf schema loading works identically in Node and in browser
5994
+ * bundles — protobuf.load() requires filesystem/network access that does
5995
+ * not exist in a browser bundle (see issue #28). Regenerate with:
5996
+ * node scripts/build-protos.mjs
5997
+ */
5998
+
5999
+ var simpleOpStoreJson = {
6000
+ nested: {
6001
+ simple_op_store: {
6002
+ nested: {
6003
+ RefConflictLegacy: {
6004
+ fields: {
6005
+ removes: {
6006
+ rule: 'repeated',
6007
+ type: 'bytes',
6008
+ id: 1,
6009
+ options: {
6010
+ deprecated: true,
6011
+ },
6012
+ },
6013
+ adds: {
6014
+ rule: 'repeated',
6015
+ type: 'bytes',
6016
+ id: 2,
6017
+ options: {
6018
+ deprecated: true,
6019
+ },
6020
+ },
6021
+ },
6022
+ },
6023
+ RefConflict: {
6024
+ fields: {
6025
+ removes: {
6026
+ rule: 'repeated',
6027
+ type: 'Term',
6028
+ id: 1,
6029
+ },
6030
+ adds: {
6031
+ rule: 'repeated',
6032
+ type: 'Term',
6033
+ id: 2,
6034
+ },
6035
+ },
6036
+ nested: {
6037
+ Term: {
6038
+ oneofs: {
6039
+ _value: {
6040
+ oneof: ['value'],
6041
+ },
6042
+ },
6043
+ fields: {
6044
+ value: {
6045
+ type: 'bytes',
6046
+ id: 1,
6047
+ options: {
6048
+ proto3_optional: true,
6049
+ },
6050
+ },
6051
+ },
6052
+ },
6053
+ },
6054
+ },
6055
+ RefTarget: {
6056
+ oneofs: {
6057
+ value: {
6058
+ oneof: ['commitId', 'conflictLegacy', 'conflict'],
6059
+ },
6060
+ },
6061
+ fields: {
6062
+ commitId: {
6063
+ type: 'bytes',
6064
+ id: 1,
6065
+ options: {
6066
+ deprecated: true,
6067
+ },
6068
+ },
6069
+ conflictLegacy: {
6070
+ type: 'RefConflictLegacy',
6071
+ id: 2,
6072
+ options: {
6073
+ deprecated: true,
6074
+ },
6075
+ },
6076
+ conflict: {
6077
+ type: 'RefConflict',
6078
+ id: 3,
6079
+ },
6080
+ },
6081
+ },
6082
+ RefTargetTerm: {
6083
+ oneofs: {
6084
+ _value: {
6085
+ oneof: ['value'],
6086
+ },
6087
+ },
6088
+ fields: {
6089
+ value: {
6090
+ type: 'bytes',
6091
+ id: 1,
6092
+ options: {
6093
+ proto3_optional: true,
6094
+ },
6095
+ },
6096
+ },
6097
+ },
6098
+ RemoteRefState: {
6099
+ values: {
6100
+ New: 0,
6101
+ Tracked: 1,
6102
+ },
6103
+ },
6104
+ RemoteBookmark: {
6105
+ oneofs: {
6106
+ _state: {
6107
+ oneof: ['state'],
6108
+ },
6109
+ },
6110
+ fields: {
6111
+ remoteName: {
6112
+ type: 'string',
6113
+ id: 1,
6114
+ },
6115
+ target: {
6116
+ type: 'RefTarget',
6117
+ id: 2,
6118
+ },
6119
+ state: {
6120
+ type: 'RemoteRefState',
6121
+ id: 3,
6122
+ options: {
6123
+ proto3_optional: true,
6124
+ },
6125
+ },
6126
+ },
6127
+ },
6128
+ Bookmark: {
6129
+ fields: {
6130
+ name: {
6131
+ type: 'string',
6132
+ id: 1,
6133
+ },
6134
+ localTarget: {
6135
+ type: 'RefTarget',
6136
+ id: 2,
6137
+ },
6138
+ remoteBookmarks: {
6139
+ rule: 'repeated',
6140
+ type: 'RemoteBookmark',
6141
+ id: 3,
6142
+ options: {
6143
+ deprecated: true,
6144
+ },
6145
+ },
6146
+ },
6147
+ },
6148
+ GitRef: {
6149
+ fields: {
6150
+ name: {
6151
+ type: 'string',
6152
+ id: 1,
6153
+ },
6154
+ commitId: {
6155
+ type: 'bytes',
6156
+ id: 2,
6157
+ options: {
6158
+ deprecated: true,
6159
+ },
6160
+ },
6161
+ target: {
6162
+ type: 'RefTarget',
6163
+ id: 3,
6164
+ },
6165
+ },
6166
+ },
6167
+ RemoteRef: {
6168
+ fields: {
6169
+ name: {
6170
+ type: 'string',
6171
+ id: 1,
6172
+ },
6173
+ targetTerms: {
6174
+ rule: 'repeated',
6175
+ type: 'RefTargetTerm',
6176
+ id: 2,
6177
+ },
6178
+ state: {
6179
+ type: 'RemoteRefState',
6180
+ id: 3,
6181
+ },
6182
+ },
6183
+ },
6184
+ Tag: {
6185
+ fields: {
6186
+ name: {
6187
+ type: 'string',
6188
+ id: 1,
6189
+ },
6190
+ target: {
6191
+ type: 'RefTarget',
6192
+ id: 2,
6193
+ },
6194
+ },
6195
+ },
6196
+ View: {
6197
+ fields: {
6198
+ headIds: {
6199
+ rule: 'repeated',
6200
+ type: 'bytes',
6201
+ id: 1,
6202
+ },
6203
+ wcCommitId: {
6204
+ type: 'bytes',
6205
+ id: 2,
6206
+ options: {
6207
+ deprecated: true,
6208
+ },
6209
+ },
6210
+ wcCommitIds: {
6211
+ keyType: 'string',
6212
+ type: 'bytes',
6213
+ id: 8,
6214
+ },
6215
+ bookmarks: {
6216
+ rule: 'repeated',
6217
+ type: 'Bookmark',
6218
+ id: 5,
6219
+ },
6220
+ localTags: {
6221
+ rule: 'repeated',
6222
+ type: 'Tag',
6223
+ id: 6,
6224
+ },
6225
+ remoteViews: {
6226
+ rule: 'repeated',
6227
+ type: 'RemoteView',
6228
+ id: 11,
6229
+ },
6230
+ gitRefs: {
6231
+ rule: 'repeated',
6232
+ type: 'GitRef',
6233
+ id: 3,
6234
+ },
6235
+ gitHeadLegacy: {
6236
+ type: 'bytes',
6237
+ id: 7,
6238
+ options: {
6239
+ deprecated: true,
6240
+ },
6241
+ },
6242
+ gitHead: {
6243
+ type: 'RefTarget',
6244
+ id: 9,
6245
+ },
6246
+ hasGitRefsMigratedToRemoteTags: {
6247
+ type: 'bool',
6248
+ id: 12,
6249
+ },
6250
+ },
6251
+ reserved: [
6252
+ [4, 4],
6253
+ [10, 10],
6254
+ ],
6255
+ },
6256
+ RemoteView: {
6257
+ fields: {
6258
+ name: {
6259
+ type: 'string',
6260
+ id: 1,
6261
+ },
6262
+ bookmarks: {
6263
+ rule: 'repeated',
6264
+ type: 'RemoteRef',
6265
+ id: 2,
6266
+ },
6267
+ tags: {
6268
+ rule: 'repeated',
6269
+ type: 'RemoteRef',
6270
+ id: 3,
6271
+ },
6272
+ },
6273
+ },
6274
+ Operation: {
6275
+ fields: {
6276
+ viewId: {
6277
+ type: 'bytes',
6278
+ id: 1,
6279
+ },
6280
+ parents: {
6281
+ rule: 'repeated',
6282
+ type: 'bytes',
6283
+ id: 2,
6284
+ },
6285
+ metadata: {
6286
+ type: 'OperationMetadata',
6287
+ id: 3,
6288
+ },
6289
+ commitPredecessors: {
6290
+ rule: 'repeated',
6291
+ type: 'CommitPredecessors',
6292
+ id: 4,
6293
+ },
6294
+ storesCommitPredecessors: {
6295
+ type: 'bool',
6296
+ id: 5,
6297
+ },
6298
+ },
6299
+ },
6300
+ Timestamp: {
6301
+ fields: {
6302
+ millisSinceEpoch: {
6303
+ type: 'int64',
6304
+ id: 1,
6305
+ },
6306
+ tzOffset: {
6307
+ type: 'int32',
6308
+ id: 2,
6309
+ },
6310
+ },
6311
+ },
6312
+ OperationMetadata: {
6313
+ fields: {
6314
+ startTime: {
6315
+ type: 'Timestamp',
6316
+ id: 1,
6317
+ },
6318
+ endTime: {
6319
+ type: 'Timestamp',
6320
+ id: 2,
6321
+ },
6322
+ description: {
6323
+ type: 'string',
6324
+ id: 3,
6325
+ },
6326
+ hostname: {
6327
+ type: 'string',
6328
+ id: 4,
6329
+ },
6330
+ username: {
6331
+ type: 'string',
6332
+ id: 5,
6333
+ },
6334
+ isSnapshot: {
6335
+ type: 'bool',
6336
+ id: 7,
6337
+ },
6338
+ tags: {
6339
+ keyType: 'string',
6340
+ type: 'string',
6341
+ id: 6,
6342
+ },
6343
+ },
6344
+ },
6345
+ CommitPredecessors: {
6346
+ fields: {
6347
+ commitId: {
6348
+ type: 'bytes',
6349
+ id: 1,
6350
+ },
6351
+ predecessorIds: {
6352
+ rule: 'repeated',
6353
+ type: 'bytes',
6354
+ id: 2,
6355
+ },
6356
+ },
6357
+ },
6358
+ },
6359
+ },
6360
+ },
6361
+ };
6362
+
6363
+ /**
6364
+ * Protobuf schema loader
6365
+ *
6366
+ * Builds protobuf.js `Root` instances from precompiled reflection JSON
6367
+ * (see local_working_copy.json.js / simple_op_store.json.js, generated from
6368
+ * the .proto files in this directory) instead of calling `protobuf.load()`
6369
+ * against a filesystem path.
6370
+ *
6371
+ * Why: `protobuf.load()` resolves its argument as a file path (Node `fs`) or
6372
+ * URL (XHR/fetch), which requires either a real filesystem or a network
6373
+ * request. In a bundled browser build there is no filesystem path to read
6374
+ * (see issue #28), and shipping the raw .proto text and fetching it at
6375
+ * runtime would require bundler-specific asset handling. Reflection JSON is
6376
+ * plain data, so it can be imported like any other JS module and works
6377
+ * identically under Node, Jest, and any browser bundler (Vite, esbuild,
6378
+ * webpack, Rollup, ...).
6379
+ *
6380
+ * Regenerate the JSON files after editing a .proto with:
6381
+ * npm run build:protos
6382
+ */
6383
+
6384
+
6385
+ /** @type {protobuf.Root|undefined} */
6386
+ let localWorkingCopyRoot;
6387
+ /** @type {protobuf.Root|undefined} */
6388
+ let simpleOpStoreRoot;
6389
+
6390
+ /**
6391
+ * @returns {protobuf.Root} Root for local_working_copy.proto (Checkout, TreeState, ...)
6392
+ */
6393
+ function getLocalWorkingCopyRoot() {
6394
+ if (!localWorkingCopyRoot) {
6395
+ localWorkingCopyRoot = protobuf.Root.fromJSON(localWorkingCopyJson);
6396
+ }
6397
+ return localWorkingCopyRoot;
6398
+ }
6399
+
6400
+ /**
6401
+ * @returns {protobuf.Root} Root for simple_op_store.proto (Operation, View, ...)
6402
+ */
6403
+ function getSimpleOpStoreRoot() {
6404
+ if (!simpleOpStoreRoot) {
6405
+ simpleOpStoreRoot = protobuf.Root.fromJSON(simpleOpStoreJson);
6406
+ }
6407
+ return simpleOpStoreRoot;
6408
+ }
6409
+
5244
6410
  /**
5245
6411
  * JJ Checkout File Manager
5246
6412
  *
@@ -5249,15 +6415,6 @@ class UserConfig {
5249
6415
  */
5250
6416
 
5251
6417
 
5252
- const __filename$4 = fileURLToPath(import.meta.url);
5253
- const __dirname$4 = path.dirname(__filename$4);
5254
-
5255
- // Determine proto path based on whether we're running from dist or src
5256
- const inDist$3 = __dirname$4.includes('/dist') || __dirname$4.includes('\\dist');
5257
- const protoDir$3 = inDist$3
5258
- ? path.join(__dirname$4, '..', 'src', 'protos')
5259
- : path.join(__dirname$4, '..', 'protos');
5260
-
5261
6418
  class JJCheckout {
5262
6419
  /**
5263
6420
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5266,7 +6423,6 @@ class JJCheckout {
5266
6423
  constructor(fs, dir) {
5267
6424
  this.fs = fs;
5268
6425
  this.dir = dir;
5269
- this.protoPath = path.join(protoDir$3, 'local_working_copy.proto');
5270
6426
  }
5271
6427
 
5272
6428
  /**
@@ -5277,11 +6433,11 @@ class JJCheckout {
5277
6433
  */
5278
6434
  async writeCheckout(operationId, workspaceName = 'default') {
5279
6435
  // Load protobuf schema
5280
- const root = await protobuf.load(this.protoPath);
6436
+ const root = getLocalWorkingCopyRoot();
5281
6437
  const Checkout = root.lookupType('local_working_copy.Checkout');
5282
6438
 
5283
6439
  // Convert hex operation ID to bytes
5284
- const opIdBuffer = Buffer.from(operationId, 'hex');
6440
+ const opIdBuffer = hexToBytes(operationId);
5285
6441
 
5286
6442
  // Create message (use camelCase for protobufjs)
5287
6443
  const message = Checkout.create({
@@ -5313,7 +6469,7 @@ class JJCheckout {
5313
6469
  const buffer = await this.fs.promises.readFile(checkoutPath);
5314
6470
 
5315
6471
  // Load protobuf schema
5316
- const root = await protobuf.load(this.protoPath);
6472
+ const root = getLocalWorkingCopyRoot();
5317
6473
  const Checkout = root.lookupType('local_working_copy.Checkout');
5318
6474
 
5319
6475
  // Decode
@@ -5327,6 +6483,52 @@ class JJCheckout {
5327
6483
  }
5328
6484
  }
5329
6485
 
6486
+ /**
6487
+ * Shared atomic-write helper for filesystem writes.
6488
+ *
6489
+ * Writes content to a uniquely-named temp file, then renames it over the
6490
+ * canonical path. A crash/error between those two steps leaves the
6491
+ * canonical file untouched (never a truncated/partial write) — the temp
6492
+ * file is best-effort cleaned up on failure.
6493
+ *
6494
+ * This mirrors the pattern `Storage.write()` (src/core/storage-manager.js)
6495
+ * already uses for JSON stores, factored out so the protobuf-backed stores
6496
+ * (JJOperationStore, JJViewStore, JJTreeState) can use it too instead of
6497
+ * calling `writeFile()` directly on the canonical path — see issue #16.
6498
+ */
6499
+
6500
+
6501
+ /**
6502
+ * @param {any} fs - Filesystem implementation (Node fs, LightningFS, etc.)
6503
+ * @param {string} targetPath - Canonical path to write to
6504
+ * @param {string|Uint8Array} content - Content to write
6505
+ */
6506
+ async function atomicWriteFile(fs, targetPath, content) {
6507
+ const tmpSuffix =
6508
+ typeof crypto !== 'undefined' && crypto.randomUUID
6509
+ ? crypto.randomUUID()
6510
+ : `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
6511
+ const tmpPath = `${targetPath}.tmp.${Date.now()}.${tmpSuffix}`;
6512
+
6513
+ try {
6514
+ const dirPath = targetPath.substring(0, targetPath.lastIndexOf('/'));
6515
+ if (dirPath) {
6516
+ await mkdirp(fs, dirPath);
6517
+ }
6518
+
6519
+ await fs.promises.writeFile(tmpPath, content);
6520
+ await fs.promises.rename(tmpPath, targetPath);
6521
+ } catch (error) {
6522
+ try {
6523
+ await fs.promises.unlink(tmpPath);
6524
+ } catch {
6525
+ // Best-effort cleanup; ignore if the temp file was never created (or
6526
+ // is already gone).
6527
+ }
6528
+ throw error;
6529
+ }
6530
+ }
6531
+
5330
6532
  /**
5331
6533
  * JJ TreeState File Manager
5332
6534
  *
@@ -5335,15 +6537,6 @@ class JJCheckout {
5335
6537
  */
5336
6538
 
5337
6539
 
5338
- const __filename$3 = fileURLToPath(import.meta.url);
5339
- const __dirname$3 = path.dirname(__filename$3);
5340
-
5341
- // Determine proto path based on whether we're running from dist or src
5342
- const inDist$2 = __dirname$3.includes('/dist') || __dirname$3.includes('\\dist');
5343
- const protoDir$2 = inDist$2
5344
- ? path.join(__dirname$3, '..', 'src', 'protos')
5345
- : path.join(__dirname$3, '..', 'protos');
5346
-
5347
6540
  class JJTreeState {
5348
6541
  /**
5349
6542
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5352,7 +6545,6 @@ class JJTreeState {
5352
6545
  constructor(fs, dir) {
5353
6546
  this.fs = fs;
5354
6547
  this.dir = dir;
5355
- this.protoPath = path.join(protoDir$2, 'local_working_copy.proto');
5356
6548
  }
5357
6549
 
5358
6550
  /**
@@ -5363,11 +6555,11 @@ class JJTreeState {
5363
6555
  */
5364
6556
  async writeTreeState(treeId, fileStates = []) {
5365
6557
  // Load protobuf schema
5366
- const root = await protobuf.load(this.protoPath);
6558
+ const root = getLocalWorkingCopyRoot();
5367
6559
  const TreeState = root.lookupType('local_working_copy.TreeState');
5368
6560
 
5369
6561
  // Convert hex tree ID to bytes
5370
- const treeIdBuffer = Buffer.from(treeId, 'hex');
6562
+ const treeIdBuffer = hexToBytes(treeId);
5371
6563
 
5372
6564
  // Convert file states to protobuf format
5373
6565
  const fileStateEntries = fileStates.map((fs) => ({
@@ -5398,9 +6590,9 @@ class JJTreeState {
5398
6590
  // Encode to binary
5399
6591
  const buffer = TreeState.encode(message).finish();
5400
6592
 
5401
- // Write to .jj/working_copy/tree_state
6593
+ // Write to .jj/working_copy/tree_state (atomically — see issue #16).
5402
6594
  const treeStatePath = `${this.dir}/.jj/working_copy/tree_state`;
5403
- await this.fs.promises.writeFile(treeStatePath, buffer);
6595
+ await atomicWriteFile(this.fs, treeStatePath, buffer);
5404
6596
  }
5405
6597
 
5406
6598
  /**
@@ -5413,7 +6605,7 @@ class JJTreeState {
5413
6605
  const buffer = await this.fs.promises.readFile(treeStatePath);
5414
6606
 
5415
6607
  // Load protobuf schema
5416
- const root = await protobuf.load(this.protoPath);
6608
+ const root = getLocalWorkingCopyRoot();
5417
6609
  const TreeState = root.lookupType('local_working_copy.TreeState');
5418
6610
 
5419
6611
  // Decode
@@ -5437,15 +6629,6 @@ class JJTreeState {
5437
6629
  */
5438
6630
 
5439
6631
 
5440
- const __filename$2 = fileURLToPath(import.meta.url);
5441
- const __dirname$2 = path.dirname(__filename$2);
5442
-
5443
- // Determine proto path based on whether we're running from dist or src
5444
- const inDist$1 = __dirname$2.includes('/dist') || __dirname$2.includes('\\dist');
5445
- const protoDir$1 = inDist$1
5446
- ? path.join(__dirname$2, '..', 'src', 'protos')
5447
- : path.join(__dirname$2, '..', 'protos');
5448
-
5449
6632
  class JJOperationStore {
5450
6633
  /**
5451
6634
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5454,7 +6637,6 @@ class JJOperationStore {
5454
6637
  constructor(fs, dir) {
5455
6638
  this.fs = fs;
5456
6639
  this.dir = dir;
5457
- this.protoPath = path.join(protoDir$1, 'simple_op_store.proto');
5458
6640
  }
5459
6641
 
5460
6642
  /**
@@ -5467,12 +6649,12 @@ class JJOperationStore {
5467
6649
  */
5468
6650
  async writeOperation(operationId, viewId, parentIds, metadata) {
5469
6651
  // Load protobuf schema
5470
- const root = await protobuf.load(this.protoPath);
6652
+ const root = getSimpleOpStoreRoot();
5471
6653
  const Operation = root.lookupType('simple_op_store.Operation');
5472
6654
 
5473
6655
  // Convert hex IDs to bytes
5474
- const viewIdBuffer = Buffer.from(viewId, 'hex');
5475
- const parentBuffers = parentIds.map((id) => Buffer.from(id, 'hex'));
6656
+ const viewIdBuffer = hexToBytes(viewId);
6657
+ const parentBuffers = parentIds.map((id) => hexToBytes(id));
5476
6658
 
5477
6659
  // Create metadata message (use camelCase for protobufjs)
5478
6660
  const metadataMsg = {
@@ -5509,9 +6691,11 @@ class JJOperationStore {
5509
6691
  // Encode to binary
5510
6692
  const buffer = Operation.encode(message).finish();
5511
6693
 
5512
- // Write to .jj/repo/op_store/operations/OPERATIONID
6694
+ // Write to .jj/repo/op_store/operations/OPERATIONID (atomically — a
6695
+ // crash mid-write must never leave a truncated/undecodable file, see
6696
+ // issue #16).
5513
6697
  const opPath = `${this.dir}/.jj/repo/op_store/operations/${operationId}`;
5514
- await this.fs.promises.writeFile(opPath, buffer);
6698
+ await atomicWriteFile(this.fs, opPath, buffer);
5515
6699
  }
5516
6700
 
5517
6701
  /**
@@ -5526,7 +6710,7 @@ class JJOperationStore {
5526
6710
  const buffer = await this.fs.promises.readFile(opPath);
5527
6711
 
5528
6712
  // Load protobuf schema
5529
- const root = await protobuf.load(this.protoPath);
6713
+ const root = getSimpleOpStoreRoot();
5530
6714
  const Operation = root.lookupType('simple_op_store.Operation');
5531
6715
 
5532
6716
  // Decode
@@ -5565,15 +6749,6 @@ class JJOperationStore {
5565
6749
  */
5566
6750
 
5567
6751
 
5568
- const __filename$1 = fileURLToPath(import.meta.url);
5569
- const __dirname$1 = path.dirname(__filename$1);
5570
-
5571
- // Determine proto path based on whether we're running from dist or src
5572
- const inDist = __dirname$1.includes('/dist') || __dirname$1.includes('\\dist');
5573
- const protoDir = inDist
5574
- ? path.join(__dirname$1, '..', 'src', 'protos')
5575
- : path.join(__dirname$1, '..', 'protos');
5576
-
5577
6752
  class JJViewStore {
5578
6753
  /**
5579
6754
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5582,7 +6757,6 @@ class JJViewStore {
5582
6757
  constructor(fs, dir) {
5583
6758
  this.fs = fs;
5584
6759
  this.dir = dir;
5585
- this.protoPath = path.join(protoDir, 'simple_op_store.proto');
5586
6760
  }
5587
6761
 
5588
6762
  /**
@@ -5594,17 +6768,17 @@ class JJViewStore {
5594
6768
  */
5595
6769
  async writeView(viewId, headIds, wcCommitIds = {}) {
5596
6770
  // Load protobuf schema
5597
- const root = await protobuf.load(this.protoPath);
6771
+ const root = getSimpleOpStoreRoot();
5598
6772
  const View = root.lookupType('simple_op_store.View');
5599
6773
 
5600
6774
  // Convert hex IDs to bytes
5601
- const headBuffers = headIds.map((id) => Buffer.from(id, 'hex'));
6775
+ const headBuffers = headIds.map((id) => hexToBytes(id));
5602
6776
 
5603
6777
  // Convert wcCommitIds object to map
5604
6778
  /** @type {Record<string, any>} */
5605
6779
  const wcCommitIdsMap = {};
5606
6780
  for (const [workspace, commitId] of Object.entries(wcCommitIds)) {
5607
- wcCommitIdsMap[workspace] = Buffer.from(commitId, 'hex');
6781
+ wcCommitIdsMap[workspace] = hexToBytes(commitId);
5608
6782
  }
5609
6783
 
5610
6784
  // Create view message (use camelCase for protobufjs)
@@ -5628,9 +6802,9 @@ class JJViewStore {
5628
6802
  // Encode to binary
5629
6803
  const buffer = View.encode(message).finish();
5630
6804
 
5631
- // Write to .jj/repo/op_store/views/VIEWID
6805
+ // Write to .jj/repo/op_store/views/VIEWID (atomically — see issue #16).
5632
6806
  const viewPath = `${this.dir}/.jj/repo/op_store/views/${viewId}`;
5633
- await this.fs.promises.writeFile(viewPath, buffer);
6807
+ await atomicWriteFile(this.fs, viewPath, buffer);
5634
6808
  }
5635
6809
 
5636
6810
  /**
@@ -5645,7 +6819,7 @@ class JJViewStore {
5645
6819
  const buffer = await this.fs.promises.readFile(viewPath);
5646
6820
 
5647
6821
  // Load protobuf schema
5648
- const root = await protobuf.load(this.protoPath);
6822
+ const root = getSimpleOpStoreRoot();
5649
6823
  const View = root.lookupType('simple_op_store.View');
5650
6824
 
5651
6825
  // Decode
@@ -6207,7 +7381,11 @@ class IsomorphicGitBackend {
6207
7381
  * @param {boolean} [opts.force] - Allow non-fast-forward
6208
7382
  * @param {any} [opts.onProgress] - Progress callback
6209
7383
  * @param {any} [opts.onAuth] - Authentication callback
6210
- * @returns {Promise<{pushedRefs: Array<any>, rejectedRefs: Array<any>}>} Push result
7384
+ * @returns {Promise<{pushedRefs: Array<{name: string, oid: string}>, rejectedRefs: Array<{name: string, code: string, reason: string, message: string}>}>} Push result.
7385
+ * Each rejected ref carries a `code` (one of `AUTH_FAILED`,
7386
+ * `NETWORK_ERROR`, `NON_FAST_FORWARD`, `OTHER`) and a human-readable
7387
+ * `reason`, so callers can tell an auth failure apart from a
7388
+ * non-fast-forward rejection instead of guessing (see issue #14).
6211
7389
  */
6212
7390
  async push(opts) {
6213
7391
  if (!this.http) {
@@ -6243,8 +7421,13 @@ class IsomorphicGitBackend {
6243
7421
  pushedRefs.push({ name: ref, oid });
6244
7422
  }
6245
7423
  } catch (pushError) {
6246
- // Track rejected refs
6247
- rejectedRefs.push(ref);
7424
+ // Track rejected refs with the categorized reason so callers can
7425
+ // distinguish an auth failure from a non-fast-forward rejection
7426
+ // (or a network blip) instead of guessing — see issue #14. A
7427
+ // caller that blindly retries with force:true on every rejection
7428
+ // risks doing so on what was actually an auth/network problem.
7429
+ const { code, reason } = this._categorizePushError(pushError);
7430
+ rejectedRefs.push({ name: ref, code, reason, message: pushError.message });
6248
7431
  }
6249
7432
  }
6250
7433
 
@@ -6276,6 +7459,53 @@ class IsomorphicGitBackend {
6276
7459
  }
6277
7460
  }
6278
7461
 
7462
+ /**
7463
+ * Categorize a per-ref push failure into a stable code/reason pair so
7464
+ * callers can distinguish auth failures, network errors, and
7465
+ * non-fast-forward rejections from each other and from other/unknown
7466
+ * failures — see issue #14. isomorphic-git doesn't throw a single
7467
+ * "AuthError" class; auth failures generally surface as an HttpError with
7468
+ * a 401/403 status code (or a UserCanceledError if onAuth cancels).
7469
+ *
7470
+ * @private
7471
+ * @param {any} error - Error thrown by git.push() for a single ref
7472
+ * @returns {{code: string, reason: string}}
7473
+ */
7474
+ _categorizePushError(error) {
7475
+ const errCode = error && error.code;
7476
+
7477
+ if (errCode === 'PushRejectedError') {
7478
+ const reason = error.data && error.data.reason;
7479
+ return {
7480
+ code: 'NON_FAST_FORWARD',
7481
+ reason: reason === 'tag-exists' ? 'tag already exists on remote' : 'non-fast-forward',
7482
+ };
7483
+ }
7484
+
7485
+ if (errCode === 'HttpError') {
7486
+ const status = error.data && error.data.statusCode;
7487
+ if (status === 401 || status === 403) {
7488
+ return { code: 'AUTH_FAILED', reason: `HTTP ${status}` };
7489
+ }
7490
+ return { code: 'NETWORK_ERROR', reason: status ? `HTTP ${status}` : 'HTTP error' };
7491
+ }
7492
+
7493
+ if (
7494
+ errCode === 'AuthError' ||
7495
+ errCode === 'UserCanceledError' ||
7496
+ errCode === 'MissingUsernameError' ||
7497
+ errCode === 'MissingPasswordTokenError'
7498
+ ) {
7499
+ return { code: 'AUTH_FAILED', reason: errCode };
7500
+ }
7501
+
7502
+ if (errCode === 'NetworkError' || errCode === 'UnknownTransportError') {
7503
+ return { code: 'NETWORK_ERROR', reason: errCode };
7504
+ }
7505
+
7506
+ return { code: 'OTHER', reason: (error && error.message) || String(error) };
7507
+ }
7508
+
6279
7509
  /**
6280
7510
  * Create minimal .jj/repo structure for jj CLI compatibility
6281
7511
  * @private
@@ -6285,14 +7515,14 @@ class IsomorphicGitBackend {
6285
7515
  const jjRepo = `${jjDir}/repo`;
6286
7516
 
6287
7517
  // Create directory structure
6288
- await this.fs.promises.mkdir(`${jjRepo}/store`, { recursive: true });
6289
- await this.fs.promises.mkdir(`${jjRepo}/store/extra/heads`, { recursive: true });
6290
- await this.fs.promises.mkdir(`${jjRepo}/op_store/operations`, { recursive: true });
6291
- await this.fs.promises.mkdir(`${jjRepo}/op_store/views`, { recursive: true });
6292
- await this.fs.promises.mkdir(`${jjRepo}/op_heads/heads`, { recursive: true });
6293
- await this.fs.promises.mkdir(`${jjRepo}/index`, { recursive: true });
6294
- await this.fs.promises.mkdir(`${jjRepo}/submodule_store`, { recursive: true });
6295
- await this.fs.promises.mkdir(`${jjDir}/working_copy`, { recursive: true });
7518
+ await mkdirp(this.fs, `${jjRepo}/store`);
7519
+ await mkdirp(this.fs, `${jjRepo}/store/extra/heads`);
7520
+ await mkdirp(this.fs, `${jjRepo}/op_store/operations`);
7521
+ await mkdirp(this.fs, `${jjRepo}/op_store/views`);
7522
+ await mkdirp(this.fs, `${jjRepo}/op_heads/heads`);
7523
+ await mkdirp(this.fs, `${jjRepo}/index`);
7524
+ await mkdirp(this.fs, `${jjRepo}/submodule_store`);
7525
+ await mkdirp(this.fs, `${jjDir}/working_copy`);
6296
7526
 
6297
7527
  // Write type files
6298
7528
  await this.fs.promises.writeFile(`${jjRepo}/store/type`, 'git');
@@ -6319,8 +7549,8 @@ class IsomorphicGitBackend {
6319
7549
  */
6320
7550
  async _createInitialJJState() {
6321
7551
  // Generate unique IDs for initial state
6322
- const operationId = crypto$1.randomBytes(64).toString('hex'); // 512-bit = 128 hex chars
6323
- const viewId = crypto$1.randomBytes(64).toString('hex');
7552
+ const operationId = randomHex(64); // 512-bit = 128 hex chars
7553
+ const viewId = randomHex(64);
6324
7554
  const emptyTreeId = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // Git's empty tree hash
6325
7555
 
6326
7556
  // Get the initial commit from Git
@@ -6332,7 +7562,7 @@ class IsomorphicGitBackend {
6332
7562
  ref: 'HEAD',
6333
7563
  });
6334
7564
  if (headOid) {
6335
- initialCommitId = crypto$1.randomBytes(64).toString('hex');
7565
+ initialCommitId = randomHex(64);
6336
7566
  }
6337
7567
  } catch (error) {
6338
7568
  // No initial commit yet
@@ -6489,7 +7719,7 @@ async function createJJ(options) {
6489
7719
 
6490
7720
  for (const filePath of trackedFiles) {
6491
7721
  try {
6492
- const fullPath = path.join(dir, filePath);
7722
+ const fullPath = join(dir, filePath);
6493
7723
  const stats = await fs.promises.stat(fullPath);
6494
7724
 
6495
7725
  // Skip files that are too large
@@ -6502,7 +7732,7 @@ async function createJJ(options) {
6502
7732
  break;
6503
7733
  }
6504
7734
 
6505
- const content = await fs.promises.readFile(fullPath, 'utf-8');
7735
+ const content = await fs.promises.readFile(fullPath, 'utf8');
6506
7736
  fileSnapshot[filePath] = content;
6507
7737
  totalSnapshotSize += stats.size;
6508
7738
  } catch (error) {
@@ -6513,7 +7743,258 @@ async function createJJ(options) {
6513
7743
  // If we can't list files, return empty snapshot
6514
7744
  }
6515
7745
 
6516
- return fileSnapshot;
7746
+ return fileSnapshot;
7747
+ };
7748
+
7749
+ /**
7750
+ * Fully sync the working directory to a target file snapshot: writes
7751
+ * every file the target has (create/update), AND deletes every currently
7752
+ * tracked file the target does NOT have.
7753
+ *
7754
+ * Both edit() and new() used to only overlay the target's files on top of
7755
+ * whatever was already on disk, so a file that existed in the previously
7756
+ * checked-out change but not in the target leaked into the next
7757
+ * snapshot — see issue #30. undo()/redo() had the same gap restoring
7758
+ * file state — see issue #29. This is the one place that does it
7759
+ * correctly, used by all four.
7760
+ *
7761
+ * @param {Record<string, string>} targetSnapshot - path -> file content
7762
+ * @returns {Promise<void>}
7763
+ */
7764
+ const syncWorkingCopyFiles = async (targetSnapshot) => {
7765
+ const snapshot = targetSnapshot || {};
7766
+ const currentFiles = await workingCopy.listFiles();
7767
+
7768
+ // Remove files that aren't part of the target.
7769
+ for (const filePath of currentFiles) {
7770
+ if (!(filePath in snapshot)) {
7771
+ try {
7772
+ const fullPath = join(dir, filePath);
7773
+ await fs.promises.unlink(fullPath);
7774
+ } catch (error) {
7775
+ // Already gone / inaccessible — fine, we just want it absent.
7776
+ }
7777
+ await workingCopy.untrackFile(filePath);
7778
+ }
7779
+ }
7780
+
7781
+ // Write/update every file the target has.
7782
+ for (const [filePath, content] of Object.entries(snapshot)) {
7783
+ const fullPath = join(dir, filePath);
7784
+ const pathParts = filePath.split('/');
7785
+ if (pathParts.length > 1) {
7786
+ const fullDirPath = join(dir, pathParts.slice(0, -1).join('/'));
7787
+ await mkdirp(fs, fullDirPath);
7788
+ }
7789
+ await fs.promises.writeFile(fullPath, content, 'utf8');
7790
+
7791
+ const stats = await fs.promises.stat(fullPath);
7792
+ await workingCopy.trackFile(filePath, {
7793
+ mtime: stats.mtime,
7794
+ size: stats.size,
7795
+ mode: stats.mode,
7796
+ });
7797
+ }
7798
+ };
7799
+
7800
+ /**
7801
+ * Compute the ChangeGraph/file/conflicts state that existed right after
7802
+ * `landingOp` finished — i.e. what undo()/redo()/operations.restore()
7803
+ * need to re-establish when winding the repository forward or back to
7804
+ * that point (see issue #29).
7805
+ *
7806
+ * Every operation that mutates EXISTING change records in place records
7807
+ * (additively — most operations touch none of this and that's fine,
7808
+ * there's nothing to reverse for them):
7809
+ * - changeSnapshot: { [changeId]: fullRecordBeforeThisOpRan }
7810
+ * - view.fileSnapshot: on-disk file state right BEFORE this op ran
7811
+ * - conflictsSnapshot: ConflictModel state right BEFORE this op ran
7812
+ *
7813
+ * Deliberately NOT reverted: changes an operation newly *created* (via
7814
+ * graph.addChange() — new(), squash()'s synthetic empty working-copy
7815
+ * change, ...). Undoing their creation only un-refs them (restores the
7816
+ * working-copy pointer/heads that made them reachable) — it does not
7817
+ * delete the change record — matching real jj, where an "unreachable"
7818
+ * change stays resolvable by its change id (hidden, not gone) until
7819
+ * explicitly gc'd, and this package's own history-editing tests rely on
7820
+ * exactly that: rebasing onto a change created by a since-undone new()
7821
+ * must still work.
7822
+ *
7823
+ * "The state right after landingOp" is therefore exactly what the FIRST
7824
+ * operation after landingOp recorded as ITS "before" state, merged across
7825
+ * every operation between landingOp and the current head in case
7826
+ * different operations touched different changeIds (a single op's own
7827
+ * snapshot only covers what IT touched). Concretely, per field:
7828
+ * - changeSnapshot: first (earliest / closest-to-landingOp) entry per
7829
+ * changeId wins — later entries in the range reflect that changeId's
7830
+ * state at a LATER point than we're restoring to.
7831
+ * - fileSnapshot / conflictsSnapshot: the first op in the range that
7832
+ * recorded one, since whole-state snapshots aren't per-changeId and
7833
+ * an op that didn't record one simply didn't change that state.
7834
+ *
7835
+ * This same helper backs undo() (landingOp = the parent of whatever's
7836
+ * being undone), redo() (landingOp = the operation a prior undo()
7837
+ * reverted), and operations.restore() (landingOp = the target operation
7838
+ * itself) — one reversal primitive, not three.
7839
+ *
7840
+ * @param {Array<any>} ops - Full chronological operation list (oplog.list())
7841
+ * @param {any} landingOp - The operation whose resulting state to recompute
7842
+ * @returns {{
7843
+ * changeSnapshotToApply: Record<string, any>,
7844
+ * fileSnapshot: Record<string, string>|undefined,
7845
+ * conflictsSnapshot: any,
7846
+ * }}
7847
+ */
7848
+ const computeGraphReversal = (/** @type {any[]} */ ops, /** @type {any} */ landingOp) => {
7849
+ const landingIndex = ops.findIndex((op) => op.id === landingOp.id);
7850
+
7851
+ /** @type {Record<string, any>} */
7852
+ const changeSnapshotToApply = {};
7853
+ /** @type {Record<string, string>|undefined} */
7854
+ let fileSnapshot;
7855
+ /** @type {any} */
7856
+ let conflictsSnapshot;
7857
+
7858
+ for (let i = landingIndex + 1; i < ops.length; i++) {
7859
+ const op = ops[i];
7860
+
7861
+ if (op.changeSnapshot) {
7862
+ for (const [changeId, snapshot] of Object.entries(op.changeSnapshot)) {
7863
+ if (!(changeId in changeSnapshotToApply)) {
7864
+ changeSnapshotToApply[changeId] = snapshot;
7865
+ }
7866
+ }
7867
+ }
7868
+
7869
+ // `!= null` (not `!== undefined`) on purpose: some operations record
7870
+ // an explicit `null` for "I ran, and I deliberately took no snapshot"
7871
+ // (e.g. describe() of a non-working-copy revision) — that's exactly
7872
+ // as uninformative as never having set the field, so keep scanning
7873
+ // rather than "finding" a null snapshot.
7874
+ if (fileSnapshot === undefined && op.view && op.view.fileSnapshot != null) {
7875
+ fileSnapshot = op.view.fileSnapshot;
7876
+ }
7877
+
7878
+ if (conflictsSnapshot === undefined && op.conflictsSnapshot != null) {
7879
+ conflictsSnapshot = op.conflictsSnapshot;
7880
+ }
7881
+ }
7882
+
7883
+ return { changeSnapshotToApply, fileSnapshot, conflictsSnapshot };
7884
+ };
7885
+
7886
+ /**
7887
+ * Apply a reversal computed by computeGraphReversal() to the live graph,
7888
+ * working-copy files, and conflict state. Does NOT touch the working-copy
7889
+ * pointer or bookmarks — callers set those from the landing op's own
7890
+ * `view` directly (pointers, unlike file/graph/conflict content, are
7891
+ * already correct as recorded, see computeGraphReversal's doc comment).
7892
+ *
7893
+ * @param {ReturnType<typeof computeGraphReversal>} reversal
7894
+ * @returns {Promise<void>}
7895
+ */
7896
+ const applyGraphReversal = async (reversal) => {
7897
+ await graph.load();
7898
+ for (const [changeId, snapshot] of Object.entries(reversal.changeSnapshotToApply)) {
7899
+ if (await graph.getChange(changeId)) {
7900
+ await graph.updateChange(snapshot);
7901
+ }
7902
+ }
7903
+
7904
+ if (reversal.fileSnapshot !== undefined) {
7905
+ await syncWorkingCopyFiles(reversal.fileSnapshot);
7906
+ }
7907
+
7908
+ await conflicts.load();
7909
+ if (reversal.conflictsSnapshot !== undefined) {
7910
+ conflicts.conflicts = new Map(Object.entries(reversal.conflictsSnapshot.conflicts || {}));
7911
+ conflicts.fileConflicts = new Map(
7912
+ Object.entries(reversal.conflictsSnapshot.fileConflicts || {})
7913
+ );
7914
+ await conflicts.save();
7915
+ }
7916
+ // Else: no operation in the reverted range touched conflicts — leave
7917
+ // current conflict state alone. (Previously undo() unconditionally
7918
+ // cleared ALL conflicts whenever the op being undone hadn't recorded
7919
+ // its own conflictsSnapshot, which is nearly every non-merge() op —
7920
+ // undoing an unrelated describe() would silently wipe conflicts from
7921
+ // an earlier, unrelated merge(). Fixed as part of issue #29.)
7922
+ };
7923
+
7924
+ /**
7925
+ * Re-parent every child of `changeId` onto `newParents` instead — used
7926
+ * when a change is being elided from the graph (abandon(), issue #30;
7927
+ * converge(), issue #32) so its children don't end up pointing at
7928
+ * something no longer meant to be there. Mutates `changeSnapshot` in
7929
+ * place, adding each touched child's PRE-reparent record so the caller's
7930
+ * own oplog entry lets undo()/redo()/operations.restore() reverse this
7931
+ * too (the same mechanism the caller already uses for the elided
7932
+ * change's own snapshot).
7933
+ *
7934
+ * @param {string} changeId - The change being elided
7935
+ * @param {string[]} newParents - What its children should point at instead
7936
+ * @param {Record<string, any>} changeSnapshot - Accumulator to add each touched child's pre-state to
7937
+ * @returns {Promise<void>}
7938
+ */
7939
+ const reparentChildrenOnto = async (changeId, newParents, changeSnapshot) => {
7940
+ const childIds = graph.getChildren(changeId);
7941
+ for (const childId of childIds) {
7942
+ const child = await graph.getChange(childId);
7943
+ if (!child) continue;
7944
+ changeSnapshot[childId] = structuredClone(child);
7945
+ const reparented = child.parents.flatMap((/** @type {string} */ p) =>
7946
+ p === changeId ? newParents : [p]
7947
+ );
7948
+ // De-dupe (e.g. the child could already have one of the new parents
7949
+ // as another of its own parents) while preserving first-seen order.
7950
+ child.parents = [...new Set(reparented)];
7951
+ await graph.updateChange(child);
7952
+ }
7953
+ };
7954
+
7955
+ /**
7956
+ * Determine which operation a fresh undo() call should revert, matching
7957
+ * real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
7958
+ * a normal operation, this will undo that last operation by restoring its
7959
+ * parent. If jj undo is used repeatedly, it will restore increasingly
7960
+ * older operations, going further back into the past.").
7961
+ *
7962
+ * The raw op-log parent chain can't answer this directly once undo/redo
7963
+ * bookkeeping operations are mixed into the log: an 'undo' operation's
7964
+ * own parent is the operation it just undid, so naively targeting
7965
+ * `ops[ops.length - 1]` on a SECOND undo() would undo the undo (toggle
7966
+ * back to the future) instead of stepping one further step into the past
7967
+ * — this was issue #29's "undo() twice only undoes the undo" bug.
7968
+ *
7969
+ * @param {Array<any>} ops - Full chronological operation list
7970
+ * @returns {any} The operation object undo() should revert
7971
+ */
7972
+ const resolveUndoTarget = (ops) => {
7973
+ const head = ops[ops.length - 1];
7974
+ const findOp = (/** @type {any} */ id) => ops.find((op) => op.id === id);
7975
+
7976
+ if (head.eventType === 'undo') {
7977
+ // We're stepping further into the past: what should be undone now is
7978
+ // the operation right BEFORE the one `head` already undid — not
7979
+ // head's own parent, which IS the operation head undid (targeting it
7980
+ // again would just toggle back to the present).
7981
+ const previouslyUndone = head.targetOpId && findOp(head.targetOpId);
7982
+ const stepBack = previouslyUndone && findOp(previouslyUndone.parents[0]);
7983
+ return stepBack || previouslyUndone || head;
7984
+ }
7985
+
7986
+ if (head.eventType === 'redo') {
7987
+ // A redo re-established some earlier operation as current; a fresh
7988
+ // undo right after it should behave exactly as if that operation
7989
+ // were freshly current and had never been touched by undo/redo at
7990
+ // all — i.e. undo it again (real jj: redo "moves in the direction of
7991
+ // the future"; undoing right after simply resumes stepping back).
7992
+ const redoneUndo = head.redoOf && findOp(head.redoOf);
7993
+ const target = redoneUndo && redoneUndo.targetOpId && findOp(redoneUndo.targetOpId);
7994
+ return target || head;
7995
+ }
7996
+
7997
+ return head;
6517
7998
  };
6518
7999
 
6519
8000
  /**
@@ -6700,6 +8181,25 @@ async function createJJ(options) {
6700
8181
  }
6701
8182
  },
6702
8183
 
8184
+ // Used by undo()/redo()/operations.restore() to reverse an
8185
+ // addChange() when winding the graph back — see issue #29. No Git
8186
+ // sync hook: a change created after the operation being reverted was
8187
+ // never pushed anywhere to unwind, and the Git backend has no
8188
+ // "forget this commit" operation (nor should it — Git objects are
8189
+ // content-addressed and harmless to leave orphaned).
8190
+ deleteChange: (/** @type {any} */ changeId) => baseGraph.deleteChange(changeId),
8191
+
8192
+ // See issue #32 / converge(): a divergent copy is another commit
8193
+ // claiming an existing changeId; no Git sync hook, for the same
8194
+ // reason addChange()'s primary copy already goes through
8195
+ // onAddChange() when it's first created — the divergent copy needs
8196
+ // no additional sync of its own.
8197
+ addDivergentCopy: (/** @type {any} */ change) => baseGraph.addDivergentCopy(change),
8198
+ getDivergentSiblings: (/** @type {any} */ changeId) =>
8199
+ baseGraph.getDivergentSiblings(changeId),
8200
+ deleteDivergentCopy: (/** @type {any} */ changeId, /** @type {any} */ commitId) =>
8201
+ baseGraph.deleteDivergentCopy(changeId, commitId),
8202
+
6703
8203
  // Delegate other operations
6704
8204
  init: () => baseGraph.init(),
6705
8205
  };
@@ -6857,6 +8357,20 @@ async function createJJ(options) {
6857
8357
  /**
6858
8358
  * Write a file to the working copy
6859
8359
  *
8360
+ * Note (issue #30): this does NOT update the working-copy change's
8361
+ * `commitId`/`fileSnapshot` — those stay as they were until the next
8362
+ * command that snapshots the working copy (describe(), new(), edit(),
8363
+ * status(), or any other call that goes through
8364
+ * autoSnapshotWorkingCopy()/snapshot()). This is intentional, not a
8365
+ * bug: jj has no staging area, but it also doesn't recompute a git
8366
+ * tree/commit hash on every single file write (that would mean
8367
+ * hashing on every keystroke of an external editor) — real jj only
8368
+ * takes a fresh working-copy snapshot when an actual `jj` command
8369
+ * runs. write() is the equivalent of editing a file with your OS's own
8370
+ * tools outside of jj entirely; describe()/new()/edit()/status()/etc.
8371
+ * are the equivalent of running a jj command, which is what triggers
8372
+ * the (auto) snapshot in both real jj and here.
8373
+ *
6860
8374
  * @param {Record<string, any>} args - Arguments
6861
8375
  * @returns {Promise<Object>} File information including path, size, and mode
6862
8376
  */
@@ -6880,7 +8394,7 @@ async function createJJ(options) {
6880
8394
  if (pathParts.length > 1) {
6881
8395
  const dirPath = pathParts.slice(0, -1).join('/');
6882
8396
  const fullDirPath = `${dir}/${dirPath}`;
6883
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8397
+ await mkdirp(fs, fullDirPath);
6884
8398
  }
6885
8399
 
6886
8400
  // Write the file
@@ -6935,10 +8449,10 @@ async function createJJ(options) {
6935
8449
  });
6936
8450
  }
6937
8451
 
6938
- const fullPath = path.join(dir, args.path);
8452
+ const fullPath = join(dir, args.path);
6939
8453
  try {
6940
8454
  if (encoding === 'utf-8' || encoding === 'utf8') {
6941
- return await fs.promises.readFile(fullPath, 'utf-8');
8455
+ return await fs.promises.readFile(fullPath, 'utf8');
6942
8456
  } else {
6943
8457
  return await fs.promises.readFile(fullPath);
6944
8458
  }
@@ -7103,7 +8617,7 @@ async function createJJ(options) {
7103
8617
 
7104
8618
  // Reading from working copy
7105
8619
  if (!args.changeId) {
7106
- const fullPath = path.join(dir, args.path);
8620
+ const fullPath = join(dir, args.path);
7107
8621
  try {
7108
8622
  const stream = fs.createReadStream(
7109
8623
  fullPath,
@@ -7153,14 +8667,14 @@ async function createJJ(options) {
7153
8667
  });
7154
8668
  }
7155
8669
 
7156
- const fullPath = path.join(dir, args.path);
8670
+ const fullPath = join(dir, args.path);
7157
8671
 
7158
8672
  // Ensure directory exists
7159
8673
  const pathParts = args.path.split('/');
7160
8674
  if (pathParts.length > 1) {
7161
8675
  const dirPath = pathParts.slice(0, -1).join('/');
7162
- const fullDirPath = path.join(dir, dirPath);
7163
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8676
+ const fullDirPath = join(dir, dirPath);
8677
+ await mkdirp(fs, fullDirPath);
7164
8678
  }
7165
8679
 
7166
8680
  // Create writable stream
@@ -7273,7 +8787,7 @@ async function createJJ(options) {
7273
8787
  // Ensure destination directory exists
7274
8788
  const toDir = toPath.substring(0, toPath.lastIndexOf('/'));
7275
8789
  try {
7276
- await fs.promises.mkdir(toDir, { recursive: true });
8790
+ await mkdirp(fs, toDir);
7277
8791
  } catch (error) {
7278
8792
  throw new JJError(
7279
8793
  'FILE_SYSTEM_ERROR',
@@ -7391,6 +8905,51 @@ async function createJJ(options) {
7391
8905
  // TODO: Prevent creating cycles in the change graph
7392
8906
  // For now, we'll allow it but it should be validated
7393
8907
 
8908
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
8909
+ // reverse the reparenting — see issue #29.
8910
+ const changeSnapshotBefore = structuredClone(change);
8911
+
8912
+ // Three-way merge against the OLD base before reparenting (issue #31):
8913
+ // real jj's rebase re-derives the rebased change's content as
8914
+ // (old base, old commit, new base) — if a sibling changed the same
8915
+ // lines differently, that's a genuine conflict, not something
8916
+ // reparenting-and-copying-verbatim can silently paper over. This
8917
+ // reuses the exact same detectConflicts() machinery merge() already
8918
+ // uses (see merge(), a few hundred lines down) rather than
8919
+ // duplicating three-way-merge logic a third time in this codebase.
8920
+ const oldParentId = change.parents[0];
8921
+ const oldParentChange = oldParentId ? await graph.getChange(oldParentId) : null;
8922
+ const baseFiles = new Map(
8923
+ Object.entries((oldParentChange && oldParentChange.fileSnapshot) || {})
8924
+ );
8925
+ const leftFiles = new Map(Object.entries(change.fileSnapshot || {}));
8926
+ const rightFiles = new Map(Object.entries(newParentChange.fileSnapshot || {}));
8927
+
8928
+ await conflicts.load();
8929
+ const conflictsSnapshot = {
8930
+ conflicts: Object.fromEntries(conflicts.conflicts),
8931
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
8932
+ };
8933
+
8934
+ const detectedConflicts = oldParentChange
8935
+ ? await conflicts.detectConflicts({
8936
+ baseFiles,
8937
+ leftFiles,
8938
+ rightFiles,
8939
+ drivers: {},
8940
+ // Not necessarily the checked-out working copy, so don't let
8941
+ // merge drivers write to disk on its behalf.
8942
+ workingCopyDir: /** @type {any} */ (null),
8943
+ baseChange: oldParentId,
8944
+ leftChange: changeId,
8945
+ rightChange: newParent,
8946
+ })
8947
+ : [];
8948
+
8949
+ for (const conflict of detectedConflicts) {
8950
+ await conflicts.addConflict(conflict);
8951
+ }
8952
+
7394
8953
  // Update parent
7395
8954
  change.parents = [newParent];
7396
8955
  await graph.updateChange(change);
@@ -7401,6 +8960,8 @@ async function createJJ(options) {
7401
8960
  user: await getUserOplogInfo(),
7402
8961
  description: `move change ${changeId.slice(0, 8)} to ${newParent.slice(0, 8)}`,
7403
8962
  parents: [],
8963
+ changeSnapshot: { [changeId]: changeSnapshotBefore },
8964
+ conflictsSnapshot,
7404
8965
  view: {
7405
8966
  bookmarks: {},
7406
8967
  remoteBookmarks: {},
@@ -7409,7 +8970,7 @@ async function createJJ(options) {
7409
8970
  },
7410
8971
  });
7411
8972
 
7412
- return change;
8973
+ return { ...change, conflicts: detectedConflicts };
7413
8974
  },
7414
8975
 
7415
8976
  /**
@@ -7541,6 +9102,11 @@ async function createJJ(options) {
7541
9102
  const isWorkingCopy = targetChangeId === workingCopy.getCurrentChangeId();
7542
9103
  const fileSnapshotBefore = isWorkingCopy ? await snapshotFilesystem() : null;
7543
9104
 
9105
+ // Snapshot the change record itself from BEFORE this operation mutates
9106
+ // it in place, so undo() can restore ChangeGraph content (not just
9107
+ // on-disk files/working-copy pointer) — mirrors fileSnapshotBefore above.
9108
+ const changeSnapshotBefore = structuredClone(change);
9109
+
7544
9110
  // Dispatch change:updating event (preventable)
7545
9111
  await dispatchEventAsync(jj, 'change:updating', {
7546
9112
  operation: 'describe',
@@ -7576,7 +9142,7 @@ async function createJJ(options) {
7576
9142
 
7577
9143
  for (const filePath of trackedFiles) {
7578
9144
  try {
7579
- const fullPath = path.join(dir, filePath);
9145
+ const fullPath = join(dir, filePath);
7580
9146
  const stats = await fs.promises.stat(fullPath);
7581
9147
 
7582
9148
  // Skip files that are too large (silently - this is an optimization)
@@ -7589,7 +9155,7 @@ async function createJJ(options) {
7589
9155
  break;
7590
9156
  }
7591
9157
 
7592
- const content = await fs.promises.readFile(fullPath, 'utf-8');
9158
+ const content = await fs.promises.readFile(fullPath, 'utf8');
7593
9159
  fileSnapshot[filePath] = content;
7594
9160
  totalSnapshotSize += stats.size;
7595
9161
  } catch (error) {
@@ -7621,6 +9187,10 @@ async function createJJ(options) {
7621
9187
  },
7622
9188
  description: `describe change ${targetChangeId.slice(0, 8)}`,
7623
9189
  parents: [],
9190
+ // Snapshot of the affected ChangeGraph node from BEFORE this
9191
+ // operation ran, so undo() can restore in-place content mutations
9192
+ // (e.g. the description change below) — not just files/pointers.
9193
+ changeSnapshot: { [targetChangeId]: changeSnapshotBefore },
7624
9194
  view: {
7625
9195
  bookmarks: {},
7626
9196
  remoteBookmarks: {},
@@ -7783,6 +9353,8 @@ async function createJJ(options) {
7783
9353
 
7784
9354
  // Snapshot filesystem BEFORE operation (for undo) unless preserveSnapshot is true
7785
9355
  let fileSnapshot;
9356
+ /** @type {Record<string, any>} */
9357
+ const changeSnapshot = {};
7786
9358
  if (!args.preserveSnapshot) {
7787
9359
  fileSnapshot = await snapshotFilesystem();
7788
9360
 
@@ -7791,6 +9363,10 @@ async function createJJ(options) {
7791
9363
  const currentChangeId = workingCopy.getCurrentChangeId();
7792
9364
  const currentChange = await graph.getChange(currentChangeId);
7793
9365
  if (currentChange && fileSnapshot) {
9366
+ // Snapshot BEFORE mutating so undo()/redo() can revert this
9367
+ // in-place update too, not just the pointer/new-change creation
9368
+ // below — see issue #29.
9369
+ changeSnapshot[currentChangeId] = structuredClone(currentChange);
7794
9370
  currentChange.fileSnapshot = fileSnapshot;
7795
9371
  await graph.updateChange(currentChange);
7796
9372
  }
@@ -7818,6 +9394,7 @@ async function createJJ(options) {
7818
9394
  const user = /** @type {any} */ (userConfig.getUser());
7819
9395
 
7820
9396
  // Initialize with parent's file snapshot
9397
+ /** @type {Record<string, string>} */
7821
9398
  let initialSnapshot = {};
7822
9399
  if (parents && parents.length > 0) {
7823
9400
  const parent = await graph.getChange(parents[0]);
@@ -7861,10 +9438,19 @@ async function createJJ(options) {
7861
9438
  // Handle insertBefore: rebase target to have new change as parent
7862
9439
  if (args.insertBefore) {
7863
9440
  const targetChange = await graph.getChange(args.insertBefore);
9441
+ changeSnapshot[args.insertBefore] = structuredClone(targetChange);
7864
9442
  targetChange.parents = [newChangeId];
7865
9443
  await graph.updateChange(targetChange);
7866
9444
  }
7867
9445
 
9446
+ // Sync the working directory to the new change's snapshot: write
9447
+ // everything it has AND remove anything it doesn't (issue #30 — this
9448
+ // used to never touch disk at all, so `new({ parents: [...] })`
9449
+ // targeting anything other than the current head silently left the
9450
+ // OLD working copy's files in place instead of checking out the new
9451
+ // parent's).
9452
+ await syncWorkingCopyFiles(initialSnapshot);
9453
+
7868
9454
  await workingCopy.setCurrentChange(newChangeId);
7869
9455
 
7870
9456
  // Record operation with filesystem snapshot
@@ -7877,6 +9463,13 @@ async function createJJ(options) {
7877
9463
  },
7878
9464
  description: `new change ${newChangeId.slice(0, 8)}`,
7879
9465
  parents: [],
9466
+ // See issue #29: lets undo()/redo()/operations.restore() reverse
9467
+ // the existing records this op updated in place (the old
9468
+ // working-copy change's refreshed fileSnapshot, and insertBefore's
9469
+ // target's reparented parents). The newly created change itself
9470
+ // (newChangeId) is intentionally NOT torn down on undo — see
9471
+ // computeGraphReversal's doc comment.
9472
+ changeSnapshot,
7880
9473
  view: {
7881
9474
  bookmarks: {},
7882
9475
  remoteBookmarks: {},
@@ -8133,11 +9726,28 @@ async function createJJ(options) {
8133
9726
  // Try to resolve as revset
8134
9727
  try {
8135
9728
  const changes = await revset.evaluate(args.change);
9729
+ if (changes.length > 1) {
9730
+ // An id-prefix (or other) revset that matches more than one
9731
+ // change must not silently pick a "winner" — see issue #13.
9732
+ throw new JJError(
9733
+ 'AMBIGUOUS_ID',
9734
+ `"${args.change}" is ambiguous: it resolves to ${changes.length} changes (${changes.join(', ')})`,
9735
+ {
9736
+ expression: args.change,
9737
+ matches: changes,
9738
+ suggestion: 'Use a longer prefix or an exact change ID to disambiguate',
9739
+ }
9740
+ );
9741
+ }
8136
9742
  if (changes.length > 0) {
8137
9743
  changeId = changes[0];
8138
9744
  }
8139
- } catch {
8140
- // Use as-is
9745
+ } catch (error) {
9746
+ if (error instanceof JJError && error.code === 'AMBIGUOUS_ID') {
9747
+ throw error;
9748
+ }
9749
+ // Not a valid revset expression - use args.change as-is (e.g. a
9750
+ // literal change ID that revset parsing rejected).
8141
9751
  }
8142
9752
  }
8143
9753
 
@@ -8240,6 +9850,13 @@ async function createJJ(options) {
8240
9850
 
8241
9851
  const previousChangeId = workingCopy.getCurrentChangeId();
8242
9852
 
9853
+ /** @type {Record<string, any>} */
9854
+ const changeSnapshot = {};
9855
+
9856
+ // Capture disk state right before we touch anything, for undo() —
9857
+ // see issue #29.
9858
+ const fileSnapshotBeforeSwitch = await snapshotFilesystem();
9859
+
8243
9860
  // CRITICAL FIX: Clean working directory BEFORE snapshotting to prevent cross-branch pollution
8244
9861
  // Remove files that don't belong to the current changeId before we snapshot it
8245
9862
  if (previousChangeId !== args.changeId) {
@@ -8253,7 +9870,7 @@ async function createJJ(options) {
8253
9870
  for (const filePath of workingDirFiles) {
8254
9871
  if (!previousSnapshot[filePath]) {
8255
9872
  try {
8256
- const fullPath = path.join(dir, filePath);
9873
+ const fullPath = join(dir, filePath);
8257
9874
  await fs.promises.unlink(fullPath);
8258
9875
  await workingCopy.untrackFile(filePath);
8259
9876
  } catch (error) {
@@ -8261,13 +9878,14 @@ async function createJJ(options) {
8261
9878
  }
8262
9879
  }
8263
9880
  }
8264
- }
8265
9881
 
8266
- // Now snapshot the CLEAN working directory (no pollution!)
8267
- const currentSnapshot = await snapshotFilesystem();
8268
- if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
8269
- previousChange.fileSnapshot = currentSnapshot;
8270
- await graph.updateChange(previousChange);
9882
+ // Now snapshot the CLEAN working directory (no pollution!)
9883
+ const currentSnapshot = await snapshotFilesystem();
9884
+ if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
9885
+ changeSnapshot[previousChangeId] = structuredClone(previousChange);
9886
+ previousChange.fileSnapshot = currentSnapshot;
9887
+ await graph.updateChange(previousChange);
9888
+ }
8271
9889
  }
8272
9890
  }
8273
9891
 
@@ -8280,38 +9898,17 @@ async function createJJ(options) {
8280
9898
  timestamp: new Date().toISOString(),
8281
9899
  });
8282
9900
 
8283
- // Restore files from the change's snapshot to working directory
8284
- if (change.fileSnapshot) {
8285
- for (const [filePath, content] of Object.entries(change.fileSnapshot)) {
8286
- try {
8287
- const fullPath = path.join(dir, filePath);
8288
-
8289
- // Ensure directory exists
8290
- const pathParts = filePath.split('/');
8291
- if (pathParts.length > 1) {
8292
- const dirPath = pathParts.slice(0, -1).join('/');
8293
- const fullDirPath = path.join(dir, dirPath);
8294
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8295
- }
8296
-
8297
- // Write file content
8298
- await fs.promises.writeFile(fullPath, content, 'utf8');
8299
-
8300
- // Update working copy tracking
8301
- const stats = await fs.promises.stat(fullPath);
8302
- await workingCopy.trackFile(filePath, {
8303
- mtime: stats.mtime,
8304
- size: stats.size,
8305
- mode: stats.mode,
8306
- });
8307
- } catch (error) {
8308
- throw new JJError(
8309
- 'FILE_RESTORE_FAILED',
8310
- `Failed to restore file ${filePath}: ${error.message}`,
8311
- { filePath, originalError: error.message }
8312
- );
8313
- }
8314
- }
9901
+ // Fully sync the working directory to the target change's snapshot:
9902
+ // write everything it has AND remove anything it doesn't (issue #30
9903
+ // — this used to only overlay the target's files on top of whatever
9904
+ // was already on disk, so a file present in the PREVIOUS change but
9905
+ // absent from the target leaked into the next snapshot).
9906
+ try {
9907
+ await syncWorkingCopyFiles(change.fileSnapshot || {});
9908
+ } catch (error) {
9909
+ throw new JJError('FILE_RESTORE_FAILED', `Failed to restore files: ${error.message}`, {
9910
+ originalError: error.message,
9911
+ });
8315
9912
  }
8316
9913
 
8317
9914
  // Set this change as the working copy
@@ -8327,11 +9924,13 @@ async function createJJ(options) {
8327
9924
  },
8328
9925
  description: `edit change ${args.changeId.slice(0, 8)}`,
8329
9926
  parents: [],
9927
+ changeSnapshot,
8330
9928
  view: {
8331
9929
  bookmarks: {},
8332
9930
  remoteBookmarks: {},
8333
9931
  heads: [args.changeId],
8334
9932
  workingCopy: args.changeId,
9933
+ fileSnapshot: fileSnapshotBeforeSwitch,
8335
9934
  },
8336
9935
  });
8337
9936
 
@@ -8362,91 +9961,107 @@ async function createJJ(options) {
8362
9961
  /**
8363
9962
  * Undo last operation
8364
9963
  *
9964
+ * Reverts the most recent operation — including graph-rewriting ones
9965
+ * (squash/rebase/abandon/new/edit), not just working-copy/file state
9966
+ * (see issue #29). Matches real jj's progressive-undo semantics
9967
+ * (`jj-undo(1)`): called again with nothing else in between, it steps
9968
+ * one further operation into the past rather than undoing itself.
9969
+ *
8365
9970
  * @returns {Promise<Object>} Information about the undo including the undone operation and restored state
8366
9971
  */
8367
9972
  async undo() {
8368
- // Get the operation we're about to undo to access its pre-state
8369
9973
  await oplog.load();
8370
9974
  const ops = await oplog.list();
8371
9975
  if (ops.length === 0) {
8372
9976
  throw new JJError('NOTHING_TO_UNDO', 'No operations to undo');
8373
9977
  }
8374
9978
 
8375
- // Get the current operation (the one being undone) to restore its pre-state
8376
- const currentOp = ops[ops.length - 1];
8377
-
8378
- const previousView = /** @type {any} */ (await oplog.undo());
8379
-
8380
- // Restore state from previous view
8381
- await workingCopy.setCurrentChange(previousView.workingCopy);
9979
+ const targetOp = resolveUndoTarget(ops);
9980
+ const landingOpId = targetOp.parents && targetOp.parents[0];
9981
+ const landingOp = landingOpId ? ops.find((op) => op.id === landingOpId) : null;
8382
9982
 
8383
- // Restore filesystem from previous operation's snapshot (taken BEFORE that operation)
8384
- // This is how JJ snapshots the working copy before every command
8385
- if (previousView.fileSnapshot) {
8386
- for (const [filePath, content] of Object.entries(previousView.fileSnapshot)) {
8387
- try {
8388
- const fullPath = path.join(dir, filePath);
8389
-
8390
- // Ensure directory exists
8391
- const pathParts = filePath.split('/');
8392
- if (pathParts.length > 1) {
8393
- const dirPath = pathParts.slice(0, -1).join('/');
8394
- const fullDirPath = path.join(dir, dirPath);
8395
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8396
- }
8397
-
8398
- // Write file content
8399
- await fs.promises.writeFile(fullPath, content, 'utf8');
8400
- } catch (error) {
8401
- throw new JJError(
8402
- 'UNDO_FILE_RESTORE_FAILED',
8403
- `Failed to restore file ${filePath} during undo: ${error.message}`,
8404
- { filePath, originalError: error.message }
8405
- );
8406
- }
8407
- }
9983
+ /** @type {ReturnType<typeof computeGraphReversal>} */
9984
+ let reversal;
9985
+ /** @type {any} */
9986
+ let landingView;
9987
+ if (landingOp) {
9988
+ reversal = computeGraphReversal(ops, landingOp);
9989
+ landingView = landingOp.view || {};
9990
+ } else {
9991
+ // Undoing the very first (root) operation: there's nothing before
9992
+ // it to restore to, so land on its own recorded state rather than
9993
+ // throwing (mirrors the previous no-parent fallback).
9994
+ reversal = {
9995
+ changeSnapshotToApply: {},
9996
+ fileSnapshot: undefined,
9997
+ conflictsSnapshot: undefined,
9998
+ };
9999
+ landingView = targetOp.view || {};
10000
+ }
10001
+
10002
+ // Capture EXACTLY what's about to be overwritten, from ground truth,
10003
+ // before applying the reversal — this is what redo() re-applies
10004
+ // later. It can't just recompute "the state after targetOp" the way
10005
+ // undo() itself computes "the state after landingOp" (by scanning
10006
+ // for a LATER op that also touched the same changeId/files/
10007
+ // conflicts): that scan only works going backward, where "nothing
10008
+ // touched it since" means "it's already correct". Going forward,
10009
+ // "nothing touched it since targetOp" means the opposite — its
10010
+ // effect is still live and needs to be captured now, not rederived.
10011
+ await graph.load();
10012
+ /** @type {Record<string, any>} */
10013
+ const redoChangeSnapshot = {};
10014
+ for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
10015
+ const current = await graph.getChange(changeId);
10016
+ if (current) redoChangeSnapshot[changeId] = current;
10017
+ }
10018
+ const redoFileSnapshot =
10019
+ reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
10020
+ let redoConflictsSnapshot;
10021
+ if (reversal.conflictsSnapshot !== undefined) {
10022
+ await conflicts.load();
10023
+ redoConflictsSnapshot = {
10024
+ conflicts: Object.fromEntries(conflicts.conflicts),
10025
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
10026
+ };
8408
10027
  }
8409
10028
 
8410
- // Restore conflicts state from before the undone operation
8411
- // The conflictsSnapshot in an operation represents the state BEFORE that operation ran
8412
- await conflicts.load();
8413
- if (currentOp && currentOp.conflictsSnapshot) {
8414
- // Restore conflicts from the operation we're undoing (its pre-state)
8415
- conflicts.conflicts = new Map(Object.entries(currentOp.conflictsSnapshot.conflicts || {}));
8416
- conflicts.fileConflicts = new Map(
8417
- Object.entries(currentOp.conflictsSnapshot.fileConflicts || {})
8418
- );
8419
- await conflicts.save();
8420
- } else {
8421
- // No conflicts snapshot - clear conflicts
8422
- await conflicts.clear();
10029
+ if (landingView.workingCopy) {
10030
+ await workingCopy.setCurrentChange(landingView.workingCopy);
8423
10031
  }
10032
+ await applyGraphReversal(reversal);
8424
10033
 
8425
- // Record undo operation. Tag it with the view it undid so that redo()
8426
- // can re-apply it (jj's progressive undo/redo, added in jj v0.33).
10034
+ // Record undo as its own operation (matching real jj) — tagged with
10035
+ // which operation it targeted so a following undo() can walk one
10036
+ // step further back (resolveUndoTarget) instead of toggling, and
10037
+ // carrying exactly what redo() needs to re-apply (captured above).
8427
10038
  await oplog.recordOperation({
8428
10039
  timestamp: new Date().toISOString(),
8429
10040
  user: await getUserOplogInfo(),
8430
10041
  description: 'undo operation',
8431
10042
  eventType: 'undo',
8432
- undoneOpId: currentOp.id,
8433
- undoneView: currentOp.view,
8434
- undoneConflictsSnapshot: currentOp.conflictsSnapshot || null,
10043
+ targetOpId: targetOp.id,
10044
+ undoneOpId: targetOp.id, // kept for API/type compatibility
10045
+ redoChangeSnapshot,
10046
+ redoFileSnapshot,
10047
+ redoConflictsSnapshot,
8435
10048
  parents: [],
8436
- view: previousView,
10049
+ view: landingView,
8437
10050
  });
8438
10051
 
8439
- // Return information about what was undone
10052
+ const restoredFileCount =
10053
+ reversal.fileSnapshot !== undefined ? Object.keys(reversal.fileSnapshot).length : 0;
10054
+
8440
10055
  return {
8441
10056
  undoneOperation: {
8442
- description: currentOp.description,
8443
- timestamp: currentOp.timestamp,
8444
- user: currentOp.user,
10057
+ description: targetOp.description,
10058
+ timestamp: targetOp.timestamp,
10059
+ user: targetOp.user,
8445
10060
  },
8446
10061
  restoredState: {
8447
- workingCopy: previousView.workingCopy,
8448
- heads: previousView.heads,
8449
- fileCount: previousView.fileSnapshot ? Object.keys(previousView.fileSnapshot).length : 0,
10062
+ workingCopy: landingView.workingCopy,
10063
+ heads: landingView.heads,
10064
+ fileCount: restoredFileCount,
8450
10065
  },
8451
10066
  };
8452
10067
  },
@@ -8454,9 +10069,9 @@ async function createJJ(options) {
8454
10069
  /**
8455
10070
  * Redo an operation previously undone with undo() (v1.5)
8456
10071
  *
8457
- * Mirrors `jj redo` (jj v0.33): progressively re-applies the operations that
8458
- * `undo()` reverted, most-recent-undo first. Each redo consumes exactly one
8459
- * pending undo.
10072
+ * Mirrors `jj redo` (jj v0.33): re-applies the operation that the most
10073
+ * recent not-yet-redone undo() reverted — including its graph rewrites,
10074
+ * not just working-copy/file state (see issue #29).
8460
10075
  *
8461
10076
  * @returns {Promise<Object>} Information about the redone operation
8462
10077
  */
@@ -8487,42 +10102,36 @@ async function createJJ(options) {
8487
10102
  });
8488
10103
  }
8489
10104
 
8490
- const redoneView = undoOp.undoneView || {};
10105
+ const targetOpId = undoOp.targetOpId || undoOp.undoneOpId;
10106
+ const targetOp = targetOpId && ops.find((op) => op.id === targetOpId);
10107
+ const redoneView = (targetOp && targetOp.view) || {};
8491
10108
 
8492
- // Restore the working-copy pointer.
8493
10109
  if (redoneView.workingCopy) {
8494
10110
  await workingCopy.setCurrentChange(redoneView.workingCopy);
8495
10111
  }
8496
10112
 
8497
- // Restore the working-copy files captured in the undone view.
8498
- if (redoneView.fileSnapshot) {
8499
- for (const [filePath, content] of Object.entries(redoneView.fileSnapshot)) {
8500
- try {
8501
- const fullPath = path.join(dir, filePath);
8502
- const pathParts = filePath.split('/');
8503
- if (pathParts.length > 1) {
8504
- const fullDirPath = path.join(dir, pathParts.slice(0, -1).join('/'));
8505
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8506
- }
8507
- await fs.promises.writeFile(fullPath, content, 'utf8');
8508
- } catch (error) {
8509
- throw new JJError(
8510
- 'REDO_FILE_RESTORE_FAILED',
8511
- `Failed to restore file ${filePath} during redo: ${error.message}`,
8512
- { filePath, originalError: error.message }
8513
- );
8514
- }
10113
+ // Re-apply exactly what this undo captured right before it reverted
10114
+ // things — NOT computeGraphReversal(ops, targetOp): that scans for a
10115
+ // LATER op touching the same changeId/files/conflicts, which is the
10116
+ // right question for undo() ("has anything since made my target
10117
+ // stale?") but the wrong one here — after an undo, NOTHING later
10118
+ // re-established targetOp's own effect, so there's nothing to find
10119
+ // by scanning forward. undo() captured it directly for exactly this
10120
+ // reason (see its own comment).
10121
+ await graph.load();
10122
+ for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
10123
+ if (await graph.getChange(changeId)) {
10124
+ await graph.updateChange(snapshot);
8515
10125
  }
8516
10126
  }
8517
-
8518
- // Restore conflicts to the state that existed after the redone operation.
10127
+ if (undoOp.redoFileSnapshot !== undefined) {
10128
+ await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
10129
+ }
8519
10130
  await conflicts.load();
8520
- if (undoOp.undoneConflictsSnapshot) {
8521
- conflicts.conflicts = new Map(
8522
- Object.entries(undoOp.undoneConflictsSnapshot.conflicts || {})
8523
- );
10131
+ if (undoOp.redoConflictsSnapshot !== undefined) {
10132
+ conflicts.conflicts = new Map(Object.entries(undoOp.redoConflictsSnapshot.conflicts || {}));
8524
10133
  conflicts.fileConflicts = new Map(
8525
- Object.entries(undoOp.undoneConflictsSnapshot.fileConflicts || {})
10134
+ Object.entries(undoOp.redoConflictsSnapshot.fileConflicts || {})
8526
10135
  );
8527
10136
  await conflicts.save();
8528
10137
  }
@@ -8537,15 +10146,18 @@ async function createJJ(options) {
8537
10146
  view: redoneView,
8538
10147
  });
8539
10148
 
10149
+ const restoredFileCount =
10150
+ undoOp.redoFileSnapshot !== undefined ? Object.keys(undoOp.redoFileSnapshot).length : 0;
10151
+
8540
10152
  return {
8541
10153
  redoneOperation: {
8542
10154
  description: undoOp.description,
8543
- undoneOpId: undoOp.undoneOpId,
10155
+ undoneOpId: targetOpId,
8544
10156
  },
8545
10157
  restoredState: {
8546
10158
  workingCopy: redoneView.workingCopy,
8547
10159
  heads: redoneView.heads,
8548
- fileCount: redoneView.fileSnapshot ? Object.keys(redoneView.fileSnapshot).length : 0,
10160
+ fileCount: restoredFileCount,
8549
10161
  },
8550
10162
  };
8551
10163
  },
@@ -8772,6 +10384,12 @@ async function createJJ(options) {
8772
10384
  /**
8773
10385
  * Restore repository to a specific operation (matches `jj operation restore`)
8774
10386
  *
10387
+ * Fully reverts everything recorded after the target operation —
10388
+ * including graph rewrites (squash/rebase/abandon/new/edit), working
10389
+ * copy files, and conflict state, not just bookmarks/the working-copy
10390
+ * pointer (see issue #29) — using the same reversal primitive as
10391
+ * undo()/redo().
10392
+ *
8775
10393
  * @param {Record<string, any>} args - Arguments
8776
10394
  * @returns {Promise<Object>} Restore result
8777
10395
  */
@@ -8811,6 +10429,11 @@ async function createJJ(options) {
8811
10429
  await workingCopy.setCurrentChange(targetOp.view.workingCopy);
8812
10430
  }
8813
10431
 
10432
+ // Revert the change graph, working-copy files, and conflict state
10433
+ // to exactly what they were right after targetOp ran.
10434
+ const reversal = computeGraphReversal(ops, targetOp);
10435
+ await applyGraphReversal(reversal);
10436
+
8814
10437
  // Record this restoration as a new operation
8815
10438
  await oplog.recordOperation({
8816
10439
  timestamp: new Date().toISOString(),
@@ -8864,6 +10487,7 @@ async function createJJ(options) {
8864
10487
  // Compute the inverse changes
8865
10488
  const inversChanges = /** @type {Record<string, any>} */ ({
8866
10489
  bookmarks: {},
10490
+ tags: {},
8867
10491
  heads: [],
8868
10492
  });
8869
10493
 
@@ -8903,6 +10527,46 @@ async function createJJ(options) {
8903
10527
 
8904
10528
  await bookmarks.save();
8905
10529
 
10530
+ // Revert tag changes (mirrors the bookmark diff above — issue #12).
10531
+ // Unlike bookmarks, TagStore has no "move" primitive (tags are meant
10532
+ // to be immutable once created), so a moved tag is reverted via
10533
+ // delete+recreate, matching how tag.set() itself updates a tag.
10534
+ const prevTags = previousOp.view?.tags || {};
10535
+ const targetTags = targetOp.view?.tags || {};
10536
+
10537
+ for (const name of Object.keys(targetTags)) {
10538
+ if (!prevTags[name]) {
10539
+ // Tag was added - delete it
10540
+ if (await tags.exists(name)) {
10541
+ await tags.delete(name);
10542
+ }
10543
+ inversChanges.tags[name] = { action: 'deleted' };
10544
+ } else if (prevTags[name] !== targetTags[name]) {
10545
+ // Tag was moved - restore old target
10546
+ if (await tags.exists(name)) {
10547
+ await tags.delete(name);
10548
+ }
10549
+ await tags.create(name, prevTags[name]);
10550
+ inversChanges.tags[name] = {
10551
+ action: 'moved',
10552
+ from: targetTags[name],
10553
+ to: prevTags[name],
10554
+ };
10555
+ }
10556
+ }
10557
+
10558
+ // Check for deleted tags (existed before, don't exist after)
10559
+ for (const name of Object.keys(prevTags)) {
10560
+ if (!targetTags[name] && !(await tags.exists(name))) {
10561
+ // Tag was deleted - restore it
10562
+ await tags.create(name, prevTags[name]);
10563
+ inversChanges.tags[name] = {
10564
+ action: 'restored',
10565
+ to: prevTags[name],
10566
+ };
10567
+ }
10568
+ }
10569
+
8906
10570
  // Revert working copy change if it changed
8907
10571
  if (previousOp.view?.workingCopy !== targetOp.view?.workingCopy) {
8908
10572
  await workingCopy.load();
@@ -8917,6 +10581,7 @@ async function createJJ(options) {
8917
10581
  parents: [],
8918
10582
  view: {
8919
10583
  bookmarks: prevBookmarks,
10584
+ tags: prevTags,
8920
10585
  remoteBookmarks: previousOp.view?.remoteBookmarks || {},
8921
10586
  heads: previousOp.view?.heads || [],
8922
10587
  workingCopy: previousOp.view?.workingCopy,
@@ -9060,10 +10725,53 @@ async function createJJ(options) {
9060
10725
  timestamp: new Date().toISOString(),
9061
10726
  });
9062
10727
 
10728
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
10729
+ // reverse the squash (un-abandon source, restore dest's description
10730
+ // and content) — see issue #29.
10731
+ /** @type {Record<string, any>} */
10732
+ const changeSnapshot = {
10733
+ [source]: structuredClone(sourceChange),
10734
+ [destChangeId]: structuredClone(destChange),
10735
+ };
10736
+
9063
10737
  // Mark source as abandoned
9064
10738
  sourceChange.abandoned = true;
9065
10739
  await graph.updateChange(sourceChange);
9066
10740
 
10741
+ // Merge source's file content into dest (issue #30: squash() used to
10742
+ // only combine descriptions, leaving dest's tree untouched). Compute
10743
+ // what source itself actually changed relative to ITS OWN parent
10744
+ // (its "diff") and fold just that onto dest, rather than overwriting
10745
+ // dest's whole snapshot with source's — a path source never touched
10746
+ // (inherited unchanged from that parent) must not clobber a
10747
+ // dest-specific version of the same path.
10748
+ const sourceParentId = sourceChange.parents && sourceChange.parents[0];
10749
+ const sourceParentChange = sourceParentId ? await graph.getChange(sourceParentId) : null;
10750
+ const sourceBaseSnapshot = (sourceParentChange && sourceParentChange.fileSnapshot) || {};
10751
+ const sourceSnapshot = sourceChange.fileSnapshot || {};
10752
+ const mergedSnapshot = { ...(destChange.fileSnapshot || {}) };
10753
+ const touchedPaths = new Set([
10754
+ ...Object.keys(sourceBaseSnapshot),
10755
+ ...Object.keys(sourceSnapshot),
10756
+ ]);
10757
+ for (const filePath of touchedPaths) {
10758
+ const baseValue = sourceBaseSnapshot[filePath];
10759
+ const sourceValue = sourceSnapshot[filePath];
10760
+ if (sourceValue === baseValue) {
10761
+ // source never touched this path (inherited as-is from its own
10762
+ // parent) — leave whatever dest already had for it alone.
10763
+ continue;
10764
+ }
10765
+ if (sourceValue === undefined) {
10766
+ // source deleted a file it inherited from its parent.
10767
+ delete mergedSnapshot[filePath];
10768
+ } else {
10769
+ // source added or modified this path.
10770
+ mergedSnapshot[filePath] = sourceValue;
10771
+ }
10772
+ }
10773
+ destChange.fileSnapshot = mergedSnapshot;
10774
+
9067
10775
  // Update description of dest to indicate squash (middleware will sync to Git)
9068
10776
  destChange.description += `\n\n(squashed from ${source.slice(0, 8)})`;
9069
10777
  await graph.updateChange(destChange);
@@ -9105,6 +10813,7 @@ async function createJJ(options) {
9105
10813
  user: await getUserOplogInfo(),
9106
10814
  description: `squash ${source.slice(0, 8)} into ${destChangeId.slice(0, 8)}`,
9107
10815
  parents: [],
10816
+ changeSnapshot,
9108
10817
  view: {
9109
10818
  bookmarks: {},
9110
10819
  remoteBookmarks: {},
@@ -9183,15 +10892,29 @@ async function createJJ(options) {
9183
10892
  timestamp: new Date().toISOString(),
9184
10893
  });
9185
10894
 
10895
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
10896
+ // reverse the abandon (un-abandon this change, un-reparent children)
10897
+ // — see issue #29.
10898
+ /** @type {Record<string, any>} */
10899
+ const changeSnapshot = { [changeId]: structuredClone(change) };
10900
+
9186
10901
  change.abandoned = true;
9187
10902
  await graph.updateChange(change);
9188
10903
 
10904
+ // Re-parent children onto the abandoned change's own parent(s)
10905
+ // (issue #30: this used to leave children pointing at the now-
10906
+ // abandoned commit). Matches real `jj abandon`: the abandoned
10907
+ // change is elided from the graph and its children attach directly
10908
+ // to whatever it was attached to.
10909
+ await reparentChildrenOnto(changeId, change.parents, changeSnapshot);
10910
+
9189
10911
  // Record operation
9190
10912
  await oplog.recordOperation({
9191
10913
  timestamp: new Date().toISOString(),
9192
10914
  user: { name: user.name, email: user.email, hostname: 'localhost' },
9193
10915
  description: `abandon change ${changeId.slice(0, 8)}`,
9194
10916
  parents: [],
10917
+ changeSnapshot,
9195
10918
  view: {
9196
10919
  bookmarks: {},
9197
10920
  remoteBookmarks: {},
@@ -9216,6 +10939,206 @@ async function createJJ(options) {
9216
10939
  return change;
9217
10940
  },
9218
10941
 
10942
+ /**
10943
+ * Converge divergent copies of a change (matches `jj converge`, jj
10944
+ * v0.45.0+ — issue #32, extended for N-way per the isomorphic-jj v0.45
10945
+ * catch-up follow-up) — resolves ANY number of divergent copies of one
10946
+ * changeId in a single call. Real jj's own CLI currently only
10947
+ * converges one change-id per invocation (an explicit TODO in its own
10948
+ * source as of v0.45.1: "consider adding logic to deal with more than
10949
+ * one divergent change-id in one invocation"), which is what fixed
10950
+ * this package's own changeId-at-a-time API design — but its
10951
+ * underlying engine (`TruncatedEvolutionGraph`/`find_divergent_changes`)
10952
+ * already resolves every divergent revision of THAT one change-id
10953
+ * together, not just a pair. This generalizes the same per-path rule
10954
+ * accordingly: two-way "does the other side's value differ from base"
10955
+ * becomes N-way "how many DISTINCT values, across every copy, differ
10956
+ * from base" — 0 means nobody touched it, 1 means exactly one copy (or
10957
+ * several copies agreeing on the same new value) changed it and that
10958
+ * value wins, 2+ is a genuine conflict between that many copies.
10959
+ *
10960
+ * A change is divergent when more than one visible commit shares its
10961
+ * change id (see the `divergent()` revset, and
10962
+ * ChangeGraph.addDivergentCopy() for how a divergent copy gets added
10963
+ * in the first place — nothing in this library creates one on its
10964
+ * own yet; this resolves one once something has, e.g. a future git
10965
+ * fetch/concurrent-operation integration).
10966
+ *
10967
+ * Non-interactive-mode convention, matching this package's other
10968
+ * ambiguous-outcome APIs: like merge(), a genuine per-path conflict is
10969
+ * returned as data (`{ resolved: false, conflicts }`), not thrown —
10970
+ * you get a result back either way and decide what to do with
10971
+ * unresolved conflicts. What DOES throw is anything that isn't even a
10972
+ * well-formed convergence attempt (no divergence to resolve at all) —
10973
+ * the same category as merge()'s own `MERGE_ERROR` throw for "no
10974
+ * common ancestor found".
10975
+ *
10976
+ * @param {Record<string, any>|string} args - `{ changeId }`, or a bare changeId string
10977
+ * @returns {Promise<Object>} `{ changeId, resolved, conflicts, commitId? }`
10978
+ */
10979
+ async converge(args) {
10980
+ const changeId = typeof args === 'string' ? args : args && (args.changeId || args.change);
10981
+ if (!changeId || typeof changeId !== 'string') {
10982
+ throw new JJError('INVALID_ARGUMENT', 'Missing changeId argument', {
10983
+ suggestion: 'Provide the divergent changeId to converge: { changeId: "..." }',
10984
+ });
10985
+ }
10986
+
10987
+ await graph.load();
10988
+ await userConfig.load();
10989
+ const siblings = graph.getDivergentSiblings(changeId);
10990
+
10991
+ if (siblings.length < 2) {
10992
+ throw new JJError(
10993
+ 'NOT_DIVERGENT',
10994
+ `Change ${changeId} has no divergent copies to converge`,
10995
+ {
10996
+ changeId,
10997
+ suggestion:
10998
+ 'converge() is only meaningful for a changeId the divergent() revset reports',
10999
+ }
11000
+ );
11001
+ }
11002
+
11003
+ // The primary copy is whichever one getChange() resolves (stored
11004
+ // under the plain changeId key); every other one is a divergent copy
11005
+ // added via addDivergentCopy(). No cap on how many — see the doc
11006
+ // comment above.
11007
+ const primary = /** @type {any} */ (await graph.getChange(changeId));
11008
+ const others = siblings.filter((/** @type {any} */ s) => s.commitId !== primary.commitId);
11009
+
11010
+ // Base for the N-way merge: what this change looked like before it
11011
+ // diverged. This package doesn't retain historical snapshots (only
11012
+ // current content), so — same pragmatic choice rebase() makes for
11013
+ // "old base" — use the shared parent's current content; divergent
11014
+ // copies realistically share the same parent, since they're all
11015
+ // rewrites of one prior position in history, not unrelated commits
11016
+ // that happen to collide. Applied uniformly to every copy, not
11017
+ // per-copy — the same simplification the original two-way version
11018
+ // made, just not newly introduced by going N-way.
11019
+ const baseParentId = primary.parents && primary.parents[0];
11020
+ const baseParentChange = baseParentId ? await graph.getChange(baseParentId) : null;
11021
+ const baseFiles = new Map(
11022
+ Object.entries((baseParentChange && baseParentChange.fileSnapshot) || {})
11023
+ );
11024
+
11025
+ // Every copy (primary + others), each as its own file map, paired
11026
+ // with its commitId for conflict reporting.
11027
+ const copies = [primary, ...others].map((/** @type {any} */ c) => ({
11028
+ commitId: c.commitId,
11029
+ files: new Map(Object.entries(c.fileSnapshot || {})),
11030
+ }));
11031
+
11032
+ await conflicts.load();
11033
+ const conflictsSnapshot = {
11034
+ conflicts: Object.fromEntries(conflicts.conflicts),
11035
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
11036
+ };
11037
+
11038
+ const allPaths = new Set([
11039
+ ...baseFiles.keys(),
11040
+ ...copies.flatMap((c) => [...c.files.keys()]),
11041
+ ]);
11042
+
11043
+ /** @type {Record<string, string>} */
11044
+ const mergedSnapshot = {};
11045
+ /** @type {any[]} */
11046
+ const detectedConflicts = [];
11047
+
11048
+ for (const filePath of allPaths) {
11049
+ const baseValue = baseFiles.get(filePath);
11050
+ // Every DISTINCT value across all copies that differs from base,
11051
+ // each paired with (one of) the commitId(s) that has it — mirrors
11052
+ // ConflictModel._detectPathConflict's two-way "no conflict" rules,
11053
+ // generalized: 0 distinct changed values means nobody touched it
11054
+ // (base wins, i.e. nothing to write), 1 means only one side (or
11055
+ // several copies agreeing) changed it (that value wins), 2+ is a
11056
+ // genuine conflict.
11057
+ const changed = new Map();
11058
+ for (const copy of copies) {
11059
+ const value = copy.files.get(filePath);
11060
+ if (value !== baseValue && !changed.has(value)) {
11061
+ changed.set(value, copy.commitId);
11062
+ }
11063
+ }
11064
+
11065
+ if (changed.size === 0) {
11066
+ if (baseValue !== undefined) mergedSnapshot[filePath] = baseValue;
11067
+ } else if (changed.size === 1) {
11068
+ const [[value]] = changed;
11069
+ if (value !== undefined) mergedSnapshot[filePath] = value;
11070
+ } else {
11071
+ const versions = [...changed.entries()].map(([content, commitId]) => ({
11072
+ commitId,
11073
+ content,
11074
+ }));
11075
+ detectedConflicts.push(conflicts.createNWayConflict(filePath, baseValue, versions));
11076
+ }
11077
+ }
11078
+
11079
+ if (detectedConflicts.length > 0) {
11080
+ // Matches real jj: non-interactive mode aborts automatic
11081
+ // resolution rather than guessing — but per this package's own
11082
+ // merge() convention, that's reported back as data, not thrown.
11083
+ for (const conflict of detectedConflicts) {
11084
+ await conflicts.addConflict(conflict);
11085
+ }
11086
+ await oplog.recordOperation({
11087
+ timestamp: new Date().toISOString(),
11088
+ user: await getUserOplogInfo(),
11089
+ description: `converge ${changeId.slice(0, 8)} (unresolved)`,
11090
+ parents: [],
11091
+ conflictsSnapshot,
11092
+ view: {
11093
+ bookmarks: {},
11094
+ remoteBookmarks: {},
11095
+ heads: [],
11096
+ workingCopy: workingCopy.getCurrentChangeId(),
11097
+ },
11098
+ });
11099
+ return { changeId, resolved: false, conflicts: detectedConflicts };
11100
+ }
11101
+
11102
+ const user = /** @type {any} */ (userConfig.getUser());
11103
+ const changeSnapshot = { [changeId]: structuredClone(primary) };
11104
+ const otherIds = others.map((/** @type {any} */ o) => o.commitId.slice(0, 8)).join(', ');
11105
+
11106
+ const convergedChange = {
11107
+ ...primary,
11108
+ commitId: randomHex(20),
11109
+ fileSnapshot: mergedSnapshot,
11110
+ description: primary.description + `\n\n(converged divergent copies: ${otherIds})`,
11111
+ timestamp: new Date().toISOString(),
11112
+ committer: {
11113
+ name: user.name,
11114
+ email: user.email,
11115
+ timestamp: new Date().toISOString(),
11116
+ },
11117
+ divergent: false,
11118
+ };
11119
+ await graph.updateChange(convergedChange);
11120
+ for (const other of others) {
11121
+ await graph.deleteDivergentCopy(changeId, other.commitId);
11122
+ }
11123
+
11124
+ await oplog.recordOperation({
11125
+ timestamp: new Date().toISOString(),
11126
+ user: await getUserOplogInfo(),
11127
+ description: `converge ${changeId.slice(0, 8)}`,
11128
+ parents: [],
11129
+ changeSnapshot,
11130
+ conflictsSnapshot,
11131
+ view: {
11132
+ bookmarks: {},
11133
+ remoteBookmarks: {},
11134
+ heads: [changeId],
11135
+ workingCopy: workingCopy.getCurrentChangeId(),
11136
+ },
11137
+ });
11138
+
11139
+ return { changeId, resolved: true, conflicts: [], commitId: convergedChange.commitId };
11140
+ },
11141
+
9219
11142
  /**
9220
11143
  * Backout a change - create a new change that reverses the effect of the specified change
9221
11144
  *
@@ -10677,7 +12600,7 @@ async function createJJ(options) {
10677
12600
  .split('/')
10678
12601
  .pop()
10679
12602
  .replace(/\.git$/, '');
10680
- const fullCloneDir = path.join(dir, cloneDir);
12603
+ const fullCloneDir = join(dir, cloneDir);
10681
12604
 
10682
12605
  // Clone using isomorphic-git
10683
12606
  await git.clone({
@@ -11055,8 +12978,14 @@ async function createJJ(options) {
11055
12978
  * @returns {Promise<Object>} Repository root information
11056
12979
  */
11057
12980
  async root() {
11058
- // Return the directory containing .git
11059
- const gitDir = path.join(dir, '.git');
12981
+ // `dir` is a real host filesystem path (unlike the repo-relative
12982
+ // git/jj paths `path` — posix-path.js — is for), so it needs the
12983
+ // host's own separator here, not always '/': on Windows Node's real
12984
+ // `fs` still resolves a forward-slash-joined path fine, but this
12985
+ // method hands the string back to the caller as data, and jj's own
12986
+ // `git root` returns a platform-native path there.
12987
+ const isWin32 = typeof process !== 'undefined' && process.platform === 'win32';
12988
+ const gitDir = isWin32 ? `${dir.replace(/[\\/]+$/, '')}\\.git` : join(dir, '.git');
11060
12989
 
11061
12990
  try {
11062
12991
  await fs.promises.access(gitDir);
@@ -11135,7 +13064,7 @@ async function createJJ(options) {
11135
13064
  const wcFiles = await workingCopy.listFiles();
11136
13065
  for (const file of wcFiles) {
11137
13066
  try {
11138
- const content = await fs.promises.readFile(path.join(dir, file), 'utf-8');
13067
+ const content = await fs.promises.readFile(join(dir, file), 'utf8');
11139
13068
  leftFiles.set(file, content);
11140
13069
  } catch (error) {
11141
13070
  // File might be deleted or binary - throw error for explicit handling
@@ -11278,7 +13207,7 @@ async function createJJ(options) {
11278
13207
  }
11279
13208
 
11280
13209
  // Write resolved content to file
11281
- await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf-8');
13210
+ await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
11282
13211
 
11283
13212
  // Mark conflict as resolved
11284
13213
  await conflicts.resolveConflict(args.conflictId, 'manual');
@@ -11321,7 +13250,7 @@ async function createJJ(options) {
11321
13250
  const resolvedContent = _resolveWithStrategy(conflict, args.strategy);
11322
13251
 
11323
13252
  // Write resolved content
11324
- await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf-8');
13253
+ await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
11325
13254
 
11326
13255
  // Mark as resolved
11327
13256
  await conflicts.resolveConflict(conflict.conflictId, args.strategy);
@@ -11354,11 +13283,18 @@ async function createJJ(options) {
11354
13283
  throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
11355
13284
  }
11356
13285
 
11357
- // Generate standard conflict markers
13286
+ // Generate standard conflict markers. Force a newline before each
13287
+ // boundary marker whenever there's non-empty content that doesn't
13288
+ // already end in one — otherwise the content fuses onto the
13289
+ // `=======`/`>>>>>>> theirs` marker, producing a corrupted/unparseable
13290
+ // line (see issue #15). Empty sides (e.g. a modify-delete conflict)
13291
+ // intentionally get no extra blank line before the marker.
11358
13292
  const ours = conflict.sides.left || '';
11359
13293
  const theirs = conflict.sides.right || '';
13294
+ const oursSep = ours && !ours.endsWith('\n') ? '\n' : '';
13295
+ const theirsSep = theirs && !theirs.endsWith('\n') ? '\n' : '';
11360
13296
 
11361
- return `<<<<<<< ours\n${ours}=======\n${theirs}>>>>>>> theirs`;
13297
+ return `<<<<<<< ours\n${ours}${oursSep}=======\n${theirs}${theirsSep}>>>>>>> theirs`;
11362
13298
  },
11363
13299
 
11364
13300
  /**
@@ -11445,10 +13381,17 @@ async function createJJ(options) {
11445
13381
  * Searches the tracked files of a change (defaults to the working copy)
11446
13382
  * for lines matching a pattern. Following jj v0.41, the pattern is treated
11447
13383
  * as a regular expression by default; pass `{ kind: 'substring' }` for a
11448
- * literal search.
13384
+ * literal search. isomorphic-jj has always returned the matched line
13385
+ * (and its 1-based `lineNumber`) alongside the path, which is what jj
13386
+ * v0.44 made the CLI's new default (`jj file search` used to print only
13387
+ * file paths). Pass `{ nameOnly: true }` for the pre-v0.44 CLI
13388
+ * behavior — a deduplicated list of matching file paths, matching the
13389
+ * new `--name-only` flag.
11449
13390
  *
11450
- * @param {Record<string, any>} args - Arguments
11451
- * @returns {Promise<Array<{path: string, lineNumber: number, line: string}>>}
13391
+ * @param {Record<string, any>} args - Arguments. Pass `nameOnly: true`
13392
+ * to return only unique matching file paths instead of per-line
13393
+ * matches (jj v0.44 `--name-only`)
13394
+ * @returns {Promise<Array<{path: string, lineNumber: number, line: string}>|Array<string>>}
11452
13395
  */
11453
13396
  async search(args) {
11454
13397
  if (!args || !args.pattern) {
@@ -11499,6 +13442,10 @@ async function createJJ(options) {
11499
13442
  }
11500
13443
  });
11501
13444
  }
13445
+
13446
+ if (args.nameOnly) {
13447
+ return Array.from(new Set(results.map((r) => r.path)));
13448
+ }
11502
13449
  return results;
11503
13450
  },
11504
13451
 
@@ -11625,7 +13572,7 @@ async function createJJ(options) {
11625
13572
  );
11626
13573
  }
11627
13574
 
11628
- const fullPath = path.join(dir, args.path);
13575
+ const fullPath = join(dir, args.path);
11629
13576
 
11630
13577
  // Convert string mode to number if needed
11631
13578
  const mode = typeof args.mode === 'string' ? parseInt(args.mode, 8) : args.mode;
@@ -11736,14 +13683,14 @@ async function createJJ(options) {
11736
13683
  if (change.fileSnapshot) {
11737
13684
  for (const [filePath, content] of Object.entries(change.fileSnapshot)) {
11738
13685
  try {
11739
- const fullPath = path.join(args.path, filePath);
13686
+ const fullPath = join(args.path, filePath);
11740
13687
 
11741
13688
  // Ensure directory exists
11742
13689
  const pathParts = filePath.split('/');
11743
13690
  if (pathParts.length > 1) {
11744
13691
  const dirPath = pathParts.slice(0, -1).join('/');
11745
- const fullDirPath = path.join(args.path, dirPath);
11746
- await fs.promises.mkdir(fullDirPath, { recursive: true });
13692
+ const fullDirPath = join(args.path, dirPath);
13693
+ await mkdirp(fs, fullDirPath);
11747
13694
  }
11748
13695
 
11749
13696
  // Write file content
@@ -12580,6 +14527,10 @@ async function createJJ(options) {
12580
14527
  view: {
12581
14528
  bookmarks: {},
12582
14529
  remoteBookmarks: {},
14530
+ // Tag delta for this operation (mirrors how `bookmarks` records
14531
+ // its delta above), so operations.revert() can diff it — see
14532
+ // issue #12.
14533
+ tags: { [args.name]: changeId },
12583
14534
  heads: [],
12584
14535
  workingCopy: workingCopy.getCurrentChangeId(),
12585
14536
  },
@@ -12625,6 +14576,7 @@ async function createJJ(options) {
12625
14576
  view: {
12626
14577
  bookmarks: {},
12627
14578
  remoteBookmarks: {},
14579
+ tags: { [args.name]: changeId },
12628
14580
  heads: [],
12629
14581
  workingCopy: workingCopy.getCurrentChangeId(),
12630
14582
  },
@@ -12634,10 +14586,11 @@ async function createJJ(options) {
12634
14586
  },
12635
14587
 
12636
14588
  /**
12637
- * List all tags
14589
+ * List all tags. Each entry also carries a `tracking: { remote, ref }`
14590
+ * field when the tag is tracking a remote (jj v0.44 `jj tag track`).
12638
14591
  *
12639
14592
  * @param {Record<string, any>} [args] - Optional filter arguments
12640
- * @returns {Promise<Array<{name: string, changeId: string}>>} List of tags
14593
+ * @returns {Promise<Array<Record<string, any>>>} List of tags
12641
14594
  */
12642
14595
  async list(args) {
12643
14596
  const pattern = args?.pattern;
@@ -12669,6 +14622,7 @@ async function createJJ(options) {
12669
14622
  view: {
12670
14623
  bookmarks: {},
12671
14624
  remoteBookmarks: {},
14625
+ tags: {},
12672
14626
  heads: [],
12673
14627
  workingCopy: workingCopy.getCurrentChangeId(),
12674
14628
  },
@@ -12676,6 +14630,94 @@ async function createJJ(options) {
12676
14630
 
12677
14631
  return { deleted: args.name };
12678
14632
  },
14633
+
14634
+ /**
14635
+ * Associate a local tag with a remote tag so it is updated on fetch
14636
+ * (matches `jj tag track`, new in jj v0.44).
14637
+ *
14638
+ * jj v0.44 made tags remote-trackable just like bookmarks: fetched
14639
+ * tags are tracked by default, and `jj tag track`/`untrack` let you
14640
+ * opt individual tags in or out by hand. isomorphic-jj doesn't (yet)
14641
+ * auto-import remote tags during `git.fetch()` — see CHANGELOG — so
14642
+ * this records tracking intent/state the same way `bookmark.track()`
14643
+ * does, without changing fetch's own behavior.
14644
+ *
14645
+ * @param {Record<string, any>} args - Tag arguments: `{ name, remote }`
14646
+ * (`name` is the local tag name to track; `remote` defaults to `'origin'`)
14647
+ * @returns {Promise<{name: string, remote: string, tracking: boolean}>}
14648
+ */
14649
+ async track(args) {
14650
+ if (!args || !args.name) {
14651
+ throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
14652
+ suggestion: 'Provide tag name: { name: "v1.0.0", remote: "origin" }',
14653
+ });
14654
+ }
14655
+
14656
+ const remote = args.remote || 'origin';
14657
+ const remoteTagName = `${remote}/${args.name}`;
14658
+
14659
+ const tagMap = await tags.load();
14660
+ /** @type {Record<string, any>} */
14661
+ const tracking = tags.tracking || {};
14662
+ tracking[args.name] = { remote, remoteName: args.name };
14663
+ tags.tracking = tracking;
14664
+ await tags.save(tagMap);
14665
+
14666
+ // Record operation
14667
+ await oplog.recordOperation({
14668
+ timestamp: new Date().toISOString(),
14669
+ user: await getUserOplogInfo(),
14670
+ description: `tag track ${args.name} from ${remote}`,
14671
+ parents: [],
14672
+ view: {
14673
+ bookmarks: {},
14674
+ remoteBookmarks: { [remoteTagName]: true },
14675
+ heads: [],
14676
+ workingCopy: workingCopy.getCurrentChangeId(),
14677
+ },
14678
+ });
14679
+
14680
+ return { name: args.name, remote, tracking: true };
14681
+ },
14682
+
14683
+ /**
14684
+ * Stop tracking a remote tag (matches `jj tag untrack`, new in jj v0.44).
14685
+ *
14686
+ * @param {Record<string, any>} args - Tag arguments: `{ name }`, the
14687
+ * local tag name to stop tracking
14688
+ * @returns {Promise<{name: string, tracking: boolean, wasTracking: boolean}>}
14689
+ */
14690
+ async untrack(args) {
14691
+ if (!args || !args.name) {
14692
+ throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
14693
+ suggestion: 'Provide tag name: { name: "v1.0.0" }',
14694
+ });
14695
+ }
14696
+
14697
+ const tagMap = await tags.load();
14698
+ /** @type {Record<string, any>} */
14699
+ const tracking = tags.tracking || {};
14700
+ const wasTracking = tracking[args.name];
14701
+ delete tracking[args.name];
14702
+ tags.tracking = tracking;
14703
+ await tags.save(tagMap);
14704
+
14705
+ // Record operation
14706
+ await oplog.recordOperation({
14707
+ timestamp: new Date().toISOString(),
14708
+ user: await getUserOplogInfo(),
14709
+ description: `tag untrack ${args.name}`,
14710
+ parents: [],
14711
+ view: {
14712
+ bookmarks: {},
14713
+ remoteBookmarks: {},
14714
+ heads: [],
14715
+ workingCopy: workingCopy.getCurrentChangeId(),
14716
+ },
14717
+ });
14718
+
14719
+ return { name: args.name, tracking: false, wasTracking: !!wasTracking };
14720
+ },
12679
14721
  },
12680
14722
 
12681
14723
  /**