isomorphic-jj 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,8 +1,5 @@
1
- import path from 'path';
2
1
  import git from 'isomorphic-git';
3
- import protobuf from 'protobufjs';
4
- import { fileURLToPath } from 'url';
5
- import crypto$1 from 'crypto';
2
+ import protobuf from 'protobufjs/light.js';
6
3
 
7
4
  /**
8
5
  * JJError - Custom error class for isomorphic-jj
@@ -90,6 +87,48 @@ class KeyedMutex {
90
87
  }
91
88
  }
92
89
 
90
+ /**
91
+ * Recursive directory creation that works across filesystem backends.
92
+ *
93
+ * Why not `fs.promises.mkdir(path, { recursive: true })`: that's a Node
94
+ * `fs` feature. `@isomorphic-git/lightning-fs` (the filesystem this library
95
+ * documents for browser use — see issue #28) implements `mkdir()` but
96
+ * silently ignores the `recursive` option and throws `ENOENT` if any
97
+ * intermediate directory is missing, so code that assumed Node's recursive
98
+ * mkdir semantics would work identically against LightningFS actually
99
+ * broke the very first real repo init in a browser. This walks the path
100
+ * one segment at a time and creates each directory that doesn't exist yet,
101
+ * which is correct against both Node's `fs` and LightningFS (and is a
102
+ * harmless no-op-per-segment against Node's `fs` too).
103
+ *
104
+ * @param {any} fs - Filesystem implementation (isomorphic-git compatible;
105
+ * needs `fs.promises.mkdir(path)`)
106
+ * @param {string} dirPath - Absolute, POSIX-style directory path to create
107
+ * @returns {Promise<void>}
108
+ */
109
+ async function mkdirp(fs, dirPath) {
110
+ const isAbsolute = dirPath.charCodeAt(0) === 47; // '/'
111
+ const prefix = isAbsolute ? '/' : '';
112
+ const segments = dirPath.split('/').filter(Boolean);
113
+
114
+ // Compare against `current === ''` (not a truthiness check) below: the
115
+ // first accumulated segment of an absolute path is itself just `/x`,
116
+ // which parses fine, but a naive `current ? ... : ...` truthiness check
117
+ // would treat the *string* `''` the same as "unset" forever and never
118
+ // prepend the leading `/`.
119
+ let current = '';
120
+ for (const segment of segments) {
121
+ current = current === '' ? prefix + segment : `${current}/${segment}`;
122
+ try {
123
+ await fs.promises.mkdir(current);
124
+ } catch (err) {
125
+ // Already exists — fine, that's exactly what "recursive" means.
126
+ if (err && (err.code === 'EEXIST' || err.code === 'EISDIR')) continue;
127
+ throw err;
128
+ }
129
+ }
130
+ }
131
+
93
132
  /**
94
133
  * Storage Manager for isomorphic-jj
95
134
  *
@@ -130,17 +169,17 @@ class Storage {
130
169
  async init() {
131
170
  try {
132
171
  // Create main directories
133
- await this.fs.promises.mkdir(this.jjDir, { recursive: true });
134
- await this.fs.promises.mkdir(this.repoDir, { recursive: true });
135
- await this.fs.promises.mkdir(this.workingCopyDir, { recursive: true });
172
+ await mkdirp(this.fs, this.jjDir);
173
+ await mkdirp(this.fs, this.repoDir);
174
+ await mkdirp(this.fs, this.workingCopyDir);
136
175
 
137
176
  // Create repo subdirectories
138
- await this.fs.promises.mkdir(`${this.repoDir}/store`, { recursive: true });
139
- await this.fs.promises.mkdir(`${this.repoDir}/op_log`, { recursive: true });
140
- await this.fs.promises.mkdir(`${this.repoDir}/conflicts`, { recursive: true });
177
+ await mkdirp(this.fs, `${this.repoDir}/store`);
178
+ await mkdirp(this.fs, `${this.repoDir}/op_log`);
179
+ await mkdirp(this.fs, `${this.repoDir}/conflicts`);
141
180
 
142
181
  // Create default workspace directory
143
- await this.fs.promises.mkdir(`${this.workingCopyDir}/default`, { recursive: true });
182
+ await mkdirp(this.fs, `${this.workingCopyDir}/default`);
144
183
  } catch (error) {
145
184
  throw new JJError(
146
185
  'STORAGE_INIT_FAILED',
@@ -217,7 +256,7 @@ class Storage {
217
256
  try {
218
257
  // Ensure parent directory exists
219
258
  const dirPath = fullPath.substring(0, fullPath.lastIndexOf('/'));
220
- await this.fs.promises.mkdir(dirPath, { recursive: true });
259
+ await mkdirp(this.fs, dirPath);
221
260
 
222
261
  // Write to temp file
223
262
  await this.fs.promises.writeFile(tmpPath, jsonData, 'utf8');
@@ -473,6 +512,75 @@ function validateBookmarkName(name) {
473
512
  }
474
513
  }
475
514
 
515
+ /**
516
+ * ID Generation utilities for isomorphic-jj
517
+ *
518
+ * Generates stable change IDs and operation IDs using cryptographically secure random values.
519
+ */
520
+
521
+ /**
522
+ * Generate `byteLength` cryptographically random bytes as a lowercase hex string.
523
+ *
524
+ * Uses the standard Web Crypto API (`crypto.getRandomValues`), available as
525
+ * a global in both Node.js (18.14+) and every browser — unlike Node's
526
+ * `crypto.randomBytes`, which requires `import 'crypto'` and does not exist
527
+ * in a browser bundle (see issue #28).
528
+ *
529
+ * @param {number} byteLength - Number of random bytes to generate
530
+ * @returns {string} `byteLength * 2`-character lowercase hex string
531
+ */
532
+ function randomHex(byteLength) {
533
+ const bytes = crypto.getRandomValues(new Uint8Array(byteLength));
534
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
535
+ }
536
+
537
+ /**
538
+ * Generate a stable change ID
539
+ *
540
+ * Uses 128-bit cryptographically random value encoded as 32-character hex string.
541
+ * Collision probability is negligible (2^128 space).
542
+ *
543
+ * @returns {string} 32-character lowercase hex string
544
+ */
545
+ function generateChangeId() {
546
+ return randomHex(16);
547
+ }
548
+
549
+ /**
550
+ * Generate an operation ID from operation content
551
+ *
552
+ * Uses SHA-256 hash of operation content for integrity verification.
553
+ *
554
+ * @param {Object.<string, any>} operation - Operation data
555
+ * @returns {Promise<string>} 64-character lowercase hex string (SHA-256)
556
+ */
557
+ async function generateOperationId(operation) {
558
+ const content = JSON.stringify({
559
+ timestamp: operation.timestamp,
560
+ user: operation.user,
561
+ description: operation.description,
562
+ parents: operation.parents,
563
+ view: operation.view,
564
+ });
565
+
566
+ const encoder = new TextEncoder();
567
+ const data = encoder.encode(content);
568
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
569
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
570
+ return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
571
+ }
572
+
573
+ /**
574
+ * Generate a generic ID with optional prefix
575
+ *
576
+ * @param {string} prefix - Optional prefix (e.g., 'conflict', 'bookmark')
577
+ * @returns {string} ID string
578
+ */
579
+ function generateId(prefix = '') {
580
+ const id = randomHex(16);
581
+ return prefix ? `${prefix}-${id}` : id;
582
+ }
583
+
476
584
  /**
477
585
  * ChangeGraph - Manages the change graph with stable change IDs
478
586
  *
@@ -568,6 +676,68 @@ class ChangeGraph {
568
676
  await this.save();
569
677
  }
570
678
 
679
+ /**
680
+ * Add a DIVERGENT copy of an existing change: another commit that also
681
+ * claims `change.changeId` (see issue #32 / the `divergent()` revset —
682
+ * "multiple visible commits sharing one change id"). `addChange()`
683
+ * can't represent this: `nodes` is a `Map<changeId, change>`, one entry
684
+ * per key, by design — every other API (getChange, getParents,
685
+ * getChildren, ...) keys on plain changeId and would be ambiguous if
686
+ * that stopped being unique.
687
+ *
688
+ * Divergent copies are therefore stored under a synthetic internal key
689
+ * (`${changeId}\0${commitId}`) so the `nodes` Map stays a true map, but
690
+ * each copy's *own* `changeId` field is unchanged — so `getAll()` (and
691
+ * therefore the existing `divergent()` revset filter, which counts
692
+ * occurrences of `c.changeId` across `getAll()`) sees both/all of them
693
+ * and correctly reports the change as divergent. `getChange(changeId)`
694
+ * deliberately keeps resolving to just the primary copy (the one
695
+ * actually stored under the plain `changeId` key) — for anything that
696
+ * needs every copy, see getDivergentSiblings().
697
+ *
698
+ * @param {Record<string, any>} change - The divergent copy (must have
699
+ * the same `changeId` as an existing change, and its own `commitId`)
700
+ */
701
+ async addDivergentCopy(change) {
702
+ validateChangeId(change.changeId);
703
+
704
+ if (!this.nodes.has(change.changeId)) {
705
+ throw new JJError(
706
+ 'CHANGE_NOT_FOUND',
707
+ `Cannot add a divergent copy of ${change.changeId}: no existing change with that id`,
708
+ { changeId: change.changeId, suggestion: 'Use addChange() to create the first copy' }
709
+ );
710
+ }
711
+
712
+ const key = `${change.changeId}\0${change.commitId}`;
713
+ if (this.nodes.has(key)) {
714
+ throw new JJError(
715
+ 'CHANGE_EXISTS',
716
+ `Change ${change.changeId} already has a divergent copy with commit ${change.commitId}`,
717
+ { changeId: change.changeId, commitId: change.commitId }
718
+ );
719
+ }
720
+
721
+ change.divergent = true;
722
+ this.nodes.set(key, change);
723
+ this.commitIndex.set(change.commitId, change.changeId);
724
+
725
+ await this.save();
726
+ }
727
+
728
+ /**
729
+ * Get every visible copy of a (possibly divergent) change id — just the
730
+ * one copy in the common case, or the primary plus every divergent copy
731
+ * added via addDivergentCopy().
732
+ *
733
+ * @param {string} changeId - Change ID
734
+ * @returns {Array<any>} All copies sharing this changeId, primary first
735
+ */
736
+ getDivergentSiblings(changeId) {
737
+ validateChangeId(changeId);
738
+ return this.getAll().filter((change) => change.changeId === changeId);
739
+ }
740
+
571
741
  /**
572
742
  * Get a change by ID
573
743
  *
@@ -679,6 +849,65 @@ class ChangeGraph {
679
849
  await this.save();
680
850
  }
681
851
 
852
+ /**
853
+ * Remove one divergent copy added via addDivergentCopy() — e.g. after
854
+ * converge() (issue #32) resolves a divergence and only needs to drop
855
+ * the now-superseded copy. The *primary* copy (stored under the plain
856
+ * `changeId` key) is never touched by this — use deleteChange() (or
857
+ * updateChange() to overwrite it with a resolved result) for that.
858
+ *
859
+ * @param {string} changeId
860
+ * @param {string} commitId - The divergent copy's own commit id
861
+ * @returns {Promise<boolean>} Whether a copy was actually removed
862
+ */
863
+ async deleteDivergentCopy(changeId, commitId) {
864
+ validateChangeId(changeId);
865
+
866
+ const key = `${changeId}\0${commitId}`;
867
+ const change = this.nodes.get(key);
868
+ if (!change) {
869
+ return false;
870
+ }
871
+
872
+ this.nodes.delete(key);
873
+ if (this.commitIndex.get(commitId) === changeId) {
874
+ this.commitIndex.delete(commitId);
875
+ }
876
+
877
+ await this.save();
878
+ return true;
879
+ }
880
+
881
+ /**
882
+ * Remove a change from the graph outright.
883
+ *
884
+ * Used to reverse an addChange() — e.g. undoing an operation that created
885
+ * a change (new(), or squash()'s synthetic empty working-copy change) —
886
+ * where restoring a "before" snapshot doesn't apply because there was no
887
+ * "before": the change simply didn't exist yet. A soft-delete (setting
888
+ * `abandoned: true` via updateChange()) is NOT the same thing and is used
889
+ * separately for abandon() itself.
890
+ *
891
+ * @param {string} changeId - Change ID to remove
892
+ * @returns {Promise<boolean>} Whether a change was actually removed
893
+ */
894
+ async deleteChange(changeId) {
895
+ validateChangeId(changeId);
896
+
897
+ const change = this.nodes.get(changeId);
898
+ if (!change) {
899
+ return false;
900
+ }
901
+
902
+ this.nodes.delete(changeId);
903
+ if (this.commitIndex.get(change.commitId) === changeId) {
904
+ this.commitIndex.delete(change.commitId);
905
+ }
906
+
907
+ await this.save();
908
+ return true;
909
+ }
910
+
682
911
  /**
683
912
  * Get all ancestors of a change (recursive parent traversal)
684
913
  *
@@ -716,10 +945,9 @@ class ChangeGraph {
716
945
  * @returns {Promise<Record<string, any>>} Created change object
717
946
  */
718
947
  async createChange(params = {}) {
719
- const crypto = await import('crypto');
720
- const changeId = params.changeId || crypto.randomBytes(16).toString('hex');
721
- const commitId = params.commitId || crypto.randomBytes(20).toString('hex');
722
- const tree = params.tree || crypto.randomBytes(20).toString('hex');
948
+ const changeId = params.changeId || randomHex(16);
949
+ const commitId = params.commitId || randomHex(20);
950
+ const tree = params.tree || randomHex(20);
723
951
  const timestamp = params.timestamp || new Date().toISOString();
724
952
 
725
953
  /** @type {Record<string, any>} */
@@ -1183,61 +1411,6 @@ class WorkingCopy {
1183
1411
  }
1184
1412
  }
1185
1413
 
