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