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