1186
- /**
1187
- * ID Generation utilities for isomorphic-jj
1188
- *
1189
- * Generates stable change IDs and operation IDs using cryptographically secure random values.
1190
- */
1191
-
1192
- /**
1193
- * Generate a stable change ID
1194
- *
1195
- * Uses 128-bit cryptographically random value encoded as 32-character hex string.
1196
- * Collision probability is negligible (2^128 space).
1197
- *
1198
- * @returns {string} 32-character lowercase hex string
1199
- */
1200
- function generateChangeId() {
1201
- const bytes = crypto.getRandomValues(new Uint8Array(16));
1202
- return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
1203
- }
1204
-
1205
- /**
1206
- * Generate an operation ID from operation content
1207
- *
1208
- * Uses SHA-256 hash of operation content for integrity verification.
1209
- *
1210
- * @param {Object.<string, any>} operation - Operation data
1211
- * @returns {Promise<string>} 64-character lowercase hex string (SHA-256)
1212
- */
1213
- async function generateOperationId(operation) {
1214
- const content = JSON.stringify({
1215
- timestamp: operation.timestamp,
1216
- user: operation.user,
1217
- description: operation.description,
1218
- parents: operation.parents,
1219
- view: operation.view,
1220
- });
1221
-
1222
- const encoder = new TextEncoder();
1223
- const data = encoder.encode(content);
1224
- const hashBuffer = await crypto.subtle.digest('SHA-256', data);
1225
- const hashArray = Array.from(new Uint8Array(hashBuffer));
1226
- return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
1227
- }
1228
-
1229
- /**
1230
- * Generate a generic ID with optional prefix
1231
- *
1232
- * @param {string} prefix - Optional prefix (e.g., 'conflict', 'bookmark')
1233
- * @returns {string} ID string
1234
- */
1235
- function generateId(prefix = '') {
1236
- const bytes = crypto.getRandomValues(new Uint8Array(16));
1237
- const id = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
1238
- return prefix ? `${prefix}-${id}` : id;
1239
- }
1240
-
1241
1414
  /**
1242
1415
  * OperationLog - Manages the append-only operation log
1243
1416
  *
@@ -1854,11 +2027,7 @@ class TagStore {
1854
2027
  async save(tags) {
1855
2028
  // Ensure the store directory exists
1856
2029
  const storeDir = `${this.jjDir}/store`;
1857
- try {
1858
- await this.fs.promises.mkdir(storeDir, { recursive: true });
1859
- } catch (err) {
1860
- // Directory might already exist, ignore
1861
- }
2030
+ await mkdirp(this.fs, storeDir);
1862
2031
  await this.fs.promises.writeFile(
1863
2032
  this.tagsFile,
1864
2033
  JSON.stringify({ tags, tracked: this.tracking || {} }, null, 2)
@@ -2624,15 +2793,27 @@ class RevsetEngine {
2624
2793
  }
2625
2794
 
2626
2795
  case 'divergent': {
2796
+ // See issue #32 / converge(): a divergent change has more than one
2797
+ // visible commit sharing its changeId (added via
2798
+ // ChangeGraph.addDivergentCopy(), which is why counting `changeId`
2799
+ // occurrences across getAll() actually finds something now —
2800
+ // every other change has exactly one entry here). `c.divergent ===
2801
+ // true` also matches directly, since addDivergentCopy() sets that
2802
+ // flag on the copy it adds; the count check catches the *primary*
2803
+ // copy too (it's never itself flagged, since nothing about IT
2804
+ // changed) so both/all copies of a divergent changeId are
2805
+ // reported, not just the one(s) added after the first.
2627
2806
  await this.graph.load();
2628
2807
  const all = this.graph.getAll();
2629
2808
  const counts = new Map();
2630
2809
  for (const c of all) {
2631
2810
  counts.set(c.changeId, (counts.get(c.changeId) || 0) + 1);
2632
2811
  }
2633
- return all
2634
- .filter((c) => c.divergent === true || counts.get(c.changeId) > 1)
2635
- .map((c) => c.changeId);
2812
+ const matched = all.filter((c) => c.divergent === true || counts.get(c.changeId) > 1);
2813
+ // De-duplicate: multiple copies of the same divergent changeId
2814
+ // would otherwise each contribute their own (identical) changeId
2815
+ // to the result.
2816
+ return Array.from(new Set(matched.map((c) => c.changeId)));
2636
2817
  }
2637
2818
 
2638
2819
  case 'merges':
@@ -3685,6 +3866,184 @@ class RevsetEngine {
3685
3866
  }
3686
3867
  }
3687
3868
 
3869
+ /**
3870
+ * Minimal POSIX-only path utilities (join / dirname / resolve).
3871
+ *
3872
+ * Why not Node's `path` module: this library is isomorphic — it also needs
3873
+ * to run in browser bundles (see issue #28), where bare `import path from
3874
+ * 'path'` fails unless a bundler-specific polyfill is configured. Git/jj
3875
+ * repository paths are always POSIX-style (forward slashes), regardless of
3876
+ * host OS, so depending on Node's `path` module was also subtly wrong on
3877
+ * Windows: `path.join()` there uses `path.win32` semantics and would
3878
+ * produce backslash-joined paths for repository-relative paths, which is
3879
+ * not what git/jj object storage or working-copy paths want.
3880
+ *
3881
+ * These implement the same normalization rules as Node's `path.posix` for
3882
+ * the operations this codebase actually uses.
3883
+ */
3884
+
3885
+ /**
3886
+ * @param {string} path
3887
+ * @returns {string} `path` with any run of `/` collapsed to a single `/`,
3888
+ * and `.`/`..` segments resolved. Preserves a leading `/` (absolute) and
3889
+ * a trailing `/` when present in the input.
3890
+ */
3891
+ function normalize(path) {
3892
+ if (path === '') return '.';
3893
+
3894
+ const isAbsolute = path.charCodeAt(0) === 47; // '/'
3895
+ const trailingSlash = path.charCodeAt(path.length - 1) === 47;
3896
+
3897
+ const segments = path.split('/');
3898
+ /** @type {string[]} */
3899
+ const out = [];
3900
+ for (const segment of segments) {
3901
+ if (segment === '' || segment === '.') continue;
3902
+ if (segment === '..') {
3903
+ if (out.length && out[out.length - 1] !== '..') {
3904
+ out.pop();
3905
+ } else if (!isAbsolute) {
3906
+ out.push('..');
3907
+ }
3908
+ continue;
3909
+ }
3910
+ out.push(segment);
3911
+ }
3912
+
3913
+ let result = out.join('/');
3914
+ if (!result && !isAbsolute) result = '.';
3915
+ if (isAbsolute) result = '/' + result;
3916
+ if (trailingSlash && result.charCodeAt(result.length - 1) !== 47) result += '/';
3917
+ return result;
3918
+ }
3919
+
3920
+ /**
3921
+ * @param {...string} parts
3922
+ * @returns {string} All `parts` joined with `/` and normalized.
3923
+ */
3924
+ function join(...parts) {
3925
+ if (parts.length === 0) return '.';
3926
+ const joined = parts.filter((p) => p !== '').join('/');
3927
+ return joined === '' ? '.' : normalize(joined);
3928
+ }
3929
+
3930
+ /**
3931
+ * @param {string} path
3932
+ * @returns {string} The directory portion of `path` (Node `path.dirname` semantics).
3933
+ */
3934
+ function dirname(path) {
3935
+ if (path.length === 0) return '.';
3936
+ const isAbsolute = path.charCodeAt(0) === 47;
3937
+ let end = -1;
3938
+ let matchedSlash = true;
3939
+ for (let i = path.length - 1; i >= 1; i--) {
3940
+ if (path.charCodeAt(i) === 47) {
3941
+ if (!matchedSlash) {
3942
+ end = i;
3943
+ break;
3944
+ }
3945
+ } else {
3946
+ matchedSlash = false;
3947
+ }
3948
+ }
3949
+
3950
+ if (end === -1) return isAbsolute ? '/' : '.';
3951
+ if (isAbsolute && end === 0) return '/';
3952
+ return path.slice(0, end);
3953
+ }
3954
+
3955
+ /**
3956
+ * Resolves a sequence of path segments into an absolute path.
3957
+ *
3958
+ * Unlike Node's `path.resolve`, this never consults the real process cwd —
3959
+ * segments are resolved right-to-left, and if no absolute segment is found
3960
+ * the result is left relative to a virtual `/` root (this library always
3961
+ * deals in absolute repository-relative paths, so callers are expected to
3962
+ * supply an absolute base).
3963
+ *
3964
+ * @param {...string} parts
3965
+ * @returns {string}
3966
+ */
3967
+ function resolve(...parts) {
3968
+ let resolved = '';
3969
+ let isAbsolute = false;
3970
+
3971
+ for (let i = parts.length - 1; i >= -1 && !isAbsolute; i--) {
3972
+ const path = i >= 0 ? parts[i] : '/';
3973
+ if (path.length === 0) continue;
3974
+ resolved = path + '/' + resolved;
3975
+ isAbsolute = path.charCodeAt(0) === 47;
3976
+ }
3977
+
3978
+ resolved = normalize(resolved);
3979
+ // normalize() preserves a trailing slash; path.resolve() never returns one
3980
+ // (except for the root itself).
3981
+ if (resolved.length > 1 && resolved.endsWith('/')) {
3982
+ resolved = resolved.slice(0, -1);
3983
+ }
3984
+ return isAbsolute ? resolved : '/' + resolved;
3985
+ }
3986
+
3987
+ /**
3988
+ * Cross-environment byte/string conversion helpers.
3989
+ *
3990
+ * Why not just Node's `Buffer`: it's a Node global that most browser
3991
+ * bundlers (Vite, esbuild, webpack 5+) do not polyfill by default, so code
3992
+ * that calls `Buffer.from(...)` compiles fine but throws `ReferenceError:
3993
+ * Buffer is not defined` at runtime in a browser bundle (see issue #28).
3994
+ *
3995
+ * `utf8Encode`/`utf8Decode` still prefer the real `Buffer` when it's present
3996
+ * (i.e. under Node) so existing behavior — including `.toString()` producing
3997
+ * the original text back, which a plain `Uint8Array` cannot do — is
3998
+ * unchanged there; `typeof Buffer` is safe to evaluate even where `Buffer`
3999
+ * was never declared (unlike referencing it directly), so this doesn't
4000
+ * reintroduce the crash outside Node. `hexToBytes`/`bytesToHex`/`isBytes`
4001
+ * only ever need `Uint8Array`, so they don't need the same branch.
4002
+ */
4003
+ const hasBuffer = typeof Buffer !== 'undefined';
4004
+
4005
+ /**
4006
+ * @param {string} hex - Even-length hex string
4007
+ * @returns {Uint8Array}
4008
+ */
4009
+ function hexToBytes(hex) {
4010
+ const length = hex.length / 2;
4011
+ const bytes = new Uint8Array(length);
4012
+ for (let i = 0; i < length; i++) {
4013
+ bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
4014
+ }
4015
+ return bytes;
4016
+ }
4017
+
4018
+ /**
4019
+ * @param {string} str
4020
+ * @returns {Uint8Array} UTF-8 encoded bytes (a real `Buffer` under Node)
4021
+ */
4022
+ function utf8Encode(str) {
4023
+ return hasBuffer ? Buffer.from(str, 'utf-8') : new TextEncoder().encode(str);
4024
+ }
4025
+
4026
+ /**
4027
+ * @param {Uint8Array} bytes
4028
+ * @returns {string} UTF-8 decoded string
4029
+ */
4030
+ function utf8Decode(bytes) {
4031
+ return hasBuffer ? Buffer.from(bytes).toString('utf-8') : new TextDecoder('utf-8').decode(bytes);
4032
+ }
4033
+
4034
+ /**
4035
+ * True for anything that walks/quacks like binary content — a real Node
4036
+ * `Buffer` (which is itself a `Uint8Array` subclass) or a plain
4037
+ * `Uint8Array`/typed array, as produced by protobufjs in environments
4038
+ * without `Buffer` (e.g. a browser bundle).
4039
+ *
4040
+ * @param {any} value
4041
+ * @returns {boolean}
4042
+ */
4043
+ function isBytes(value) {
4044
+ return value instanceof Uint8Array;
4045
+ }
4046
+
3688
4047
  /**
3689
4048
  * ConflictModel - First-class conflict handling
3690
4049
  *
@@ -3857,6 +4216,15 @@ class ConflictModel {
3857
4216
  const conflict = this._detectPathConflict(path, baseContent, leftContent, rightContent);
3858
4217
  if (conflict) {
3859
4218
  conflicts.push(conflict);
4219
+ } else if (workingCopyDir) {
4220
+ // No conflict, but "no conflict" doesn't mean "nothing to do": of
4221
+ // _detectPathConflict's null-returning cases, every one *except*
4222
+ // "right side changed, left didn't" already matches what's on disk,
4223
+ // because `leftContent` **is** what's on disk (read straight from
4224
+ // the working copy above) -- so only that one case needs a write.
4225
+ if (baseContent === leftContent && baseContent !== rightContent) {
4226
+ await this._applyCleanResolution(workingCopyDir, path, rightContent);
4227
+ }
3860
4228
  }
3861
4229
  }
3862
4230
 
@@ -3944,6 +4312,30 @@ class ConflictModel {
3944
4312
  return null;
3945
4313
  }
3946
4314
 
4315
+ /**
4316
+ * Create a content conflict with more than two disagreeing versions —
4317
+ * every other conflict in this module has exactly two sides (`left`/
4318
+ * `right`, or `left`/`right` on an add-add), because every OTHER caller
4319
+ * (merge, rebase) only ever three-way-merges two changes at a time.
4320
+ * converge() (issue #32) can face more than two divergent copies of one
4321
+ * change in a single call, so `sides` here is `{ base, versions }`
4322
+ * instead: `versions` is every distinct value found across all copies
4323
+ * that differs from `base`, each paired with (one of) the commitId(s)
4324
+ * that has it.
4325
+ *
4326
+ * @param {string} path
4327
+ * @param {any} base
4328
+ * @param {Array<{commitId: string, content: any}>} versions
4329
+ */
4330
+ createNWayConflict(path, base, versions) {
4331
+ return this._createConflict({
4332
+ type: ConflictType.CONTENT,
4333
+ path,
4334
+ sides: { base, versions },
4335
+ message: `${versions.length} divergent copies disagree on this file's content`,
4336
+ });
4337
+ }
4338
+
3947
4339
  /**
3948
4340
  * Create a conflict object
3949
4341
  *
@@ -4188,39 +4580,69 @@ class ConflictModel {
4188
4580
  }
4189
4581
 
4190
4582
  /**
4191
- * Write merge driver result to working copy (v0.5)
4583
+ * Apply a clean (non-conflicting) three-way resolution to the working
4584
+ * copy, for the default (no merge driver) path in detectConflicts(): the
4585
+ * "right side wins" case, where the resolved content differs from what's
4586
+ * currently on disk (see the call site for why this is the only clean
4587
+ * case that needs one).
4192
4588
  *
4193
4589
  * @param {string} workingCopyDir - Working copy directory
4194
4590
  * @param {string} filePath - File path
4195
- * @param {Record<string, any>} result - Merge result
4591
+ * @param {any} content - The winning (right) side's content, or
4592
+ * `undefined` to mean the file was deleted on that side
4196
4593
  */
4197
- async _writeDriverResult(workingCopyDir, filePath, result) {
4198
- const fullPath = path.join(workingCopyDir, filePath);
4594
+ async _applyCleanResolution(workingCopyDir, filePath, content) {
4595
+ const fullPath = join(workingCopyDir, filePath);
4199
4596
 
4200
- // Ensure directory exists
4201
- const dir = path.dirname(fullPath);
4202
- await this.fs.promises.mkdir(dir, { recursive: true });
4597
+ if (content === undefined) {
4598
+ await this.fs.promises.unlink(fullPath).catch(() => {});
4599
+ return;
4600
+ }
4601
+
4602
+ const dir = dirname(fullPath);
4603
+ await mkdirp(this.fs, dir);
4604
+
4605
+ if (isBytes(content)) {
4606
+ await this.fs.promises.writeFile(fullPath, content);
4607
+ } else {
4608
+ await this.fs.promises.writeFile(fullPath, content, 'utf8');
4609
+ }
4610
+ }
4611
+
4612
+ /**
4613
+ * Write merge driver result to working copy (v0.5)
4614
+ *
4615
+ * @param {string} workingCopyDir - Working copy directory
4616
+ * @param {string} filePath - File path
4617
+ * @param {Record<string, any>} result - Merge result
4618
+ */
4619
+ async _writeDriverResult(workingCopyDir, filePath, result) {
4620
+ const fullPath = join(workingCopyDir, filePath);
4621
+
4622
+ // Ensure directory exists
4623
+ const dir = dirname(fullPath);
4624
+ await mkdirp(this.fs, dir);
4203
4625
 
4204
4626
  // Write main file
4205
4627
  if (result.content !== null && result.content !== undefined) {
4206
- if (Buffer.isBuffer(result.content)) {
4628
+ if (isBytes(result.content)) {
4207
4629
  await this.fs.promises.writeFile(fullPath, result.content);
4208
4630
  } else {
4209
- await this.fs.promises.writeFile(fullPath, result.content, 'utf-8');
4631
+ await this.fs.promises.writeFile(fullPath, result.content, 'utf8');
4210
4632
  }
4211
4633
  }
4212
4634
 
4213
4635
  // Write additional files if driver provided them
4214
4636
  if (result.additionalFiles) {
4215
4637
  for (const [additionalPath, additionalContent] of Object.entries(result.additionalFiles)) {
4216
- const additionalFullPath = path.join(workingCopyDir, additionalPath);
4217
- const additionalDir = path.dirname(additionalFullPath);
4218
- await this.fs.promises.mkdir(additionalDir, { recursive: true });
4638
+ const additionalFullPath = join(workingCopyDir, additionalPath);
4639
+ const additionalDir = dirname(additionalFullPath);
4640
+ await mkdirp(this.fs, additionalDir);
4219
4641
 
4220
- if (Buffer.isBuffer(additionalContent)) {
4642
+ if (isBytes(additionalContent)) {
4221
4643
  await this.fs.promises.writeFile(additionalFullPath, additionalContent);
4222
4644
  } else {
4223
- await this.fs.promises.writeFile(additionalFullPath, additionalContent, 'utf-8');
4645
+ await this.fs.promises.writeFile(additionalFullPath, additionalContent, 'utf8');
4224
4646
  }
4225
4647
  }
4226
4648
  }
@@ -4266,11 +4688,11 @@ function matchesPattern(path, pattern) {
4266
4688
  /**
4267
4689
  * Check if a file is likely binary based on content
4268
4690
  *
4269
- * @param {string|Buffer} content - File content
4691
+ * @param {string|Uint8Array} content - File content
4270
4692
  * @returns {boolean} Whether content appears binary
4271
4693
  */
4272
4694
  function isBinaryContent(content) {
4273
- if (Buffer.isBuffer(content)) return true;
4695
+ if (isBytes(content)) return true;
4274
4696
  if (typeof content !== 'string') return false;
4275
4697
 
4276
4698
  // Check for null bytes (common in binary files)
@@ -4585,9 +5007,9 @@ class MergeDriverRegistry {
4585
5007
  /**
4586
5008
  * Prepare content for driver based on binary/text
4587
5009
  *
4588
- * @param {string|Buffer|null} content - Raw content
4589
- * @param {boolean} isBinary - Whether to return as Buffer
4590
- * @returns {string|Buffer|null} Prepared content
5010
+ * @param {string|Uint8Array|null} content - Raw content
5011
+ * @param {boolean} isBinary - Whether to return as bytes
5012
+ * @returns {string|Uint8Array|null} Prepared content
4591
5013
  */
4592
5014
  prepareContent(content, isBinary) {
4593
5015
  if (content === null || content === undefined) {
@@ -4595,14 +5017,14 @@ class MergeDriverRegistry {
4595
5017
  }
4596
5018
 
4597
5019
  if (isBinary) {
4598
- // Convert to Buffer if needed
4599
- if (Buffer.isBuffer(content)) return content;
4600
- if (typeof content === 'string') return Buffer.from(content, 'utf-8');
4601
- return Buffer.from(String(content));
5020
+ // Convert to bytes if needed
5021
+ if (isBytes(content)) return content;
5022
+ if (typeof content === 'string') return utf8Encode(content);
5023
+ return utf8Encode(String(content));
4602
5024
  } else {
4603
5025
  // Convert to string if needed
4604
5026
  if (typeof content === 'string') return content;
4605
- if (Buffer.isBuffer(content)) return content.toString('utf-8');
5027
+ if (isBytes(content)) return utf8Decode(content);
4606
5028
  return String(content);
4607
5029
  }
4608
5030
  }
@@ -4763,25 +5185,25 @@ class WorkspaceManager {
4763
5185
  };
4764
5186
 
4765
5187
  // Create workspace directory
4766
- await this.fs.promises.mkdir(args.path, { recursive: true });
5188
+ await mkdirp(this.fs, args.path);
4767
5189
 
4768
5190
  // Create workspace-specific working_copy directory in .jj
4769
- const workspaceCopyDir = path.join(this.repoDir, '.jj', 'working_copy', workspaceId);
4770
- await this.fs.promises.mkdir(workspaceCopyDir, { recursive: true });
5191
+ const workspaceCopyDir = join(this.repoDir, '.jj', 'working_copy', workspaceId);
5192
+ await mkdirp(this.fs, workspaceCopyDir);
4771
5193
 
4772
5194
  // Create .git file pointing to main repo's .git directory
4773
5195
  // This allows Git tools to work in the workspace
4774
5196
  // IMPORTANT: Must use absolute paths for Git compatibility
4775
- const gitFilePath = path.join(args.path, '.git');
4776
- const mainGitDir = path.resolve(this.repoDir, '.git');
5197
+ const gitFilePath = join(args.path, '.git');
5198
+ const mainGitDir = resolve(this.repoDir, '.git');
4777
5199
  const gitFileContent = `gitdir: ${mainGitDir}\n`;
4778
5200
  await this.fs.promises.writeFile(gitFilePath, gitFileContent, 'utf8');
4779
5201
 
4780
5202
  // Create .jj file pointing to main repo's .jj directory
4781
5203
  // This allows JJ tools to work in the workspace
4782
5204
  // IMPORTANT: Must use absolute paths for JJ CLI compatibility
4783
- const jjFilePath = path.join(args.path, '.jj');
4784
- const mainJJDir = path.resolve(this.repoDir, '.jj');
5205
+ const jjFilePath = join(args.path, '.jj');
5206
+ const mainJJDir = resolve(this.repoDir, '.jj');
4785
5207
  const jjFileContent = `jjdir: ${mainJJDir}\n`;
4786
5208
  await this.fs.promises.writeFile(jjFilePath, jjFileContent, 'utf8');
4787
5209
 
@@ -5416,6 +5838,575 @@ class UserConfig {
5416
5838
  }
5417
5839
  }
5418
5840
 
5841
+ /**
5842
+ * Precompiled protobuf reflection JSON for local_working_copy.proto
5843
+ *
5844
+ * Generated from src/protos/local_working_copy.proto by scripts/build-protos.mjs. This
5845
+ * is bundled directly (instead of loaded from a filesystem path at runtime)
5846
+ * so that protobuf schema loading works identically in Node and in browser
5847
+ * bundles — protobuf.load() requires filesystem/network access that does
5848
+ * not exist in a browser bundle (see issue #28). Regenerate with:
5849
+ * node scripts/build-protos.mjs
5850
+ */
5851
+
5852
+ var localWorkingCopyJson = {
5853
+ nested: {
5854
+ local_working_copy: {
5855
+ nested: {
5856
+ FileType: {
5857
+ valuesOptions: {
5858
+ Conflict: {
5859
+ deprecated: true,
5860
+ },
5861
+ },
5862
+ values: {
5863
+ Normal: 0,
5864
+ Symlink: 1,
5865
+ Executable: 2,
5866
+ Conflict: 3,
5867
+ GitSubmodule: 4,
5868
+ },
5869
+ },
5870
+ MaterializedConflictData: {
5871
+ fields: {
5872
+ conflictMarkerLen: {
5873
+ type: 'uint32',
5874
+ id: 1,
5875
+ },
5876
+ },
5877
+ },
5878
+ FileState: {
5879
+ fields: {
5880
+ mtimeMillisSinceEpoch: {
5881
+ type: 'int64',
5882
+ id: 1,
5883
+ },
5884
+ size: {
5885
+ type: 'uint64',
5886
+ id: 2,
5887
+ },
5888
+ fileType: {
5889
+ type: 'FileType',
5890
+ id: 3,
5891
+ },
5892
+ materializedConflictData: {
5893
+ type: 'MaterializedConflictData',
5894
+ id: 5,
5895
+ },
5896
+ },
5897
+ reserved: [[4, 4]],
5898
+ },
5899
+ FileStateEntry: {
5900
+ fields: {
5901
+ path: {
5902
+ type: 'string',
5903
+ id: 1,
5904
+ },
5905
+ state: {
5906
+ type: 'FileState',
5907
+ id: 2,
5908
+ },
5909
+ },
5910
+ },
5911
+ SparsePatterns: {
5912
+ fields: {
5913
+ prefixes: {
5914
+ rule: 'repeated',
5915
+ type: 'string',
5916
+ id: 1,
5917
+ },
5918
+ },
5919
+ },
5920
+ TreeState: {
5921
+ fields: {
5922
+ legacyTreeId: {
5923
+ type: 'bytes',
5924
+ id: 1,
5925
+ options: {
5926
+ deprecated: true,
5927
+ },
5928
+ },
5929
+ treeIds: {
5930
+ rule: 'repeated',
5931
+ type: 'bytes',
5932
+ id: 5,
5933
+ },
5934
+ fileStates: {
5935
+ rule: 'repeated',
5936
+ type: 'FileStateEntry',
5937
+ id: 2,
5938
+ },
5939
+ isFileStatesSorted: {
5940
+ type: 'bool',
5941
+ id: 6,
5942
+ },
5943
+ sparsePatterns: {
5944
+ type: 'SparsePatterns',
5945
+ id: 3,
5946
+ },
5947
+ watchmanClock: {
5948
+ type: 'WatchmanClock',
5949
+ id: 4,
5950
+ },
5951
+ },
5952
+ },
5953
+ WatchmanClock: {
5954
+ oneofs: {
5955
+ watchmanClock: {
5956
+ oneof: ['stringClock', 'unixTimestamp'],
5957
+ },
5958
+ },
5959
+ fields: {
5960
+ stringClock: {
5961
+ type: 'string',
5962
+ id: 1,
5963
+ },
5964
+ unixTimestamp: {
5965
+ type: 'int64',
5966
+ id: 2,
5967
+ },
5968
+ },
5969
+ },
5970
+ Checkout: {
5971
+ fields: {
5972
+ operationId: {
5973
+ type: 'bytes',
5974
+ id: 2,
5975
+ },
5976
+ workspaceName: {
5977
+ type: 'string',
5978
+ id: 3,
5979
+ },
5980
+ },
5981
+ reserved: [[1, 1]],
5982
+ },
5983
+ },
5984
+ },
5985
+ },
5986
+ };
5987
+
5988
+ /**
5989
+ * Precompiled protobuf reflection JSON for simple_op_store.proto
5990
+ *
5991
+ * Generated from src/protos/simple_op_store.proto by scripts/build-protos.mjs. This
5992
+ * is bundled directly (instead of loaded from a filesystem path at runtime)
5993
+ * so that protobuf schema loading works identically in Node and in browser
5994
+ * bundles — protobuf.load() requires filesystem/network access that does
5995
+ * not exist in a browser bundle (see issue #28). Regenerate with:
5996
+ * node scripts/build-protos.mjs
5997
+ */
5998
+
5999
+ var simpleOpStoreJson = {
6000
+ nested: {
6001
+ simple_op_store: {
6002
+ nested: {
6003
+ RefConflictLegacy: {
6004
+ fields: {
6005
+ removes: {
6006
+ rule: 'repeated',
6007
+ type: 'bytes',
6008
+ id: 1,
6009
+ options: {
6010
+ deprecated: true,
6011
+ },
6012
+ },
6013
+ adds: {
6014
+ rule: 'repeated',
6015
+ type: 'bytes',
6016
+ id: 2,
6017
+ options: {
6018
+ deprecated: true,
6019
+ },
6020
+ },
6021
+ },
6022
+ },
6023
+ RefConflict: {
6024
+ fields: {
6025
+ removes: {
6026
+ rule: 'repeated',
6027
+ type: 'Term',
6028
+ id: 1,
6029
+ },
6030
+ adds: {
6031
+ rule: 'repeated',
6032
+ type: 'Term',
6033
+ id: 2,
6034
+ },
6035
+ },
6036
+ nested: {
6037
+ Term: {
6038
+ oneofs: {
6039
+ _value: {
6040
+ oneof: ['value'],
6041
+ },
6042
+ },
6043
+ fields: {
6044
+ value: {
6045
+ type: 'bytes',
6046
+ id: 1,
6047
+ options: {
6048
+ proto3_optional: true,
6049
+ },
6050
+ },
6051
+ },
6052
+ },
6053
+ },
6054
+ },
6055
+ RefTarget: {
6056
+ oneofs: {
6057
+ value: {
6058
+ oneof: ['commitId', 'conflictLegacy', 'conflict'],
6059
+ },
6060
+ },
6061
+ fields: {
6062
+ commitId: {
6063
+ type: 'bytes',
6064
+ id: 1,
6065
+ options: {
6066
+ deprecated: true,
6067
+ },
6068
+ },
6069
+ conflictLegacy: {
6070
+ type: 'RefConflictLegacy',
6071
+ id: 2,
6072
+ options: {
6073
+ deprecated: true,
6074
+ },
6075
+ },
6076
+ conflict: {
6077
+ type: 'RefConflict',
6078
+ id: 3,
6079
+ },
6080
+ },
6081
+ },
6082
+ RefTargetTerm: {
6083
+ oneofs: {
6084
+ _value: {
6085
+ oneof: ['value'],
6086
+ },
6087
+ },
6088
+ fields: {
6089
+ value: {
6090
+ type: 'bytes',
6091
+ id: 1,
6092
+ options: {
6093
+ proto3_optional: true,
6094
+ },
6095
+ },
6096
+ },
6097
+ },
6098
+ RemoteRefState: {
6099
+ values: {
6100
+ New: 0,
6101
+ Tracked: 1,
6102
+ },
6103
+ },
6104
+ RemoteBookmark: {
6105
+ oneofs: {
6106
+ _state: {
6107
+ oneof: ['state'],
6108
+ },
6109
+ },
6110
+ fields: {
6111
+ remoteName: {
6112
+ type: 'string',
6113
+ id: 1,
6114
+ },
6115
+ target: {
6116
+ type: 'RefTarget',
6117
+ id: 2,
6118
+ },
6119
+ state: {
6120
+ type: 'RemoteRefState',
6121
+ id: 3,
6122
+ options: {
6123
+ proto3_optional: true,
6124
+ },
6125
+ },
6126
+ },
6127
+ },
6128
+ Bookmark: {
6129
+ fields: {
6130
+ name: {
6131
+ type: 'string',
6132
+ id: 1,
6133
+ },
6134
+ localTarget: {
6135
+ type: 'RefTarget',
6136
+ id: 2,
6137
+ },
6138
+ remoteBookmarks: {
6139
+ rule: 'repeated',
6140
+ type: 'RemoteBookmark',
6141
+ id: 3,
6142
+ options: {
6143
+ deprecated: true,
6144
+ },
6145
+ },
6146
+ },
6147
+ },
6148
+ GitRef: {
6149
+ fields: {
6150
+ name: {
6151
+ type: 'string',
6152
+ id: 1,
6153
+ },
6154
+ commitId: {
6155
+ type: 'bytes',
6156
+ id: 2,
6157
+ options: {
6158
+ deprecated: true,
6159
+ },
6160
+ },
6161
+ target: {
6162
+ type: 'RefTarget',
6163
+ id: 3,
6164
+ },
6165
+ },
6166
+ },
6167
+ RemoteRef: {
6168
+ fields: {
6169
+ name: {
6170
+ type: 'string',
6171
+ id: 1,
6172
+ },
6173
+ targetTerms: {
6174
+ rule: 'repeated',
6175
+ type: 'RefTargetTerm',
6176
+ id: 2,
6177
+ },
6178
+ state: {
6179
+ type: 'RemoteRefState',
6180
+ id: 3,
6181
+ },
6182
+ },
6183
+ },
6184
+ Tag: {
6185
+ fields: {
6186
+ name: {
6187
+ type: 'string',
6188
+ id: 1,
6189
+ },
6190
+ target: {
6191
+ type: 'RefTarget',
6192
+ id: 2,
6193
+ },
6194
+ },
6195
+ },
6196
+ View: {
6197
+ fields: {
6198
+ headIds: {
6199
+ rule: 'repeated',
6200
+ type: 'bytes',
6201
+ id: 1,
6202
+ },
6203
+ wcCommitId: {
6204
+ type: 'bytes',
6205
+ id: 2,
6206
+ options: {
6207
+ deprecated: true,
6208
+ },
6209
+ },
6210
+ wcCommitIds: {
6211
+ keyType: 'string',
6212
+ type: 'bytes',
6213
+ id: 8,
6214
+ },
6215
+ bookmarks: {
6216
+ rule: 'repeated',
6217
+ type: 'Bookmark',
6218
+ id: 5,
6219
+ },
6220
+ localTags: {
6221
+ rule: 'repeated',
6222
+ type: 'Tag',
6223
+ id: 6,
6224
+ },
6225
+ remoteViews: {
6226
+ rule: 'repeated',
6227
+ type: 'RemoteView',
6228
+ id: 11,
6229
+ },
6230
+ gitRefs: {
6231
+ rule: 'repeated',
6232
+ type: 'GitRef',
6233
+ id: 3,
6234
+ },
6235
+ gitHeadLegacy: {
6236
+ type: 'bytes',
6237
+ id: 7,
6238
+ options: {
6239
+ deprecated: true,
6240
+ },
6241
+ },
6242
+ gitHead: {
6243
+ type: 'RefTarget',
6244
+ id: 9,
6245
+ },
6246
+ hasGitRefsMigratedToRemoteTags: {
6247
+ type: 'bool',
6248
+ id: 12,
6249
+ },
6250
+ },
6251
+ reserved: [
6252
+ [4, 4],
6253
+ [10, 10],
6254
+ ],
6255
+ },
6256
+ RemoteView: {
6257
+ fields: {
6258
+ name: {
6259
+ type: 'string',
6260
+ id: 1,
6261
+ },
6262
+ bookmarks: {
6263
+ rule: 'repeated',
6264
+ type: 'RemoteRef',
6265
+ id: 2,
6266
+ },
6267
+ tags: {
6268
+ rule: 'repeated',
6269
+ type: 'RemoteRef',
6270
+ id: 3,
6271
+ },
6272
+ },
6273
+ },
6274
+ Operation: {
6275
+ fields: {
6276
+ viewId: {
6277
+ type: 'bytes',
6278
+ id: 1,
6279
+ },
6280
+ parents: {
6281
+ rule: 'repeated',
6282
+ type: 'bytes',
6283
+ id: 2,
6284
+ },
6285
+ metadata: {
6286
+ type: 'OperationMetadata',
6287
+ id: 3,
6288
+ },
6289
+ commitPredecessors: {
6290
+ rule: 'repeated',
6291
+ type: 'CommitPredecessors',
6292
+ id: 4,
6293
+ },
6294
+ storesCommitPredecessors: {
6295
+ type: 'bool',
6296
+ id: 5,
6297
+ },
6298
+ },
6299
+ },
6300
+ Timestamp: {
6301
+ fields: {
6302
+ millisSinceEpoch: {
6303
+ type: 'int64',
6304
+ id: 1,
6305
+ },
6306
+ tzOffset: {
6307
+ type: 'int32',
6308
+ id: 2,
6309
+ },
6310
+ },
6311
+ },
6312
+ OperationMetadata: {
6313
+ fields: {
6314
+ startTime: {
6315
+ type: 'Timestamp',
6316
+ id: 1,
6317
+ },
6318
+ endTime: {
6319
+ type: 'Timestamp',
6320
+ id: 2,
6321
+ },
6322
+ description: {
6323
+ type: 'string',
6324
+ id: 3,
6325
+ },
6326
+ hostname: {
6327
+ type: 'string',
6328
+ id: 4,
6329
+ },
6330
+ username: {
6331
+ type: 'string',
6332
+ id: 5,
6333
+ },
6334
+ isSnapshot: {
6335
+ type: 'bool',
6336
+ id: 7,
6337
+ },
6338
+ tags: {
6339
+ keyType: 'string',
6340
+ type: 'string',
6341
+ id: 6,
6342
+ },
6343
+ },
6344
+ },
6345
+ CommitPredecessors: {
6346
+ fields: {
6347
+ commitId: {
6348
+ type: 'bytes',
6349
+ id: 1,
6350
+ },
6351
+ predecessorIds: {
6352
+ rule: 'repeated',
6353
+ type: 'bytes',
6354
+ id: 2,
6355
+ },
6356
+ },
6357
+ },
6358
+ },
6359
+ },
6360
+ },
6361
+ };
6362
+
6363
+ /**
6364
+ * Protobuf schema loader
6365
+ *
6366
+ * Builds protobuf.js `Root` instances from precompiled reflection JSON
6367
+ * (see local_working_copy.json.js / simple_op_store.json.js, generated from
6368
+ * the .proto files in this directory) instead of calling `protobuf.load()`
6369
+ * against a filesystem path.
6370
+ *
6371
+ * Why: `protobuf.load()` resolves its argument as a file path (Node `fs`) or
6372
+ * URL (XHR/fetch), which requires either a real filesystem or a network
6373
+ * request. In a bundled browser build there is no filesystem path to read
6374
+ * (see issue #28), and shipping the raw .proto text and fetching it at
6375
+ * runtime would require bundler-specific asset handling. Reflection JSON is
6376
+ * plain data, so it can be imported like any other JS module and works
6377
+ * identically under Node, Jest, and any browser bundler (Vite, esbuild,
6378
+ * webpack, Rollup, ...).
6379
+ *
6380
+ * Regenerate the JSON files after editing a .proto with:
6381
+ * npm run build:protos
6382
+ */
6383
+
6384
+
6385
+ /** @type {protobuf.Root|undefined} */
6386
+ let localWorkingCopyRoot;
6387
+ /** @type {protobuf.Root|undefined} */
6388
+ let simpleOpStoreRoot;
6389
+
6390
+ /**
6391
+ * @returns {protobuf.Root} Root for local_working_copy.proto (Checkout, TreeState, ...)
6392
+ */
6393
+ function getLocalWorkingCopyRoot() {
6394
+ if (!localWorkingCopyRoot) {
6395
+ localWorkingCopyRoot = protobuf.Root.fromJSON(localWorkingCopyJson);
6396
+ }
6397
+ return localWorkingCopyRoot;
6398
+ }
6399
+
6400
+ /**
6401
+ * @returns {protobuf.Root} Root for simple_op_store.proto (Operation, View, ...)
6402
+ */
6403
+ function getSimpleOpStoreRoot() {
6404
+ if (!simpleOpStoreRoot) {
6405
+ simpleOpStoreRoot = protobuf.Root.fromJSON(simpleOpStoreJson);
6406
+ }
6407
+ return simpleOpStoreRoot;
6408
+ }
6409
+
5419
6410
  /**
5420
6411
  * JJ Checkout File Manager
5421
6412
  *
@@ -5424,15 +6415,6 @@ class UserConfig {
5424
6415
  */
5425
6416
 
5426
6417
 
5427
- const __filename$4 = fileURLToPath(import.meta.url);
5428
- const __dirname$4 = path.dirname(__filename$4);
5429
-
5430
- // Determine proto path based on whether we're running from dist or src
5431
- const inDist$3 = __dirname$4.includes('/dist') || __dirname$4.includes('\\dist');
5432
- const protoDir$3 = inDist$3
5433
- ? path.join(__dirname$4, '..', 'src', 'protos')
5434
- : path.join(__dirname$4, '..', 'protos');
5435
-
5436
6418
  class JJCheckout {
5437
6419
  /**
5438
6420
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5441,7 +6423,6 @@ class JJCheckout {
5441
6423
  constructor(fs, dir) {
5442
6424
  this.fs = fs;
5443
6425
  this.dir = dir;
5444
- this.protoPath = path.join(protoDir$3, 'local_working_copy.proto');
5445
6426
  }
5446
6427
 
5447
6428
  /**
@@ -5452,11 +6433,11 @@ class JJCheckout {
5452
6433
  */
5453
6434
  async writeCheckout(operationId, workspaceName = 'default') {
5454
6435
  // Load protobuf schema
5455
- const root = await protobuf.load(this.protoPath);
6436
+ const root = getLocalWorkingCopyRoot();
5456
6437
  const Checkout = root.lookupType('local_working_copy.Checkout');
5457
6438
 
5458
6439
  // Convert hex operation ID to bytes
5459
- const opIdBuffer = Buffer.from(operationId, 'hex');
6440
+ const opIdBuffer = hexToBytes(operationId);
5460
6441
 
5461
6442
  // Create message (use camelCase for protobufjs)
5462
6443
  const message = Checkout.create({
@@ -5488,7 +6469,7 @@ class JJCheckout {
5488
6469
  const buffer = await this.fs.promises.readFile(checkoutPath);
5489
6470
 
5490
6471
  // Load protobuf schema
5491
- const root = await protobuf.load(this.protoPath);
6472
+ const root = getLocalWorkingCopyRoot();
5492
6473
  const Checkout = root.lookupType('local_working_copy.Checkout');
5493
6474
 
5494
6475
  // Decode
@@ -5516,6 +6497,7 @@ class JJCheckout {
5516
6497
  * calling `writeFile()` directly on the canonical path — see issue #16.
5517
6498
  */
5518
6499
 
6500
+
5519
6501
  /**
5520
6502
  * @param {any} fs - Filesystem implementation (Node fs, LightningFS, etc.)
5521
6503
  * @param {string} targetPath - Canonical path to write to
@@ -5531,7 +6513,7 @@ async function atomicWriteFile(fs, targetPath, content) {
5531
6513
  try {
5532
6514
  const dirPath = targetPath.substring(0, targetPath.lastIndexOf('/'));
5533
6515
  if (dirPath) {
5534
- await fs.promises.mkdir(dirPath, { recursive: true });
6516
+ await mkdirp(fs, dirPath);
5535
6517
  }
5536
6518
 
5537
6519
  await fs.promises.writeFile(tmpPath, content);
@@ -5555,15 +6537,6 @@ async function atomicWriteFile(fs, targetPath, content) {
5555
6537
  */
5556
6538
 
5557
6539
 
5558
- const __filename$3 = fileURLToPath(import.meta.url);
5559
- const __dirname$3 = path.dirname(__filename$3);
5560
-
5561
- // Determine proto path based on whether we're running from dist or src
5562
- const inDist$2 = __dirname$3.includes('/dist') || __dirname$3.includes('\\dist');
5563
- const protoDir$2 = inDist$2
5564
- ? path.join(__dirname$3, '..', 'src', 'protos')
5565
- : path.join(__dirname$3, '..', 'protos');
5566
-
5567
6540
  class JJTreeState {
5568
6541
  /**
5569
6542
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5572,7 +6545,6 @@ class JJTreeState {
5572
6545
  constructor(fs, dir) {
5573
6546
  this.fs = fs;
5574
6547
  this.dir = dir;
5575
- this.protoPath = path.join(protoDir$2, 'local_working_copy.proto');
5576
6548
  }
5577
6549
 
5578
6550
  /**
@@ -5583,11 +6555,11 @@ class JJTreeState {
5583
6555
  */
5584
6556
  async writeTreeState(treeId, fileStates = []) {
5585
6557
  // Load protobuf schema
5586
- const root = await protobuf.load(this.protoPath);
6558
+ const root = getLocalWorkingCopyRoot();
5587
6559
  const TreeState = root.lookupType('local_working_copy.TreeState');
5588
6560
 
5589
6561
  // Convert hex tree ID to bytes
5590
- const treeIdBuffer = Buffer.from(treeId, 'hex');
6562
+ const treeIdBuffer = hexToBytes(treeId);
5591
6563
 
5592
6564
  // Convert file states to protobuf format
5593
6565
  const fileStateEntries = fileStates.map((fs) => ({
@@ -5633,7 +6605,7 @@ class JJTreeState {
5633
6605
  const buffer = await this.fs.promises.readFile(treeStatePath);
5634
6606
 
5635
6607
  // Load protobuf schema
5636
- const root = await protobuf.load(this.protoPath);
6608
+ const root = getLocalWorkingCopyRoot();
5637
6609
  const TreeState = root.lookupType('local_working_copy.TreeState');
5638
6610
 
5639
6611
  // Decode
@@ -5657,15 +6629,6 @@ class JJTreeState {
5657
6629
  */
5658
6630
 
5659
6631
 
5660
- const __filename$2 = fileURLToPath(import.meta.url);
5661
- const __dirname$2 = path.dirname(__filename$2);
5662
-
5663
- // Determine proto path based on whether we're running from dist or src
5664
- const inDist$1 = __dirname$2.includes('/dist') || __dirname$2.includes('\\dist');
5665
- const protoDir$1 = inDist$1
5666
- ? path.join(__dirname$2, '..', 'src', 'protos')
5667
- : path.join(__dirname$2, '..', 'protos');
5668
-
5669
6632
  class JJOperationStore {
5670
6633
  /**
5671
6634
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5674,7 +6637,6 @@ class JJOperationStore {
5674
6637
  constructor(fs, dir) {
5675
6638
  this.fs = fs;
5676
6639
  this.dir = dir;
5677
- this.protoPath = path.join(protoDir$1, 'simple_op_store.proto');
5678
6640
  }
5679
6641
 
5680
6642
  /**
@@ -5687,12 +6649,12 @@ class JJOperationStore {
5687
6649
  */
5688
6650
  async writeOperation(operationId, viewId, parentIds, metadata) {
5689
6651
  // Load protobuf schema
5690
- const root = await protobuf.load(this.protoPath);
6652
+ const root = getSimpleOpStoreRoot();
5691
6653
  const Operation = root.lookupType('simple_op_store.Operation');
5692
6654
 
5693
6655
  // Convert hex IDs to bytes
5694
- const viewIdBuffer = Buffer.from(viewId, 'hex');
5695
- const parentBuffers = parentIds.map((id) => Buffer.from(id, 'hex'));
6656
+ const viewIdBuffer = hexToBytes(viewId);
6657
+ const parentBuffers = parentIds.map((id) => hexToBytes(id));
5696
6658
 
5697
6659
  // Create metadata message (use camelCase for protobufjs)
5698
6660
  const metadataMsg = {
@@ -5748,7 +6710,7 @@ class JJOperationStore {
5748
6710
  const buffer = await this.fs.promises.readFile(opPath);
5749
6711
 
5750
6712
  // Load protobuf schema
5751
- const root = await protobuf.load(this.protoPath);
6713
+ const root = getSimpleOpStoreRoot();
5752
6714
  const Operation = root.lookupType('simple_op_store.Operation');
5753
6715
 
5754
6716
  // Decode
@@ -5787,15 +6749,6 @@ class JJOperationStore {
5787
6749
  */
5788
6750
 
5789
6751
 
5790
- const __filename$1 = fileURLToPath(import.meta.url);
5791
- const __dirname$1 = path.dirname(__filename$1);
5792
-
5793
- // Determine proto path based on whether we're running from dist or src
5794
- const inDist = __dirname$1.includes('/dist') || __dirname$1.includes('\\dist');
5795
- const protoDir = inDist
5796
- ? path.join(__dirname$1, '..', 'src', 'protos')
5797
- : path.join(__dirname$1, '..', 'protos');
5798
-
5799
6752
  class JJViewStore {
5800
6753
  /**
5801
6754
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5804,7 +6757,6 @@ class JJViewStore {
5804
6757
  constructor(fs, dir) {
5805
6758
  this.fs = fs;
5806
6759
  this.dir = dir;
5807
- this.protoPath = path.join(protoDir, 'simple_op_store.proto');
5808
6760
  }
5809
6761
 
5810
6762
  /**
@@ -5816,17 +6768,17 @@ class JJViewStore {
5816
6768
  */
5817
6769
  async writeView(viewId, headIds, wcCommitIds = {}) {
5818
6770
  // Load protobuf schema
5819
- const root = await protobuf.load(this.protoPath);
6771
+ const root = getSimpleOpStoreRoot();
5820
6772
  const View = root.lookupType('simple_op_store.View');
5821
6773
 
5822
6774
  // Convert hex IDs to bytes
5823
- const headBuffers = headIds.map((id) => Buffer.from(id, 'hex'));
6775
+ const headBuffers = headIds.map((id) => hexToBytes(id));
5824
6776
 
5825
6777
  // Convert wcCommitIds object to map
5826
6778
  /** @type {Record<string, any>} */
5827
6779
  const wcCommitIdsMap = {};
5828
6780
  for (const [workspace, commitId] of Object.entries(wcCommitIds)) {
5829
- wcCommitIdsMap[workspace] = Buffer.from(commitId, 'hex');
6781
+ wcCommitIdsMap[workspace] = hexToBytes(commitId);
5830
6782
  }
5831
6783
 
5832
6784
  // Create view message (use camelCase for protobufjs)
@@ -5867,7 +6819,7 @@ class JJViewStore {
5867
6819
  const buffer = await this.fs.promises.readFile(viewPath);
5868
6820
 
5869
6821
  // Load protobuf schema
5870
- const root = await protobuf.load(this.protoPath);
6822
+ const root = getSimpleOpStoreRoot();
5871
6823
  const View = root.lookupType('simple_op_store.View');
5872
6824
 
5873
6825
  // Decode
@@ -6563,14 +7515,14 @@ class IsomorphicGitBackend {
6563
7515
  const jjRepo = `${jjDir}/repo`;
6564
7516
 
6565
7517
  // Create directory structure
6566
- await this.fs.promises.mkdir(`${jjRepo}/store`, { recursive: true });
6567
- await this.fs.promises.mkdir(`${jjRepo}/store/extra/heads`, { recursive: true });
6568
- await this.fs.promises.mkdir(`${jjRepo}/op_store/operations`, { recursive: true });
6569
- await this.fs.promises.mkdir(`${jjRepo}/op_store/views`, { recursive: true });
6570
- await this.fs.promises.mkdir(`${jjRepo}/op_heads/heads`, { recursive: true });
6571
- await this.fs.promises.mkdir(`${jjRepo}/index`, { recursive: true });
6572
- await this.fs.promises.mkdir(`${jjRepo}/submodule_store`, { recursive: true });
6573
- await this.fs.promises.mkdir(`${jjDir}/working_copy`, { recursive: true });
7518
+ await mkdirp(this.fs, `${jjRepo}/store`);
7519
+ await mkdirp(this.fs, `${jjRepo}/store/extra/heads`);
7520
+ await mkdirp(this.fs, `${jjRepo}/op_store/operations`);
7521
+ await mkdirp(this.fs, `${jjRepo}/op_store/views`);
7522
+ await mkdirp(this.fs, `${jjRepo}/op_heads/heads`);
7523
+ await mkdirp(this.fs, `${jjRepo}/index`);
7524
+ await mkdirp(this.fs, `${jjRepo}/submodule_store`);
7525
+ await mkdirp(this.fs, `${jjDir}/working_copy`);
6574
7526
 
6575
7527
  // Write type files
6576
7528
  await this.fs.promises.writeFile(`${jjRepo}/store/type`, 'git');
@@ -6597,8 +7549,8 @@ class IsomorphicGitBackend {
6597
7549
  */
6598
7550
  async _createInitialJJState() {
6599
7551
  // Generate unique IDs for initial state
6600
- const operationId = crypto$1.randomBytes(64).toString('hex'); // 512-bit = 128 hex chars
6601
- const viewId = crypto$1.randomBytes(64).toString('hex');
7552
+ const operationId = randomHex(64); // 512-bit = 128 hex chars
7553
+ const viewId = randomHex(64);
6602
7554
  const emptyTreeId = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // Git's empty tree hash
6603
7555
 
6604
7556
  // Get the initial commit from Git
@@ -6610,7 +7562,7 @@ class IsomorphicGitBackend {
6610
7562
  ref: 'HEAD',
6611
7563
  });
6612
7564
  if (headOid) {
6613
- initialCommitId = crypto$1.randomBytes(64).toString('hex');
7565
+ initialCommitId = randomHex(64);
6614
7566
  }
6615
7567
  } catch (error) {
6616
7568
  // No initial commit yet
@@ -6767,7 +7719,7 @@ async function createJJ(options) {
6767
7719
 
6768
7720
  for (const filePath of trackedFiles) {
6769
7721
  try {
6770
- const fullPath = path.join(dir, filePath);
7722
+ const fullPath = join(dir, filePath);
6771
7723
  const stats = await fs.promises.stat(fullPath);
6772
7724
 
6773
7725
  // Skip files that are too large
@@ -6780,7 +7732,7 @@ async function createJJ(options) {
6780
7732
  break;
6781
7733
  }
6782
7734
 
6783
- const content = await fs.promises.readFile(fullPath, 'utf-8');
7735
+ const content = await fs.promises.readFile(fullPath, 'utf8');
6784
7736
  fileSnapshot[filePath] = content;
6785
7737
  totalSnapshotSize += stats.size;
6786
7738
  } catch (error) {
@@ -6794,6 +7746,257 @@ async function createJJ(options) {
6794
7746
  return fileSnapshot;
6795
7747
  };
6796
7748
 
7749
+ /**
7750
+ * Fully sync the working directory to a target file snapshot: writes
7751
+ * every file the target has (create/update), AND deletes every currently
7752
+ * tracked file the target does NOT have.
7753
+ *
7754
+ * Both edit() and new() used to only overlay the target's files on top of
7755
+ * whatever was already on disk, so a file that existed in the previously
7756
+ * checked-out change but not in the target leaked into the next
7757
+ * snapshot — see issue #30. undo()/redo() had the same gap restoring
7758
+ * file state — see issue #29. This is the one place that does it
7759
+ * correctly, used by all four.
7760
+ *
7761
+ * @param {Record<string, string>} targetSnapshot - path -> file content
7762
+ * @returns {Promise<void>}
7763
+ */
7764
+ const syncWorkingCopyFiles = async (targetSnapshot) => {
7765
+ const snapshot = targetSnapshot || {};
7766
+ const currentFiles = await workingCopy.listFiles();
7767
+
7768
+ // Remove files that aren't part of the target.
7769
+ for (const filePath of currentFiles) {
7770
+ if (!(filePath in snapshot)) {
7771
+ try {
7772
+ const fullPath = join(dir, filePath);
7773
+ await fs.promises.unlink(fullPath);
7774
+ } catch (error) {
7775
+ // Already gone / inaccessible — fine, we just want it absent.
7776
+ }
7777
+ await workingCopy.untrackFile(filePath);
7778
+ }
7779
+ }
7780
+
7781
+ // Write/update every file the target has.
7782
+ for (const [filePath, content] of Object.entries(snapshot)) {
7783
+ const fullPath = join(dir, filePath);
7784
+ const pathParts = filePath.split('/');
7785
+ if (pathParts.length > 1) {
7786
+ const fullDirPath = join(dir, pathParts.slice(0, -1).join('/'));
7787
+ await mkdirp(fs, fullDirPath);
7788
+ }
7789
+ await fs.promises.writeFile(fullPath, content, 'utf8');
7790
+
7791
+ const stats = await fs.promises.stat(fullPath);
7792
+ await workingCopy.trackFile(filePath, {
7793
+ mtime: stats.mtime,
7794
+ size: stats.size,
7795
+ mode: stats.mode,
7796
+ });
7797
+ }
7798
+ };
7799
+
7800
+ /**
7801
+ * Compute the ChangeGraph/file/conflicts state that existed right after
7802
+ * `landingOp` finished — i.e. what undo()/redo()/operations.restore()
7803
+ * need to re-establish when winding the repository forward or back to
7804
+ * that point (see issue #29).
7805
+ *
7806
+ * Every operation that mutates EXISTING change records in place records
7807
+ * (additively — most operations touch none of this and that's fine,
7808
+ * there's nothing to reverse for them):
7809
+ * - changeSnapshot: { [changeId]: fullRecordBeforeThisOpRan }
7810
+ * - view.fileSnapshot: on-disk file state right BEFORE this op ran
7811
+ * - conflictsSnapshot: ConflictModel state right BEFORE this op ran
7812
+ *
7813
+ * Deliberately NOT reverted: changes an operation newly *created* (via
7814
+ * graph.addChange() — new(), squash()'s synthetic empty working-copy
7815
+ * change, ...). Undoing their creation only un-refs them (restores the
7816
+ * working-copy pointer/heads that made them reachable) — it does not
7817
+ * delete the change record — matching real jj, where an "unreachable"
7818
+ * change stays resolvable by its change id (hidden, not gone) until
7819
+ * explicitly gc'd, and this package's own history-editing tests rely on
7820
+ * exactly that: rebasing onto a change created by a since-undone new()
7821
+ * must still work.
7822
+ *
7823
+ * "The state right after landingOp" is therefore exactly what the FIRST
7824
+ * operation after landingOp recorded as ITS "before" state, merged across
7825
+ * every operation between landingOp and the current head in case
7826
+ * different operations touched different changeIds (a single op's own
7827
+ * snapshot only covers what IT touched). Concretely, per field:
7828
+ * - changeSnapshot: first (earliest / closest-to-landingOp) entry per
7829
+ * changeId wins — later entries in the range reflect that changeId's
7830
+ * state at a LATER point than we're restoring to.
7831
+ * - fileSnapshot / conflictsSnapshot: the first op in the range that
7832
+ * recorded one, since whole-state snapshots aren't per-changeId and
7833
+ * an op that didn't record one simply didn't change that state.
7834
+ *
7835
+ * This same helper backs undo() (landingOp = the parent of whatever's
7836
+ * being undone), redo() (landingOp = the operation a prior undo()
7837
+ * reverted), and operations.restore() (landingOp = the target operation
7838
+ * itself) — one reversal primitive, not three.
7839
+ *
7840
+ * @param {Array<any>} ops - Full chronological operation list (oplog.list())
7841
+ * @param {any} landingOp - The operation whose resulting state to recompute
7842
+ * @returns {{
7843
+ * changeSnapshotToApply: Record<string, any>,
7844
+ * fileSnapshot: Record<string, string>|undefined,
7845
+ * conflictsSnapshot: any,
7846
+ * }}
7847
+ */
7848
+ const computeGraphReversal = (/** @type {any[]} */ ops, /** @type {any} */ landingOp) => {
7849
+ const landingIndex = ops.findIndex((op) => op.id === landingOp.id);
7850
+
7851
+ /** @type {Record<string, any>} */
7852
+ const changeSnapshotToApply = {};
7853
+ /** @type {Record<string, string>|undefined} */
7854
+ let fileSnapshot;
7855
+ /** @type {any} */
7856
+ let conflictsSnapshot;
7857
+
7858
+ for (let i = landingIndex + 1; i < ops.length; i++) {
7859
+ const op = ops[i];
7860
+
7861
+ if (op.changeSnapshot) {
7862
+ for (const [changeId, snapshot] of Object.entries(op.changeSnapshot)) {
7863
+ if (!(changeId in changeSnapshotToApply)) {
7864
+ changeSnapshotToApply[changeId] = snapshot;
7865
+ }
7866
+ }
7867
+ }
7868
+
7869
+ // `!= null` (not `!== undefined`) on purpose: some operations record
7870
+ // an explicit `null` for "I ran, and I deliberately took no snapshot"
7871
+ // (e.g. describe() of a non-working-copy revision) — that's exactly
7872
+ // as uninformative as never having set the field, so keep scanning
7873
+ // rather than "finding" a null snapshot.
7874
+ if (fileSnapshot === undefined && op.view && op.view.fileSnapshot != null) {
7875
+ fileSnapshot = op.view.fileSnapshot;
7876
+ }
7877
+
7878
+ if (conflictsSnapshot === undefined && op.conflictsSnapshot != null) {
7879
+ conflictsSnapshot = op.conflictsSnapshot;
7880
+ }
7881
+ }
7882
+
7883
+ return { changeSnapshotToApply, fileSnapshot, conflictsSnapshot };
7884
+ };
7885
+
7886
+ /**
7887
+ * Apply a reversal computed by computeGraphReversal() to the live graph,
7888
+ * working-copy files, and conflict state. Does NOT touch the working-copy
7889
+ * pointer or bookmarks — callers set those from the landing op's own
7890
+ * `view` directly (pointers, unlike file/graph/conflict content, are
7891
+ * already correct as recorded, see computeGraphReversal's doc comment).
7892
+ *
7893
+ * @param {ReturnType<typeof computeGraphReversal>} reversal
7894
+ * @returns {Promise<void>}
7895
+ */
7896
+ const applyGraphReversal = async (reversal) => {
7897
+ await graph.load();
7898
+ for (const [changeId, snapshot] of Object.entries(reversal.changeSnapshotToApply)) {
7899
+ if (await graph.getChange(changeId)) {
7900
+ await graph.updateChange(snapshot);
7901
+ }
7902
+ }
7903
+
7904
+ if (reversal.fileSnapshot !== undefined) {
7905
+ await syncWorkingCopyFiles(reversal.fileSnapshot);
7906
+ }
7907
+
7908
+ await conflicts.load();
7909
+ if (reversal.conflictsSnapshot !== undefined) {
7910
+ conflicts.conflicts = new Map(Object.entries(reversal.conflictsSnapshot.conflicts || {}));
7911
+ conflicts.fileConflicts = new Map(
7912
+ Object.entries(reversal.conflictsSnapshot.fileConflicts || {})
7913
+ );
7914
+ await conflicts.save();
7915
+ }
7916
+ // Else: no operation in the reverted range touched conflicts — leave
7917
+ // current conflict state alone. (Previously undo() unconditionally
7918
+ // cleared ALL conflicts whenever the op being undone hadn't recorded
7919
+ // its own conflictsSnapshot, which is nearly every non-merge() op —
7920
+ // undoing an unrelated describe() would silently wipe conflicts from
7921
+ // an earlier, unrelated merge(). Fixed as part of issue #29.)
7922
+ };
7923
+
7924
+ /**
7925
+ * Re-parent every child of `changeId` onto `newParents` instead — used
7926
+ * when a change is being elided from the graph (abandon(), issue #30;
7927
+ * converge(), issue #32) so its children don't end up pointing at
7928
+ * something no longer meant to be there. Mutates `changeSnapshot` in
7929
+ * place, adding each touched child's PRE-reparent record so the caller's
7930
+ * own oplog entry lets undo()/redo()/operations.restore() reverse this
7931
+ * too (the same mechanism the caller already uses for the elided
7932
+ * change's own snapshot).
7933
+ *
7934
+ * @param {string} changeId - The change being elided
7935
+ * @param {string[]} newParents - What its children should point at instead
7936
+ * @param {Record<string, any>} changeSnapshot - Accumulator to add each touched child's pre-state to
7937
+ * @returns {Promise<void>}
7938
+ */
7939
+ const reparentChildrenOnto = async (changeId, newParents, changeSnapshot) => {
7940
+ const childIds = graph.getChildren(changeId);
7941
+ for (const childId of childIds) {
7942
+ const child = await graph.getChange(childId);
7943
+ if (!child) continue;
7944
+ changeSnapshot[childId] = structuredClone(child);
7945
+ const reparented = child.parents.flatMap((/** @type {string} */ p) =>
7946
+ p === changeId ? newParents : [p]
7947
+ );
7948
+ // De-dupe (e.g. the child could already have one of the new parents
7949
+ // as another of its own parents) while preserving first-seen order.
7950
+ child.parents = [...new Set(reparented)];
7951
+ await graph.updateChange(child);
7952
+ }
7953
+ };
7954
+
7955
+ /**
7956
+ * Determine which operation a fresh undo() call should revert, matching
7957
+ * real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
7958
+ * a normal operation, this will undo that last operation by restoring its
7959
+ * parent. If jj undo is used repeatedly, it will restore increasingly
7960
+ * older operations, going further back into the past.").
7961
+ *
7962
+ * The raw op-log parent chain can't answer this directly once undo/redo
7963
+ * bookkeeping operations are mixed into the log: an 'undo' operation's
7964
+ * own parent is the operation it just undid, so naively targeting
7965
+ * `ops[ops.length - 1]` on a SECOND undo() would undo the undo (toggle
7966
+ * back to the future) instead of stepping one further step into the past
7967
+ * — this was issue #29's "undo() twice only undoes the undo" bug.
7968
+ *
7969
+ * @param {Array<any>} ops - Full chronological operation list
7970
+ * @returns {any} The operation object undo() should revert
7971
+ */
7972
+ const resolveUndoTarget = (ops) => {
7973
+ const head = ops[ops.length - 1];
7974
+ const findOp = (/** @type {any} */ id) => ops.find((op) => op.id === id);
7975
+
7976
+ if (head.eventType === 'undo') {
7977
+ // We're stepping further into the past: what should be undone now is
7978
+ // the operation right BEFORE the one `head` already undid — not
7979
+ // head's own parent, which IS the operation head undid (targeting it
7980
+ // again would just toggle back to the present).
7981
+ const previouslyUndone = head.targetOpId && findOp(head.targetOpId);
7982
+ const stepBack = previouslyUndone && findOp(previouslyUndone.parents[0]);
7983
+ return stepBack || previouslyUndone || head;
7984
+ }
7985
+
7986
+ if (head.eventType === 'redo') {
7987
+ // A redo re-established some earlier operation as current; a fresh
7988
+ // undo right after it should behave exactly as if that operation
7989
+ // were freshly current and had never been touched by undo/redo at
7990
+ // all — i.e. undo it again (real jj: redo "moves in the direction of
7991
+ // the future"; undoing right after simply resumes stepping back).
7992
+ const redoneUndo = head.redoOf && findOp(head.redoOf);
7993
+ const target = redoneUndo && redoneUndo.targetOpId && findOp(redoneUndo.targetOpId);
7994
+ return target || head;
7995
+ }
7996
+
7997
+ return head;
7998
+ };
7999
+
6797
8000
  /**
6798
8001
  * Automatic working-copy snapshot (v1.6).
6799
8002
  *
@@ -6978,6 +8181,25 @@ async function createJJ(options) {
6978
8181
  }
6979
8182
  },
6980
8183
 
8184
+ // Used by undo()/redo()/operations.restore() to reverse an
8185
+ // addChange() when winding the graph back — see issue #29. No Git
8186
+ // sync hook: a change created after the operation being reverted was
8187
+ // never pushed anywhere to unwind, and the Git backend has no
8188
+ // "forget this commit" operation (nor should it — Git objects are
8189
+ // content-addressed and harmless to leave orphaned).
8190
+ deleteChange: (/** @type {any} */ changeId) => baseGraph.deleteChange(changeId),
8191
+
8192
+ // See issue #32 / converge(): a divergent copy is another commit
8193
+ // claiming an existing changeId; no Git sync hook, for the same
8194
+ // reason addChange()'s primary copy already goes through
8195
+ // onAddChange() when it's first created — the divergent copy needs
8196
+ // no additional sync of its own.
8197
+ addDivergentCopy: (/** @type {any} */ change) => baseGraph.addDivergentCopy(change),
8198
+ getDivergentSiblings: (/** @type {any} */ changeId) =>
8199
+ baseGraph.getDivergentSiblings(changeId),
8200
+ deleteDivergentCopy: (/** @type {any} */ changeId, /** @type {any} */ commitId) =>
8201
+ baseGraph.deleteDivergentCopy(changeId, commitId),
8202
+
6981
8203
  // Delegate other operations
6982
8204
  init: () => baseGraph.init(),
6983
8205
  };
@@ -7135,6 +8357,20 @@ async function createJJ(options) {
7135
8357
  /**
7136
8358
  * Write a file to the working copy
7137
8359
  *
8360
+ * Note (issue #30): this does NOT update the working-copy change's
8361
+ * `commitId`/`fileSnapshot` — those stay as they were until the next
8362
+ * command that snapshots the working copy (describe(), new(), edit(),
8363
+ * status(), or any other call that goes through
8364
+ * autoSnapshotWorkingCopy()/snapshot()). This is intentional, not a
8365
+ * bug: jj has no staging area, but it also doesn't recompute a git
8366
+ * tree/commit hash on every single file write (that would mean
8367
+ * hashing on every keystroke of an external editor) — real jj only
8368
+ * takes a fresh working-copy snapshot when an actual `jj` command
8369
+ * runs. write() is the equivalent of editing a file with your OS's own
8370
+ * tools outside of jj entirely; describe()/new()/edit()/status()/etc.
8371
+ * are the equivalent of running a jj command, which is what triggers
8372
+ * the (auto) snapshot in both real jj and here.
8373
+ *
7138
8374
  * @param {Record<string, any>} args - Arguments
7139
8375
  * @returns {Promise<Object>} File information including path, size, and mode
7140
8376
  */
@@ -7158,7 +8394,7 @@ async function createJJ(options) {
7158
8394
  if (pathParts.length > 1) {
7159
8395
  const dirPath = pathParts.slice(0, -1).join('/');
7160
8396
  const fullDirPath = `${dir}/${dirPath}`;
7161
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8397
+ await mkdirp(fs, fullDirPath);
7162
8398
  }
7163
8399
 
7164
8400
  // Write the file
@@ -7213,10 +8449,10 @@ async function createJJ(options) {
7213
8449
  });
7214
8450
  }
7215
8451
 
7216
- const fullPath = path.join(dir, args.path);
8452
+ const fullPath = join(dir, args.path);
7217
8453
  try {
7218
8454
  if (encoding === 'utf-8' || encoding === 'utf8') {
7219
- return await fs.promises.readFile(fullPath, 'utf-8');
8455
+ return await fs.promises.readFile(fullPath, 'utf8');
7220
8456
  } else {
7221
8457
  return await fs.promises.readFile(fullPath);
7222
8458
  }
@@ -7381,7 +8617,7 @@ async function createJJ(options) {
7381
8617
 
7382
8618
  // Reading from working copy
7383
8619
  if (!args.changeId) {
7384
- const fullPath = path.join(dir, args.path);
8620
+ const fullPath = join(dir, args.path);
7385
8621
  try {
7386
8622
  const stream = fs.createReadStream(
7387
8623
  fullPath,
@@ -7431,14 +8667,14 @@ async function createJJ(options) {
7431
8667
  });
7432
8668
  }
7433
8669
 
7434
- const fullPath = path.join(dir, args.path);
8670
+ const fullPath = join(dir, args.path);
7435
8671
 
7436
8672
  // Ensure directory exists
7437
8673
  const pathParts = args.path.split('/');
7438
8674
  if (pathParts.length > 1) {
7439
8675
  const dirPath = pathParts.slice(0, -1).join('/');
7440
- const fullDirPath = path.join(dir, dirPath);
7441
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8676
+ const fullDirPath = join(dir, dirPath);
8677
+ await mkdirp(fs, fullDirPath);
7442
8678
  }
7443
8679
 
7444
8680
  // Create writable stream
@@ -7551,7 +8787,7 @@ async function createJJ(options) {
7551
8787
  // Ensure destination directory exists
7552
8788
  const toDir = toPath.substring(0, toPath.lastIndexOf('/'));
7553
8789
  try {
7554
- await fs.promises.mkdir(toDir, { recursive: true });
8790
+ await mkdirp(fs, toDir);
7555
8791
  } catch (error) {
7556
8792
  throw new JJError(
7557
8793
  'FILE_SYSTEM_ERROR',
@@ -7669,6 +8905,51 @@ async function createJJ(options) {
7669
8905
  // TODO: Prevent creating cycles in the change graph
7670
8906
  // For now, we'll allow it but it should be validated
7671
8907
 
8908
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
8909
+ // reverse the reparenting — see issue #29.
8910
+ const changeSnapshotBefore = structuredClone(change);
8911
+
8912
+ // Three-way merge against the OLD base before reparenting (issue #31):
8913
+ // real jj's rebase re-derives the rebased change's content as
8914
+ // (old base, old commit, new base) — if a sibling changed the same
8915
+ // lines differently, that's a genuine conflict, not something
8916
+ // reparenting-and-copying-verbatim can silently paper over. This
8917
+ // reuses the exact same detectConflicts() machinery merge() already
8918
+ // uses (see merge(), a few hundred lines down) rather than
8919
+ // duplicating three-way-merge logic a third time in this codebase.
8920
+ const oldParentId = change.parents[0];
8921
+ const oldParentChange = oldParentId ? await graph.getChange(oldParentId) : null;
8922
+ const baseFiles = new Map(
8923
+ Object.entries((oldParentChange && oldParentChange.fileSnapshot) || {})
8924
+ );
8925
+ const leftFiles = new Map(Object.entries(change.fileSnapshot || {}));
8926
+ const rightFiles = new Map(Object.entries(newParentChange.fileSnapshot || {}));
8927
+
8928
+ await conflicts.load();
8929
+ const conflictsSnapshot = {
8930
+ conflicts: Object.fromEntries(conflicts.conflicts),
8931
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
8932
+ };
8933
+
8934
+ const detectedConflicts = oldParentChange
8935
+ ? await conflicts.detectConflicts({
8936
+ baseFiles,
8937
+ leftFiles,
8938
+ rightFiles,
8939
+ drivers: {},
8940
+ // Not necessarily the checked-out working copy, so don't let
8941
+ // merge drivers write to disk on its behalf.
8942
+ workingCopyDir: /** @type {any} */ (null),
8943
+ baseChange: oldParentId,
8944
+ leftChange: changeId,
8945
+ rightChange: newParent,
8946
+ })
8947
+ : [];
8948
+
8949
+ for (const conflict of detectedConflicts) {
8950
+ await conflicts.addConflict(conflict);
8951
+ }
8952
+
7672
8953
  // Update parent
7673
8954
  change.parents = [newParent];
7674
8955
  await graph.updateChange(change);
@@ -7679,6 +8960,8 @@ async function createJJ(options) {
7679
8960
  user: await getUserOplogInfo(),
7680
8961
  description: `move change ${changeId.slice(0, 8)} to ${newParent.slice(0, 8)}`,
7681
8962
  parents: [],
8963
+ changeSnapshot: { [changeId]: changeSnapshotBefore },
8964
+ conflictsSnapshot,
7682
8965
  view: {
7683
8966
  bookmarks: {},
7684
8967
  remoteBookmarks: {},
@@ -7687,7 +8970,7 @@ async function createJJ(options) {
7687
8970
  },
7688
8971
  });
7689
8972
 
7690
- return change;
8973
+ return { ...change, conflicts: detectedConflicts };
7691
8974
  },
7692
8975
 
7693
8976
  /**
@@ -7859,7 +9142,7 @@ async function createJJ(options) {
7859
9142
 
7860
9143
  for (const filePath of trackedFiles) {
7861
9144
  try {
7862
- const fullPath = path.join(dir, filePath);
9145
+ const fullPath = join(dir, filePath);
7863
9146
  const stats = await fs.promises.stat(fullPath);
7864
9147
 
7865
9148
  // Skip files that are too large (silently - this is an optimization)
@@ -7872,7 +9155,7 @@ async function createJJ(options) {
7872
9155
  break;
7873
9156
  }
7874
9157
 
7875
- const content = await fs.promises.readFile(fullPath, 'utf-8');
9158
+ const content = await fs.promises.readFile(fullPath, 'utf8');
7876
9159
  fileSnapshot[filePath] = content;
7877
9160
  totalSnapshotSize += stats.size;
7878
9161
  } catch (error) {
@@ -8070,6 +9353,8 @@ async function createJJ(options) {
8070
9353
 
8071
9354
  // Snapshot filesystem BEFORE operation (for undo) unless preserveSnapshot is true
8072
9355
  let fileSnapshot;
9356
+ /** @type {Record<string, any>} */
9357
+ const changeSnapshot = {};
8073
9358
  if (!args.preserveSnapshot) {
8074
9359
  fileSnapshot = await snapshotFilesystem();
8075
9360
 
@@ -8078,6 +9363,10 @@ async function createJJ(options) {
8078
9363
  const currentChangeId = workingCopy.getCurrentChangeId();
8079
9364
  const currentChange = await graph.getChange(currentChangeId);
8080
9365
  if (currentChange && fileSnapshot) {
9366
+ // Snapshot BEFORE mutating so undo()/redo() can revert this
9367
+ // in-place update too, not just the pointer/new-change creation
9368
+ // below — see issue #29.
9369
+ changeSnapshot[currentChangeId] = structuredClone(currentChange);
8081
9370
  currentChange.fileSnapshot = fileSnapshot;
8082
9371
  await graph.updateChange(currentChange);
8083
9372
  }
@@ -8105,6 +9394,7 @@ async function createJJ(options) {
8105
9394
  const user = /** @type {any} */ (userConfig.getUser());
8106
9395
 
8107
9396
  // Initialize with parent's file snapshot
9397
+ /** @type {Record<string, string>} */
8108
9398
  let initialSnapshot = {};
8109
9399
  if (parents && parents.length > 0) {
8110
9400
  const parent = await graph.getChange(parents[0]);
@@ -8148,10 +9438,19 @@ async function createJJ(options) {
8148
9438
  // Handle insertBefore: rebase target to have new change as parent
8149
9439
  if (args.insertBefore) {
8150
9440
  const targetChange = await graph.getChange(args.insertBefore);
9441
+ changeSnapshot[args.insertBefore] = structuredClone(targetChange);
8151
9442
  targetChange.parents = [newChangeId];
8152
9443
  await graph.updateChange(targetChange);
8153
9444
  }
8154
9445
 
9446
+ // Sync the working directory to the new change's snapshot: write
9447
+ // everything it has AND remove anything it doesn't (issue #30 — this
9448
+ // used to never touch disk at all, so `new({ parents: [...] })`
9449
+ // targeting anything other than the current head silently left the
9450
+ // OLD working copy's files in place instead of checking out the new
9451
+ // parent's).
9452
+ await syncWorkingCopyFiles(initialSnapshot);
9453
+
8155
9454
  await workingCopy.setCurrentChange(newChangeId);
8156
9455
 
8157
9456
  // Record operation with filesystem snapshot
@@ -8164,6 +9463,13 @@ async function createJJ(options) {
8164
9463
  },
8165
9464
  description: `new change ${newChangeId.slice(0, 8)}`,
8166
9465
  parents: [],
9466
+ // See issue #29: lets undo()/redo()/operations.restore() reverse
9467
+ // the existing records this op updated in place (the old
9468
+ // working-copy change's refreshed fileSnapshot, and insertBefore's
9469
+ // target's reparented parents). The newly created change itself
9470
+ // (newChangeId) is intentionally NOT torn down on undo — see
9471
+ // computeGraphReversal's doc comment.
9472
+ changeSnapshot,
8167
9473
  view: {
8168
9474
  bookmarks: {},
8169
9475
  remoteBookmarks: {},
@@ -8544,6 +9850,13 @@ async function createJJ(options) {
8544
9850
 
8545
9851
  const previousChangeId = workingCopy.getCurrentChangeId();
8546
9852
 
9853
+ /** @type {Record<string, any>} */
9854
+ const changeSnapshot = {};
9855
+
9856
+ // Capture disk state right before we touch anything, for undo() —
9857
+ // see issue #29.
9858
+ const fileSnapshotBeforeSwitch = await snapshotFilesystem();
9859
+
8547
9860
  // CRITICAL FIX: Clean working directory BEFORE snapshotting to prevent cross-branch pollution
8548
9861
  // Remove files that don't belong to the current changeId before we snapshot it
8549
9862
  if (previousChangeId !== args.changeId) {
@@ -8557,7 +9870,7 @@ async function createJJ(options) {
8557
9870
  for (const filePath of workingDirFiles) {
8558
9871
  if (!previousSnapshot[filePath]) {
8559
9872
  try {
8560
- const fullPath = path.join(dir, filePath);
9873
+ const fullPath = join(dir, filePath);
8561
9874
  await fs.promises.unlink(fullPath);
8562
9875
  await workingCopy.untrackFile(filePath);
8563
9876
  } catch (error) {
@@ -8565,13 +9878,14 @@ async function createJJ(options) {
8565
9878
  }
8566
9879
  }
8567
9880
  }
8568
- }
8569
9881
 
8570
- // Now snapshot the CLEAN working directory (no pollution!)
8571
- const currentSnapshot = await snapshotFilesystem();
8572
- if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
8573
- previousChange.fileSnapshot = currentSnapshot;
8574
- await graph.updateChange(previousChange);
9882
+ // Now snapshot the CLEAN working directory (no pollution!)
9883
+ const currentSnapshot = await snapshotFilesystem();
9884
+ if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
9885
+ changeSnapshot[previousChangeId] = structuredClone(previousChange);
9886
+ previousChange.fileSnapshot = currentSnapshot;
9887
+ await graph.updateChange(previousChange);
9888
+ }
8575
9889
  }
8576
9890
  }
8577
9891
 
@@ -8584,38 +9898,17 @@ async function createJJ(options) {
8584
9898
  timestamp: new Date().toISOString(),
8585
9899
  });
8586
9900
 
8587
- // Restore files from the change's snapshot to working directory
8588
- if (change.fileSnapshot) {
8589
- for (const [filePath, content] of Object.entries(change.fileSnapshot)) {
8590
- try {
8591
- const fullPath = path.join(dir, filePath);
8592
-
8593
- // Ensure directory exists
8594
- const pathParts = filePath.split('/');
8595
- if (pathParts.length > 1) {
8596
- const dirPath = pathParts.slice(0, -1).join('/');
8597
- const fullDirPath = path.join(dir, dirPath);
8598
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8599
- }
8600
-
8601
- // Write file content
8602
- await fs.promises.writeFile(fullPath, content, 'utf8');
8603
-
8604
- // Update working copy tracking
8605
- const stats = await fs.promises.stat(fullPath);
8606
- await workingCopy.trackFile(filePath, {
8607
- mtime: stats.mtime,
8608
- size: stats.size,
8609
- mode: stats.mode,
8610
- });
8611
- } catch (error) {
8612
- throw new JJError(
8613
- 'FILE_RESTORE_FAILED',
8614
- `Failed to restore file ${filePath}: ${error.message}`,
8615
- { filePath, originalError: error.message }
8616
- );
8617
- }
8618
- }
9901
+ // Fully sync the working directory to the target change's snapshot:
9902
+ // write everything it has AND remove anything it doesn't (issue #30
9903
+ // — this used to only overlay the target's files on top of whatever
9904
+ // was already on disk, so a file present in the PREVIOUS change but
9905
+ // absent from the target leaked into the next snapshot).
9906
+ try {
9907
+ await syncWorkingCopyFiles(change.fileSnapshot || {});
9908
+ } catch (error) {
9909
+ throw new JJError('FILE_RESTORE_FAILED', `Failed to restore files: ${error.message}`, {
9910
+ originalError: error.message,
9911
+ });
8619
9912
  }
8620
9913
 
8621
9914
  // Set this change as the working copy
@@ -8631,11 +9924,13 @@ async function createJJ(options) {
8631
9924
  },
8632
9925
  description: `edit change ${args.changeId.slice(0, 8)}`,
8633
9926
  parents: [],
9927
+ changeSnapshot,
8634
9928
  view: {
8635
9929
  bookmarks: {},
8636
9930
  remoteBookmarks: {},
8637
9931
  heads: [args.changeId],
8638
9932
  workingCopy: args.changeId,
9933
+ fileSnapshot: fileSnapshotBeforeSwitch,
8639
9934
  },
8640
9935
  });
8641
9936
 
@@ -8666,106 +9961,107 @@ async function createJJ(options) {
8666
9961
  /**
8667
9962
  * Undo last operation
8668
9963
  *
9964
+ * Reverts the most recent operation — including graph-rewriting ones
9965
+ * (squash/rebase/abandon/new/edit), not just working-copy/file state
9966
+ * (see issue #29). Matches real jj's progressive-undo semantics
9967
+ * (`jj-undo(1)`): called again with nothing else in between, it steps
9968
+ * one further operation into the past rather than undoing itself.
9969
+ *
8669
9970
  * @returns {Promise<Object>} Information about the undo including the undone operation and restored state
8670
9971
  */
8671
9972
  async undo() {
8672
- // Get the operation we're about to undo to access its pre-state
8673
9973
  await oplog.load();
8674
9974
  const ops = await oplog.list();
8675
9975
  if (ops.length === 0) {
8676
9976
  throw new JJError('NOTHING_TO_UNDO', 'No operations to undo');
8677
9977
  }
8678
9978
 
8679
- // Get the current operation (the one being undone) to restore its pre-state
8680
- const currentOp = ops[ops.length - 1];
8681
-
8682
- const previousView = /** @type {any} */ (await oplog.undo());
8683
-
8684
- // Restore state from previous view
8685
- await workingCopy.setCurrentChange(previousView.workingCopy);
9979
+ const targetOp = resolveUndoTarget(ops);
9980
+ const landingOpId = targetOp.parents && targetOp.parents[0];
9981
+ const landingOp = landingOpId ? ops.find((op) => op.id === landingOpId) : null;
8686
9982
 
8687
- // Restore ChangeGraph content mutated in place by the operation being
8688
- // undone (e.g. describe() editing change.description) — see issue #12.
8689
- // currentOp.changeSnapshot holds the affected change record(s) as they
8690
- // were BEFORE currentOp ran, captured the same way fileSnapshot is.
9983
+ /** @type {ReturnType<typeof computeGraphReversal>} */
9984
+ let reversal;
9985
+ /** @type {any} */
9986
+ let landingView;
9987
+ if (landingOp) {
9988
+ reversal = computeGraphReversal(ops, landingOp);
9989
+ landingView = landingOp.view || {};
9990
+ } else {
9991
+ // Undoing the very first (root) operation: there's nothing before
9992
+ // it to restore to, so land on its own recorded state rather than
9993
+ // throwing (mirrors the previous no-parent fallback).
9994
+ reversal = {
9995
+ changeSnapshotToApply: {},
9996
+ fileSnapshot: undefined,
9997
+ conflictsSnapshot: undefined,
9998
+ };
9999
+ landingView = targetOp.view || {};
10000
+ }
10001
+
10002
+ // Capture EXACTLY what's about to be overwritten, from ground truth,
10003
+ // before applying the reversal — this is what redo() re-applies
10004
+ // later. It can't just recompute "the state after targetOp" the way
10005
+ // undo() itself computes "the state after landingOp" (by scanning
10006
+ // for a LATER op that also touched the same changeId/files/
10007
+ // conflicts): that scan only works going backward, where "nothing
10008
+ // touched it since" means "it's already correct". Going forward,
10009
+ // "nothing touched it since targetOp" means the opposite — its
10010
+ // effect is still live and needs to be captured now, not rederived.
8691
10011
  await graph.load();
8692
- if (currentOp && currentOp.changeSnapshot) {
8693
- for (const snapshot of Object.values(
8694
- /** @type {Record<string, any>} */ (currentOp.changeSnapshot)
8695
- )) {
8696
- if (snapshot && (await graph.getChange(snapshot.changeId))) {
8697
- await graph.updateChange(snapshot);
8698
- }
8699
- }
8700
- }
8701
-
8702
- // Restore filesystem from previous operation's snapshot (taken BEFORE that operation)
8703
- // This is how JJ snapshots the working copy before every command
8704
- if (previousView.fileSnapshot) {
8705
- for (const [filePath, content] of Object.entries(previousView.fileSnapshot)) {
8706
- try {
8707
- const fullPath = path.join(dir, filePath);
8708
-
8709
- // Ensure directory exists
8710
- const pathParts = filePath.split('/');
8711
- if (pathParts.length > 1) {
8712
- const dirPath = pathParts.slice(0, -1).join('/');
8713
- const fullDirPath = path.join(dir, dirPath);
8714
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8715
- }
8716
-
8717
- // Write file content
8718
- await fs.promises.writeFile(fullPath, content, 'utf8');
8719
- } catch (error) {
8720
- throw new JJError(
8721
- 'UNDO_FILE_RESTORE_FAILED',
8722
- `Failed to restore file ${filePath} during undo: ${error.message}`,
8723
- { filePath, originalError: error.message }
8724
- );
8725
- }
8726
- }
10012
+ /** @type {Record<string, any>} */
10013
+ const redoChangeSnapshot = {};
10014
+ for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
10015
+ const current = await graph.getChange(changeId);
10016
+ if (current) redoChangeSnapshot[changeId] = current;
10017
+ }
10018
+ const redoFileSnapshot =
10019
+ reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
10020
+ let redoConflictsSnapshot;
10021
+ if (reversal.conflictsSnapshot !== undefined) {
10022
+ await conflicts.load();
10023
+ redoConflictsSnapshot = {
10024
+ conflicts: Object.fromEntries(conflicts.conflicts),
10025
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
10026
+ };
8727
10027
  }
8728
10028
 
8729
- // Restore conflicts state from before the undone operation
8730
- // The conflictsSnapshot in an operation represents the state BEFORE that operation ran
8731
- await conflicts.load();
8732
- if (currentOp && currentOp.conflictsSnapshot) {
8733
- // Restore conflicts from the operation we're undoing (its pre-state)
8734
- conflicts.conflicts = new Map(Object.entries(currentOp.conflictsSnapshot.conflicts || {}));
8735
- conflicts.fileConflicts = new Map(
8736
- Object.entries(currentOp.conflictsSnapshot.fileConflicts || {})
8737
- );
8738
- await conflicts.save();
8739
- } else {
8740
- // No conflicts snapshot - clear conflicts
8741
- await conflicts.clear();
10029
+ if (landingView.workingCopy) {
10030
+ await workingCopy.setCurrentChange(landingView.workingCopy);
8742
10031
  }
10032
+ await applyGraphReversal(reversal);
8743
10033
 
8744
- // Record undo operation. Tag it with the view it undid so that redo()
8745
- // can re-apply it (jj's progressive undo/redo, added in jj v0.33).
10034
+ // Record undo as its own operation (matching real jj) — tagged with
10035
+ // which operation it targeted so a following undo() can walk one
10036
+ // step further back (resolveUndoTarget) instead of toggling, and
10037
+ // carrying exactly what redo() needs to re-apply (captured above).
8746
10038
  await oplog.recordOperation({
8747
10039
  timestamp: new Date().toISOString(),
8748
10040
  user: await getUserOplogInfo(),
8749
10041
  description: 'undo operation',
8750
10042
  eventType: 'undo',
8751
- undoneOpId: currentOp.id,
8752
- undoneView: currentOp.view,
8753
- undoneConflictsSnapshot: currentOp.conflictsSnapshot || null,
10043
+ targetOpId: targetOp.id,
10044
+ undoneOpId: targetOp.id, // kept for API/type compatibility
10045
+ redoChangeSnapshot,
10046
+ redoFileSnapshot,
10047
+ redoConflictsSnapshot,
8754
10048
  parents: [],
8755
- view: previousView,
10049
+ view: landingView,
8756
10050
  });
8757
10051
 
8758
- // Return information about what was undone
10052
+ const restoredFileCount =
10053
+ reversal.fileSnapshot !== undefined ? Object.keys(reversal.fileSnapshot).length : 0;
10054
+
8759
10055
  return {
8760
10056
  undoneOperation: {
8761
- description: currentOp.description,
8762
- timestamp: currentOp.timestamp,
8763
- user: currentOp.user,
10057
+ description: targetOp.description,
10058
+ timestamp: targetOp.timestamp,
10059
+ user: targetOp.user,
8764
10060
  },
8765
10061
  restoredState: {
8766
- workingCopy: previousView.workingCopy,
8767
- heads: previousView.heads,
8768
- fileCount: previousView.fileSnapshot ? Object.keys(previousView.fileSnapshot).length : 0,
10062
+ workingCopy: landingView.workingCopy,
10063
+ heads: landingView.heads,
10064
+ fileCount: restoredFileCount,
8769
10065
  },
8770
10066
  };
8771
10067
  },
@@ -8773,9 +10069,9 @@ async function createJJ(options) {
8773
10069
  /**
8774
10070
  * Redo an operation previously undone with undo() (v1.5)
8775
10071
  *
8776
- * Mirrors `jj redo` (jj v0.33): progressively re-applies the operations that
8777
- * `undo()` reverted, most-recent-undo first. Each redo consumes exactly one
8778
- * pending undo.
10072
+ * Mirrors `jj redo` (jj v0.33): re-applies the operation that the most
10073
+ * recent not-yet-redone undo() reverted — including its graph rewrites,
10074
+ * not just working-copy/file state (see issue #29).
8779
10075
  *
8780
10076
  * @returns {Promise<Object>} Information about the redone operation
8781
10077
  */
@@ -8806,42 +10102,36 @@ async function createJJ(options) {
8806
10102
  });
8807
10103
  }
8808
10104
 
8809
- const redoneView = undoOp.undoneView || {};
10105
+ const targetOpId = undoOp.targetOpId || undoOp.undoneOpId;
10106
+ const targetOp = targetOpId && ops.find((op) => op.id === targetOpId);
10107
+ const redoneView = (targetOp && targetOp.view) || {};
8810
10108
 
8811
- // Restore the working-copy pointer.
8812
10109
  if (redoneView.workingCopy) {
8813
10110
  await workingCopy.setCurrentChange(redoneView.workingCopy);
8814
10111
  }
8815
10112
 
8816
- // Restore the working-copy files captured in the undone view.
8817
- if (redoneView.fileSnapshot) {
8818
- for (const [filePath, content] of Object.entries(redoneView.fileSnapshot)) {
8819
- try {
8820
- const fullPath = path.join(dir, filePath);
8821
- const pathParts = filePath.split('/');
8822
- if (pathParts.length > 1) {
8823
- const fullDirPath = path.join(dir, pathParts.slice(0, -1).join('/'));
8824
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8825
- }
8826
- await fs.promises.writeFile(fullPath, content, 'utf8');
8827
- } catch (error) {
8828
- throw new JJError(
8829
- 'REDO_FILE_RESTORE_FAILED',
8830
- `Failed to restore file ${filePath} during redo: ${error.message}`,
8831
- { filePath, originalError: error.message }
8832
- );
8833
- }
10113
+ // Re-apply exactly what this undo captured right before it reverted
10114
+ // things — NOT computeGraphReversal(ops, targetOp): that scans for a
10115
+ // LATER op touching the same changeId/files/conflicts, which is the
10116
+ // right question for undo() ("has anything since made my target
10117
+ // stale?") but the wrong one here — after an undo, NOTHING later
10118
+ // re-established targetOp's own effect, so there's nothing to find
10119
+ // by scanning forward. undo() captured it directly for exactly this
10120
+ // reason (see its own comment).
10121
+ await graph.load();
10122
+ for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
10123
+ if (await graph.getChange(changeId)) {
10124
+ await graph.updateChange(snapshot);
8834
10125
  }
8835
10126
  }
8836
-
8837
- // Restore conflicts to the state that existed after the redone operation.
10127
+ if (undoOp.redoFileSnapshot !== undefined) {
10128
+ await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
10129
+ }
8838
10130
  await conflicts.load();
8839
- if (undoOp.undoneConflictsSnapshot) {
8840
- conflicts.conflicts = new Map(
8841
- Object.entries(undoOp.undoneConflictsSnapshot.conflicts || {})
8842
- );
10131
+ if (undoOp.redoConflictsSnapshot !== undefined) {
10132
+ conflicts.conflicts = new Map(Object.entries(undoOp.redoConflictsSnapshot.conflicts || {}));
8843
10133
  conflicts.fileConflicts = new Map(
8844
- Object.entries(undoOp.undoneConflictsSnapshot.fileConflicts || {})
10134
+ Object.entries(undoOp.redoConflictsSnapshot.fileConflicts || {})
8845
10135
  );
8846
10136
  await conflicts.save();
8847
10137
  }
@@ -8856,15 +10146,18 @@ async function createJJ(options) {
8856
10146
  view: redoneView,
8857
10147
  });
8858
10148
 
10149
+ const restoredFileCount =
10150
+ undoOp.redoFileSnapshot !== undefined ? Object.keys(undoOp.redoFileSnapshot).length : 0;
10151
+
8859
10152
  return {
8860
10153
  redoneOperation: {
8861
10154
  description: undoOp.description,
8862
- undoneOpId: undoOp.undoneOpId,
10155
+ undoneOpId: targetOpId,
8863
10156
  },
8864
10157
  restoredState: {
8865
10158
  workingCopy: redoneView.workingCopy,
8866
10159
  heads: redoneView.heads,
8867
- fileCount: redoneView.fileSnapshot ? Object.keys(redoneView.fileSnapshot).length : 0,
10160
+ fileCount: restoredFileCount,
8868
10161
  },
8869
10162
  };
8870
10163
  },
@@ -9091,6 +10384,12 @@ async function createJJ(options) {
9091
10384
  /**
9092
10385
  * Restore repository to a specific operation (matches `jj operation restore`)
9093
10386
  *
10387
+ * Fully reverts everything recorded after the target operation —
10388
+ * including graph rewrites (squash/rebase/abandon/new/edit), working
10389
+ * copy files, and conflict state, not just bookmarks/the working-copy
10390
+ * pointer (see issue #29) — using the same reversal primitive as
10391
+ * undo()/redo().
10392
+ *
9094
10393
  * @param {Record<string, any>} args - Arguments
9095
10394
  * @returns {Promise<Object>} Restore result
9096
10395
  */
@@ -9130,6 +10429,11 @@ async function createJJ(options) {
9130
10429
  await workingCopy.setCurrentChange(targetOp.view.workingCopy);
9131
10430
  }
9132
10431
 
10432
+ // Revert the change graph, working-copy files, and conflict state
10433
+ // to exactly what they were right after targetOp ran.
10434
+ const reversal = computeGraphReversal(ops, targetOp);
10435
+ await applyGraphReversal(reversal);
10436
+
9133
10437
  // Record this restoration as a new operation
9134
10438
  await oplog.recordOperation({
9135
10439
  timestamp: new Date().toISOString(),
@@ -9421,10 +10725,53 @@ async function createJJ(options) {
9421
10725
  timestamp: new Date().toISOString(),
9422
10726
  });
9423
10727
 
10728
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
10729
+ // reverse the squash (un-abandon source, restore dest's description
10730
+ // and content) — see issue #29.
10731
+ /** @type {Record<string, any>} */
10732
+ const changeSnapshot = {
10733
+ [source]: structuredClone(sourceChange),
10734
+ [destChangeId]: structuredClone(destChange),
10735
+ };
10736
+
9424
10737
  // Mark source as abandoned
9425
10738
  sourceChange.abandoned = true;
9426
10739
  await graph.updateChange(sourceChange);
9427
10740
 
10741
+ // Merge source's file content into dest (issue #30: squash() used to
10742
+ // only combine descriptions, leaving dest's tree untouched). Compute
10743
+ // what source itself actually changed relative to ITS OWN parent
10744
+ // (its "diff") and fold just that onto dest, rather than overwriting
10745
+ // dest's whole snapshot with source's — a path source never touched
10746
+ // (inherited unchanged from that parent) must not clobber a
10747
+ // dest-specific version of the same path.
10748
+ const sourceParentId = sourceChange.parents && sourceChange.parents[0];
10749
+ const sourceParentChange = sourceParentId ? await graph.getChange(sourceParentId) : null;
10750
+ const sourceBaseSnapshot = (sourceParentChange && sourceParentChange.fileSnapshot) || {};
10751
+ const sourceSnapshot = sourceChange.fileSnapshot || {};
10752
+ const mergedSnapshot = { ...(destChange.fileSnapshot || {}) };
10753
+ const touchedPaths = new Set([
10754
+ ...Object.keys(sourceBaseSnapshot),
10755
+ ...Object.keys(sourceSnapshot),
10756
+ ]);
10757
+ for (const filePath of touchedPaths) {
10758
+ const baseValue = sourceBaseSnapshot[filePath];
10759
+ const sourceValue = sourceSnapshot[filePath];
10760
+ if (sourceValue === baseValue) {
10761
+ // source never touched this path (inherited as-is from its own
10762
+ // parent) — leave whatever dest already had for it alone.
10763
+ continue;
10764
+ }
10765
+ if (sourceValue === undefined) {
10766
+ // source deleted a file it inherited from its parent.
10767
+ delete mergedSnapshot[filePath];
10768
+ } else {
10769
+ // source added or modified this path.
10770
+ mergedSnapshot[filePath] = sourceValue;
10771
+ }
10772
+ }
10773
+ destChange.fileSnapshot = mergedSnapshot;
10774
+
9428
10775
  // Update description of dest to indicate squash (middleware will sync to Git)
9429
10776
  destChange.description += `\n\n(squashed from ${source.slice(0, 8)})`;
9430
10777
  await graph.updateChange(destChange);
@@ -9466,6 +10813,7 @@ async function createJJ(options) {
9466
10813
  user: await getUserOplogInfo(),
9467
10814
  description: `squash ${source.slice(0, 8)} into ${destChangeId.slice(0, 8)}`,
9468
10815
  parents: [],
10816
+ changeSnapshot,
9469
10817
  view: {
9470
10818
  bookmarks: {},
9471
10819
  remoteBookmarks: {},
@@ -9544,15 +10892,29 @@ async function createJJ(options) {
9544
10892
  timestamp: new Date().toISOString(),
9545
10893
  });
9546
10894
 
10895
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
10896
+ // reverse the abandon (un-abandon this change, un-reparent children)
10897
+ // — see issue #29.
10898
+ /** @type {Record<string, any>} */
10899
+ const changeSnapshot = { [changeId]: structuredClone(change) };
10900
+
9547
10901
  change.abandoned = true;
9548
10902
  await graph.updateChange(change);
9549
10903
 
10904
+ // Re-parent children onto the abandoned change's own parent(s)
10905
+ // (issue #30: this used to leave children pointing at the now-
10906
+ // abandoned commit). Matches real `jj abandon`: the abandoned
10907
+ // change is elided from the graph and its children attach directly
10908
+ // to whatever it was attached to.
10909
+ await reparentChildrenOnto(changeId, change.parents, changeSnapshot);
10910
+
9550
10911
  // Record operation
9551
10912
  await oplog.recordOperation({
9552
10913
  timestamp: new Date().toISOString(),
9553
10914
  user: { name: user.name, email: user.email, hostname: 'localhost' },
9554
10915
  description: `abandon change ${changeId.slice(0, 8)}`,
9555
10916
  parents: [],
10917
+ changeSnapshot,
9556
10918
  view: {
9557
10919
  bookmarks: {},
9558
10920
  remoteBookmarks: {},
@@ -9577,6 +10939,206 @@ async function createJJ(options) {
9577
10939
  return change;
9578
10940
  },
9579
10941
 
10942
+ /**
10943
+ * Converge divergent copies of a change (matches `jj converge`, jj
10944
+ * v0.45.0+ — issue #32, extended for N-way per the isomorphic-jj v0.45
10945
+ * catch-up follow-up) — resolves ANY number of divergent copies of one
10946
+ * changeId in a single call. Real jj's own CLI currently only
10947
+ * converges one change-id per invocation (an explicit TODO in its own
10948
+ * source as of v0.45.1: "consider adding logic to deal with more than
10949
+ * one divergent change-id in one invocation"), which is what fixed
10950
+ * this package's own changeId-at-a-time API design — but its
10951
+ * underlying engine (`TruncatedEvolutionGraph`/`find_divergent_changes`)
10952
+ * already resolves every divergent revision of THAT one change-id
10953
+ * together, not just a pair. This generalizes the same per-path rule
10954
+ * accordingly: two-way "does the other side's value differ from base"
10955
+ * becomes N-way "how many DISTINCT values, across every copy, differ
10956
+ * from base" — 0 means nobody touched it, 1 means exactly one copy (or
10957
+ * several copies agreeing on the same new value) changed it and that
10958
+ * value wins, 2+ is a genuine conflict between that many copies.
10959
+ *
10960
+ * A change is divergent when more than one visible commit shares its
10961
+ * change id (see the `divergent()` revset, and
10962
+ * ChangeGraph.addDivergentCopy() for how a divergent copy gets added
10963
+ * in the first place — nothing in this library creates one on its
10964
+ * own yet; this resolves one once something has, e.g. a future git
10965
+ * fetch/concurrent-operation integration).
10966
+ *
10967
+ * Non-interactive-mode convention, matching this package's other
10968
+ * ambiguous-outcome APIs: like merge(), a genuine per-path conflict is
10969
+ * returned as data (`{ resolved: false, conflicts }`), not thrown —
10970
+ * you get a result back either way and decide what to do with
10971
+ * unresolved conflicts. What DOES throw is anything that isn't even a
10972
+ * well-formed convergence attempt (no divergence to resolve at all) —
10973
+ * the same category as merge()'s own `MERGE_ERROR` throw for "no
10974
+ * common ancestor found".
10975
+ *
10976
+ * @param {Record<string, any>|string} args - `{ changeId }`, or a bare changeId string
10977
+ * @returns {Promise<Object>} `{ changeId, resolved, conflicts, commitId? }`
10978
+ */
10979
+ async converge(args) {
10980
+ const changeId = typeof args === 'string' ? args : args && (args.changeId || args.change);
10981
+ if (!changeId || typeof changeId !== 'string') {
10982
+ throw new JJError('INVALID_ARGUMENT', 'Missing changeId argument', {
10983
+ suggestion: 'Provide the divergent changeId to converge: { changeId: "..." }',
10984
+ });
10985
+ }
10986
+
10987
+ await graph.load();
10988
+ await userConfig.load();
10989
+ const siblings = graph.getDivergentSiblings(changeId);
10990
+
10991
+ if (siblings.length < 2) {
10992
+ throw new JJError(
10993
+ 'NOT_DIVERGENT',
10994
+ `Change ${changeId} has no divergent copies to converge`,
10995
+ {
10996
+ changeId,
10997
+ suggestion:
10998
+ 'converge() is only meaningful for a changeId the divergent() revset reports',
10999
+ }
11000
+ );
11001
+ }
11002
+
11003
+ // The primary copy is whichever one getChange() resolves (stored
11004
+ // under the plain changeId key); every other one is a divergent copy
11005
+ // added via addDivergentCopy(). No cap on how many — see the doc
11006
+ // comment above.
11007
+ const primary = /** @type {any} */ (await graph.getChange(changeId));
11008
+ const others = siblings.filter((/** @type {any} */ s) => s.commitId !== primary.commitId);
11009
+
11010
+ // Base for the N-way merge: what this change looked like before it
11011
+ // diverged. This package doesn't retain historical snapshots (only
11012
+ // current content), so — same pragmatic choice rebase() makes for
11013
+ // "old base" — use the shared parent's current content; divergent
11014
+ // copies realistically share the same parent, since they're all
11015
+ // rewrites of one prior position in history, not unrelated commits
11016
+ // that happen to collide. Applied uniformly to every copy, not
11017
+ // per-copy — the same simplification the original two-way version
11018
+ // made, just not newly introduced by going N-way.
11019
+ const baseParentId = primary.parents && primary.parents[0];
11020
+ const baseParentChange = baseParentId ? await graph.getChange(baseParentId) : null;
11021
+ const baseFiles = new Map(
11022
+ Object.entries((baseParentChange && baseParentChange.fileSnapshot) || {})
11023
+ );
11024
+
11025
+ // Every copy (primary + others), each as its own file map, paired
11026
+ // with its commitId for conflict reporting.
11027
+ const copies = [primary, ...others].map((/** @type {any} */ c) => ({
11028
+ commitId: c.commitId,
11029
+ files: new Map(Object.entries(c.fileSnapshot || {})),
11030
+ }));
11031
+
11032
+ await conflicts.load();
11033
+ const conflictsSnapshot = {
11034
+ conflicts: Object.fromEntries(conflicts.conflicts),
11035
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
11036
+ };
11037
+
11038
+ const allPaths = new Set([
11039
+ ...baseFiles.keys(),
11040
+ ...copies.flatMap((c) => [...c.files.keys()]),
11041
+ ]);
11042
+
11043
+ /** @type {Record<string, string>} */
11044
+ const mergedSnapshot = {};
11045
+ /** @type {any[]} */
11046
+ const detectedConflicts = [];
11047
+
11048
+ for (const filePath of allPaths) {
11049
+ const baseValue = baseFiles.get(filePath);
11050
+ // Every DISTINCT value across all copies that differs from base,
11051
+ // each paired with (one of) the commitId(s) that has it — mirrors
11052
+ // ConflictModel._detectPathConflict's two-way "no conflict" rules,
11053
+ // generalized: 0 distinct changed values means nobody touched it
11054
+ // (base wins, i.e. nothing to write), 1 means only one side (or
11055
+ // several copies agreeing) changed it (that value wins), 2+ is a
11056
+ // genuine conflict.
11057
+ const changed = new Map();
11058
+ for (const copy of copies) {
11059
+ const value = copy.files.get(filePath);
11060
+ if (value !== baseValue && !changed.has(value)) {
11061
+ changed.set(value, copy.commitId);
11062
+ }
11063
+ }
11064
+
11065
+ if (changed.size === 0) {
11066
+ if (baseValue !== undefined) mergedSnapshot[filePath] = baseValue;
11067
+ } else if (changed.size === 1) {
11068
+ const [[value]] = changed;
11069
+ if (value !== undefined) mergedSnapshot[filePath] = value;
11070
+ } else {
11071
+ const versions = [...changed.entries()].map(([content, commitId]) => ({
11072
+ commitId,
11073
+ content,
11074
+ }));
11075
+ detectedConflicts.push(conflicts.createNWayConflict(filePath, baseValue, versions));
11076
+ }
11077
+ }
11078
+
11079
+ if (detectedConflicts.length > 0) {
11080
+ // Matches real jj: non-interactive mode aborts automatic
11081
+ // resolution rather than guessing — but per this package's own
11082
+ // merge() convention, that's reported back as data, not thrown.
11083
+ for (const conflict of detectedConflicts) {
11084
+ await conflicts.addConflict(conflict);
11085
+ }
11086
+ await oplog.recordOperation({
11087
+ timestamp: new Date().toISOString(),
11088
+ user: await getUserOplogInfo(),
11089
+ description: `converge ${changeId.slice(0, 8)} (unresolved)`,
11090
+ parents: [],
11091
+ conflictsSnapshot,
11092
+ view: {
11093
+ bookmarks: {},
11094
+ remoteBookmarks: {},
11095
+ heads: [],
11096
+ workingCopy: workingCopy.getCurrentChangeId(),
11097
+ },
11098
+ });
11099
+ return { changeId, resolved: false, conflicts: detectedConflicts };
11100
+ }
11101
+
11102
+ const user = /** @type {any} */ (userConfig.getUser());
11103
+ const changeSnapshot = { [changeId]: structuredClone(primary) };
11104
+ const otherIds = others.map((/** @type {any} */ o) => o.commitId.slice(0, 8)).join(', ');
11105
+
11106
+ const convergedChange = {
11107
+ ...primary,
11108
+ commitId: randomHex(20),
11109
+ fileSnapshot: mergedSnapshot,
11110
+ description: primary.description + `\n\n(converged divergent copies: ${otherIds})`,
11111
+ timestamp: new Date().toISOString(),
11112
+ committer: {
11113
+ name: user.name,
11114
+ email: user.email,
11115
+ timestamp: new Date().toISOString(),
11116
+ },
11117
+ divergent: false,
11118
+ };
11119
+ await graph.updateChange(convergedChange);
11120
+ for (const other of others) {
11121
+ await graph.deleteDivergentCopy(changeId, other.commitId);
11122
+ }
11123
+
11124
+ await oplog.recordOperation({
11125
+ timestamp: new Date().toISOString(),
11126
+ user: await getUserOplogInfo(),
11127
+ description: `converge ${changeId.slice(0, 8)}`,
11128
+ parents: [],
11129
+ changeSnapshot,
11130
+ conflictsSnapshot,
11131
+ view: {
11132
+ bookmarks: {},
11133
+ remoteBookmarks: {},
11134
+ heads: [changeId],
11135
+ workingCopy: workingCopy.getCurrentChangeId(),
11136
+ },
11137
+ });
11138
+
11139
+ return { changeId, resolved: true, conflicts: [], commitId: convergedChange.commitId };
11140
+ },
11141
+
9580
11142
  /**
9581
11143
  * Backout a change - create a new change that reverses the effect of the specified change
9582
11144
  *
@@ -11038,7 +12600,7 @@ async function createJJ(options) {
11038
12600
  .split('/')
11039
12601
  .pop()
11040
12602
  .replace(/\.git$/, '');
11041
- const fullCloneDir = path.join(dir, cloneDir);
12603
+ const fullCloneDir = join(dir, cloneDir);
11042
12604
 
11043
12605
  // Clone using isomorphic-git
11044
12606
  await git.clone({
@@ -11416,8 +12978,14 @@ async function createJJ(options) {
11416
12978
  * @returns {Promise<Object>} Repository root information
11417
12979
  */
11418
12980
  async root() {
11419
- // Return the directory containing .git
11420
- const gitDir = path.join(dir, '.git');
12981
+ // `dir` is a real host filesystem path (unlike the repo-relative
12982
+ // git/jj paths `path` — posix-path.js — is for), so it needs the
12983
+ // host's own separator here, not always '/': on Windows Node's real
12984
+ // `fs` still resolves a forward-slash-joined path fine, but this
12985
+ // method hands the string back to the caller as data, and jj's own
12986
+ // `git root` returns a platform-native path there.
12987
+ const isWin32 = typeof process !== 'undefined' && process.platform === 'win32';
12988
+ const gitDir = isWin32 ? `${dir.replace(/[\\/]+$/, '')}\\.git` : join(dir, '.git');
11421
12989
 
11422
12990
  try {
11423
12991
  await fs.promises.access(gitDir);
@@ -11496,7 +13064,7 @@ async function createJJ(options) {
11496
13064
  const wcFiles = await workingCopy.listFiles();
11497
13065
  for (const file of wcFiles) {
11498
13066
  try {
11499
- const content = await fs.promises.readFile(path.join(dir, file), 'utf-8');
13067
+ const content = await fs.promises.readFile(join(dir, file), 'utf8');
11500
13068
  leftFiles.set(file, content);
11501
13069
  } catch (error) {
11502
13070
  // File might be deleted or binary - throw error for explicit handling
@@ -11639,7 +13207,7 @@ async function createJJ(options) {
11639
13207
  }
11640
13208
 
11641
13209
  // Write resolved content to file
11642
- await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf-8');
13210
+ await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
11643
13211
 
11644
13212
  // Mark conflict as resolved
11645
13213
  await conflicts.resolveConflict(args.conflictId, 'manual');
@@ -11682,7 +13250,7 @@ async function createJJ(options) {
11682
13250
  const resolvedContent = _resolveWithStrategy(conflict, args.strategy);
11683
13251
 
11684
13252
  // Write resolved content
11685
- await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf-8');
13253
+ await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
11686
13254
 
11687
13255
  // Mark as resolved
11688
13256
  await conflicts.resolveConflict(conflict.conflictId, args.strategy);
@@ -12004,7 +13572,7 @@ async function createJJ(options) {
12004
13572
  );
12005
13573
  }
12006
13574
 
12007
- const fullPath = path.join(dir, args.path);
13575
+ const fullPath = join(dir, args.path);
12008
13576
 
12009
13577
  // Convert string mode to number if needed
12010
13578
  const mode = typeof args.mode === 'string' ? parseInt(args.mode, 8) : args.mode;
@@ -12115,14 +13683,14 @@ async function createJJ(options) {
12115
13683
  if (change.fileSnapshot) {
12116
13684
  for (const [filePath, content] of Object.entries(change.fileSnapshot)) {
12117
13685
  try {
12118
- const fullPath = path.join(args.path, filePath);
13686
+ const fullPath = join(args.path, filePath);
12119
13687
 
12120
13688
  // Ensure directory exists
12121
13689
  const pathParts = filePath.split('/');
12122
13690
  if (pathParts.length > 1) {
12123
13691
  const dirPath = pathParts.slice(0, -1).join('/');
12124
- const fullDirPath = path.join(args.path, dirPath);
12125
- await fs.promises.mkdir(fullDirPath, { recursive: true });
13692
+ const fullDirPath = join(args.path, dirPath);
13693
+ await mkdirp(fs, fullDirPath);
12126
13694
  }
12127
13695
 
12128
13696
  // Write file content