isomorphic-jj 1.8.0 → 1.9.2

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>} */
@@ -764,6 +991,44 @@ class ChangeGraph {
764
991
  */
765
992
 
766
993
 
994
+ /**
995
+ * Normalize an mtime value to a millisecond epoch number so two stat()
996
+ * results (or a stat() result and a stored value) can be compared by VALUE
997
+ * instead of by object identity (see issue #41).
998
+ *
999
+ * `fs.promises.stat()` returns a fresh `Date` instance on every call, so two
1000
+ * separate stats of the same unchanged file are never `===`/`!==`-equal even
1001
+ * when the underlying timestamp is identical. Additionally, once a tracked
1002
+ * mtime has been through a `JSON.stringify`/`JSON.parse` round-trip (Storage
1003
+ * persists working-copy state as JSON), a `Date` becomes an ISO string, not a
1004
+ * `Date` — so the stored side of the comparison may be a `Date`, a plain
1005
+ * epoch-ms number (this module now stores numbers going forward), or an ISO
1006
+ * string (older/reloaded-from-disk state). This normalizes all three to a
1007
+ * comparable number.
1008
+ *
1009
+ * Deliberately duck-types the Date case (`typeof value.getTime ===
1010
+ * 'function'`) instead of `value instanceof Date`: Node's built-in `fs`
1011
+ * module constructs `Stats.mtime` using its own realm's `Date`, which is not
1012
+ * always the same `Date` constructor as this module's under test runners
1013
+ * that sandbox each test file in its own VM context (observed under Jest
1014
+ * with `--experimental-vm-modules`) — there, a real `Date` instance from
1015
+ * `fs.promises.stat()` fails `instanceof Date` here even though it's a
1016
+ * genuine Date. Duck-typing survives that realm mismatch.
1017
+ *
1018
+ * @param {Date|number|string|undefined} value
1019
+ * @returns {number}
1020
+ */
1021
+ function mtimeMs(value) {
1022
+ if (value === null || value === undefined) return NaN;
1023
+ if (typeof value === 'number') return value;
1024
+ if (typeof value === 'string') return new Date(value).getTime();
1025
+ // Per the type contract above, anything left is Date-like -- duck-typed
1026
+ // (see the comment above) rather than `instanceof Date`, so no runtime
1027
+ // check is needed here either; a value outside this contract is a caller
1028
+ // bug, not something to silently coerce.
1029
+ return /** @type {any} */ (value).getTime();
1030
+ }
1031
+
767
1032
  class WorkingCopy {
768
1033
  /**
769
1034
  * @param {Storage} storage - Storage manager instance
@@ -935,8 +1200,10 @@ class WorkingCopy {
935
1200
  try {
936
1201
  const stats = await this.fs.promises.stat(fullPath);
937
1202
 
938
- // Fast path: check mtime and size
939
- if (stats.mtime !== tracked.mtime || stats.size !== tracked.size) {
1203
+ // Fast path: check mtime and size. Compare by VALUE (see mtimeMs
1204
+ // above) — comparing the raw stat()/stored values with !== is
1205
+ // always "different" even when nothing changed (issue #41).
1206
+ if (mtimeMs(stats.mtime) !== mtimeMs(tracked.mtime) || stats.size !== tracked.size) {
940
1207
  modified.push(path);
941
1208
  }
942
1209
  } catch (error) {
@@ -1047,12 +1314,19 @@ class WorkingCopy {
1047
1314
  }
1048
1315
 
1049
1316
  const prev = this.state.fileStates[rel];
1050
- const next = { mtime: stats.mtime, size: stats.size, mode: stats.mode };
1317
+ // Store mtime as a plain epoch-ms number (not the raw stat() value):
1318
+ // numbers round-trip through JSON exactly, so state reloaded from disk
1319
+ // later compares identically to state still cached in-memory. Goes
1320
+ // through mtimeMs() rather than assuming `.getTime()` exists directly —
1321
+ // some fs implementations (e.g. the in-memory mock used in tests)
1322
+ // return mtime as a plain epoch-ms number already, not a Date (see
1323
+ // mtimeMs above and issue #41).
1324
+ const next = { mtime: mtimeMs(stats.mtime), size: stats.size, mode: stats.mode };
1051
1325
 
1052
1326
  if (!prev) {
1053
1327
  this.state.fileStates[rel] = next;
1054
1328
  added.push(rel);
1055
- } else if (prev.mtime !== stats.mtime || prev.size !== stats.size) {
1329
+ } else if (mtimeMs(stats.mtime) !== mtimeMs(prev.mtime) || prev.size !== stats.size) {
1056
1330
  this.state.fileStates[rel] = next;
1057
1331
  modified.push(rel);
1058
1332
  }
@@ -1186,61 +1460,6 @@ class WorkingCopy {
1186
1460
  }
1187
1461
  }
1188
1462
 
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
1463
  /**
1245
1464
  * OperationLog - Manages the append-only operation log
1246
1465
  *
@@ -1857,11 +2076,7 @@ class TagStore {
1857
2076
  async save(tags) {
1858
2077
  // Ensure the store directory exists
1859
2078
  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
- }
2079
+ await mkdirp(this.fs, storeDir);
1865
2080
  await this.fs.promises.writeFile(
1866
2081
  this.tagsFile,
1867
2082
  JSON.stringify({ tags, tracked: this.tracking || {} }, null, 2)
@@ -2500,9 +2715,12 @@ class RevsetEngine {
2500
2715
  const unquote = (/** @type {string} */ s) => s.trim().replace(/^['"]|['"]$/g, '');
2501
2716
 
2502
2717
  switch (name) {
2503
- case 'all':
2718
+ case 'all': {
2504
2719
  await this.graph.load();
2505
- return this.graph.getAll().map((c) => c.changeId);
2720
+ const allChanges = this.graph.getAll();
2721
+ const hidden = await this._computeHiddenOrphans(allChanges);
2722
+ return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
2723
+ }
2506
2724
 
2507
2725
  // `builtin_log()` (jj v0.44) is the revset the built-in `jj log` uses
2508
2726
  // when no user override is configured. isomorphic-jj's `log()` already
@@ -2510,9 +2728,12 @@ class RevsetEngine {
2510
2728
  // `builtin_log()` is a pure alias for that same default — this lets
2511
2729
  // callers compose it (e.g. `builtin_log() & mine()`) instead of
2512
2730
  // duplicating `all()`.
2513
- case 'builtin_log':
2731
+ case 'builtin_log': {
2514
2732
  await this.graph.load();
2515
- return this.graph.getAll().map((c) => c.changeId);
2733
+ const allChanges = this.graph.getAll();
2734
+ const hidden = await this._computeHiddenOrphans(allChanges);
2735
+ return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
2736
+ }
2516
2737
 
2517
2738
  case 'none':
2518
2739
  return [];
@@ -2627,15 +2848,27 @@ class RevsetEngine {
2627
2848
  }
2628
2849
 
2629
2850
  case 'divergent': {
2851
+ // See issue #32 / converge(): a divergent change has more than one
2852
+ // visible commit sharing its changeId (added via
2853
+ // ChangeGraph.addDivergentCopy(), which is why counting `changeId`
2854
+ // occurrences across getAll() actually finds something now —
2855
+ // every other change has exactly one entry here). `c.divergent ===
2856
+ // true` also matches directly, since addDivergentCopy() sets that
2857
+ // flag on the copy it adds; the count check catches the *primary*
2858
+ // copy too (it's never itself flagged, since nothing about IT
2859
+ // changed) so both/all copies of a divergent changeId are
2860
+ // reported, not just the one(s) added after the first.
2630
2861
  await this.graph.load();
2631
2862
  const all = this.graph.getAll();
2632
2863
  const counts = new Map();
2633
2864
  for (const c of all) {
2634
2865
  counts.set(c.changeId, (counts.get(c.changeId) || 0) + 1);
2635
2866
  }
2636
- return all
2637
- .filter((c) => c.divergent === true || counts.get(c.changeId) > 1)
2638
- .map((c) => c.changeId);
2867
+ const matched = all.filter((c) => c.divergent === true || counts.get(c.changeId) > 1);
2868
+ // De-duplicate: multiple copies of the same divergent changeId
2869
+ // would otherwise each contribute their own (identical) changeId
2870
+ // to the result.
2871
+ return Array.from(new Set(matched.map((c) => c.changeId)));
2639
2872
  }
2640
2873
 
2641
2874
  case 'merges':
@@ -2961,6 +3194,108 @@ class RevsetEngine {
2961
3194
  return ancestors;
2962
3195
  }
2963
3196
 
3197
+ /**
3198
+ * Change IDs that `all()`/`builtin_log()` (and therefore the default
3199
+ * `jj.log()`) should exclude — issue #37(b).
3200
+ *
3201
+ * undo()/redo()/operations.restore() deliberately don't delete a change
3202
+ * record for a `new()`/`squash()` that gets reverted — they only un-ref
3203
+ * it (restore the working-copy pointer/heads that made it reachable),
3204
+ * matching real jj where an unreachable change stays resolvable by its
3205
+ * change id (hidden, not gone) until explicitly gc'd — see
3206
+ * computeGraphReversal's doc comment in repository.js. But that means
3207
+ * the orphaned change record lingers in the graph, and before this fix
3208
+ * `all()` (a flat `graph.getAll()`) surfaced it in `log()` right
3209
+ * alongside everything still actually reachable.
3210
+ *
3211
+ * Real jj hides a commit from the default log when it is BOTH:
3212
+ * - unreachable from any visible head (not itself a head, and not an
3213
+ * ancestor of one), AND
3214
+ * - "discardable": empty (no file diff versus its own parent) and
3215
+ * undescribed.
3216
+ * Merge commits (2+ parents) are conservatively never treated as
3217
+ * "empty" here — real jj's diff-emptiness rule for merges is more
3218
+ * involved and isn't needed by anything this package creates today.
3219
+ *
3220
+ * "Visible head" here deliberately does NOT mean "any change with no
3221
+ * children" (that's what the `visible_heads()` revset function computes,
3222
+ * structurally, from the raw graph) — an orphan `new()`/`squash()`
3223
+ * change that undo() un-referenced has no children EITHER, so it would
3224
+ * trivially count as its own head under that definition and never be
3225
+ * excludable. Real jj instead tracks an explicit, maintained set of
3226
+ * heads in its View (updated as operations run — abandoning/squashing a
3227
+ * change removes it, rebasing children onto a new parent removes the
3228
+ * old parent, etc.). This package doesn't thread the operation log's
3229
+ * `view.heads` through to the revset engine, but the working-copy
3230
+ * pointer plus every bookmark/tag target is the closest available
3231
+ * proxy for "what's actually still in play" — anything only reachable
3232
+ * from those, not from every dangling graph leaf.
3233
+ *
3234
+ * @param {any[]} allChanges - `graph.getAll()`
3235
+ * @returns {Promise<Set<string>>} change IDs to exclude
3236
+ */
3237
+ async _computeHiddenOrphans(allChanges) {
3238
+ const trackedHeads = new Set();
3239
+ try {
3240
+ const wcId = this.workingCopy && this.workingCopy.getCurrentChangeId();
3241
+ if (wcId) trackedHeads.add(wcId);
3242
+ } catch {
3243
+ // working copy not loaded/available — fine, just no wc-based root.
3244
+ }
3245
+ if (this.bookmarkStore) {
3246
+ await this.bookmarkStore.load();
3247
+ for (const bookmark of await this.bookmarkStore.list()) {
3248
+ if (bookmark && bookmark.changeId) trackedHeads.add(bookmark.changeId);
3249
+ }
3250
+ }
3251
+ if (this.tagStore) {
3252
+ for (const tag of await this.tagStore.list()) {
3253
+ if (tag && tag.changeId) trackedHeads.add(tag.changeId);
3254
+ }
3255
+ }
3256
+
3257
+ const reachable = new Set();
3258
+ for (const headId of trackedHeads) {
3259
+ for (const id of await this.getAncestors(headId)) {
3260
+ reachable.add(id);
3261
+ }
3262
+ }
3263
+
3264
+ // Sentinel "no message given" descriptions this package itself writes
3265
+ // (new()'s '(no description)', squash()'s post-squash working-copy
3266
+ // change '(no description set)') are, for visibility purposes, just
3267
+ // as undescribed as a genuinely empty/missing description field.
3268
+ const UNDESCRIBED = new Set([undefined, null, '', '(no description)', '(no description set)']);
3269
+
3270
+ const byId = new Map(allChanges.map((c) => [c.changeId, c]));
3271
+ const isEmpty = (/** @type {any} */ change) => {
3272
+ const parents = change.parents || [];
3273
+ if (parents.length === 0) {
3274
+ return !change.fileSnapshot || Object.keys(change.fileSnapshot).length === 0;
3275
+ }
3276
+ if (parents.length > 1) return false; // merges: not handled by this rule
3277
+ const parent = byId.get(parents[0]);
3278
+ const parentSnapshot = (parent && parent.fileSnapshot) || {};
3279
+ const ownSnapshot = change.fileSnapshot || {};
3280
+ const parentKeys = Object.keys(parentSnapshot);
3281
+ const ownKeys = Object.keys(ownSnapshot);
3282
+ if (parentKeys.length !== ownKeys.length) return false;
3283
+ return parentKeys.every((k) => parentSnapshot[k] === ownSnapshot[k]);
3284
+ };
3285
+
3286
+ const hidden = new Set();
3287
+ for (const change of allChanges) {
3288
+ if (
3289
+ !reachable.has(change.changeId) &&
3290
+ isEmpty(change) &&
3291
+ UNDESCRIBED.has(change.description)
3292
+ ) {
3293
+ hidden.add(change.changeId);
3294
+ }
3295
+ }
3296
+ return hidden;
3297
+ }
3298
+
2964
3299
  /**
2965
3300
  * Resolve a revset argument to a set of change IDs (v1.5).
2966
3301
  *
@@ -3689,43 +4024,221 @@ class RevsetEngine {
3689
4024
  }
3690
4025
 
3691
4026
  /**
3692
- * ConflictModel - First-class conflict handling
4027
+ * Minimal POSIX-only path utilities (join / dirname / resolve).
3693
4028
  *
3694
- * In JJ, conflicts are not errors but structured data that can be:
3695
- * - Stored in commits
3696
- * - Rebased along with changes
3697
- * - Resolved incrementally
3698
- * - Committed and shared
4029
+ * Why not Node's `path` module: this library is isomorphic — it also needs
4030
+ * to run in browser bundles (see issue #28), where bare `import path from
4031
+ * 'path'` fails unless a bundler-specific polyfill is configured. Git/jj
4032
+ * repository paths are always POSIX-style (forward slashes), regardless of
4033
+ * host OS, so depending on Node's `path` module was also subtly wrong on
4034
+ * Windows: `path.join()` there uses `path.win32` semantics and would
4035
+ * produce backslash-joined paths for repository-relative paths, which is
4036
+ * not what git/jj object storage or working-copy paths want.
3699
4037
  *
3700
- * This matches JJ's philosophy: conflicts never block you.
4038
+ * These implement the same normalization rules as Node's `path.posix` for
4039
+ * the operations this codebase actually uses.
4040
+ */
4041
+
4042
+ /**
4043
+ * @param {string} path
4044
+ * @returns {string} `path` with any run of `/` collapsed to a single `/`,
4045
+ * and `.`/`..` segments resolved. Preserves a leading `/` (absolute) and
4046
+ * a trailing `/` when present in the input.
3701
4047
  */
4048
+ function normalize(path) {
4049
+ if (path === '') return '.';
4050
+
4051
+ const isAbsolute = path.charCodeAt(0) === 47; // '/'
4052
+ const trailingSlash = path.charCodeAt(path.length - 1) === 47;
4053
+
4054
+ const segments = path.split('/');
4055
+ /** @type {string[]} */
4056
+ const out = [];
4057
+ for (const segment of segments) {
4058
+ if (segment === '' || segment === '.') continue;
4059
+ if (segment === '..') {
4060
+ if (out.length && out[out.length - 1] !== '..') {
4061
+ out.pop();
4062
+ } else if (!isAbsolute) {
4063
+ out.push('..');
4064
+ }
4065
+ continue;
4066
+ }
4067
+ out.push(segment);
4068
+ }
3702
4069
 
4070
+ let result = out.join('/');
4071
+ if (!result && !isAbsolute) result = '.';
4072
+ if (isAbsolute) result = '/' + result;
4073
+ if (trailingSlash && result.charCodeAt(result.length - 1) !== 47) result += '/';
4074
+ return result;
4075
+ }
3703
4076
 
3704
4077
  /**
3705
- * Conflict types supported by JJ
4078
+ * @param {...string} parts
4079
+ * @returns {string} All `parts` joined with `/` and normalized.
3706
4080
  */
3707
- const ConflictType = {
3708
- CONTENT: 'content', // File content conflicts (3-way merge)
3709
- DELETE_MODIFY: 'delete-modify', // File deleted in one side, modified in other
3710
- MODIFY_DELETE: 'modify-delete', // File modified in one side, deleted in other
3711
- ADD_ADD: 'add-add', // Same path added in both sides
3712
- };
4081
+ function join(...parts) {
4082
+ if (parts.length === 0) return '.';
4083
+ const joined = parts.filter((p) => p !== '').join('/');
4084
+ return joined === '' ? '.' : normalize(joined);
4085
+ }
3713
4086
 
3714
4087
  /**
3715
- * ConflictModel manages conflict detection, storage, and resolution
4088
+ * @param {string} path
4089
+ * @returns {string} The directory portion of `path` (Node `path.dirname` semantics).
3716
4090
  */
3717
- class ConflictModel {
3718
- /**
3719
- * @param {any} storage
3720
- * @param {any} fs
3721
- * @param {any} [mergeDriverRegistry]
3722
- */
3723
- constructor(storage, fs, mergeDriverRegistry = null) {
3724
- this.storage = storage;
3725
- this.fs = fs;
3726
- /** @type {any} */
3727
- this.mergeDriverRegistry = mergeDriverRegistry; // v0.5: merge drivers
3728
- this.conflicts = new Map(); // conflictId -> Conflict
4091
+ function dirname(path) {
4092
+ if (path.length === 0) return '.';
4093
+ const isAbsolute = path.charCodeAt(0) === 47;
4094
+ let end = -1;
4095
+ let matchedSlash = true;
4096
+ for (let i = path.length - 1; i >= 1; i--) {
4097
+ if (path.charCodeAt(i) === 47) {
4098
+ if (!matchedSlash) {
4099
+ end = i;
4100
+ break;
4101
+ }
4102
+ } else {
4103
+ matchedSlash = false;
4104
+ }
4105
+ }
4106
+
4107
+ if (end === -1) return isAbsolute ? '/' : '.';
4108
+ if (isAbsolute && end === 0) return '/';
4109
+ return path.slice(0, end);
4110
+ }
4111
+
4112
+ /**
4113
+ * Resolves a sequence of path segments into an absolute path.
4114
+ *
4115
+ * Unlike Node's `path.resolve`, this never consults the real process cwd —
4116
+ * segments are resolved right-to-left, and if no absolute segment is found
4117
+ * the result is left relative to a virtual `/` root (this library always
4118
+ * deals in absolute repository-relative paths, so callers are expected to
4119
+ * supply an absolute base).
4120
+ *
4121
+ * @param {...string} parts
4122
+ * @returns {string}
4123
+ */
4124
+ function resolve(...parts) {
4125
+ let resolved = '';
4126
+ let isAbsolute = false;
4127
+
4128
+ for (let i = parts.length - 1; i >= -1 && !isAbsolute; i--) {
4129
+ const path = i >= 0 ? parts[i] : '/';
4130
+ if (path.length === 0) continue;
4131
+ resolved = path + '/' + resolved;
4132
+ isAbsolute = path.charCodeAt(0) === 47;
4133
+ }
4134
+
4135
+ resolved = normalize(resolved);
4136
+ // normalize() preserves a trailing slash; path.resolve() never returns one
4137
+ // (except for the root itself).
4138
+ if (resolved.length > 1 && resolved.endsWith('/')) {
4139
+ resolved = resolved.slice(0, -1);
4140
+ }
4141
+ return isAbsolute ? resolved : '/' + resolved;
4142
+ }
4143
+
4144
+ /**
4145
+ * Cross-environment byte/string conversion helpers.
4146
+ *
4147
+ * Why not just Node's `Buffer`: it's a Node global that most browser
4148
+ * bundlers (Vite, esbuild, webpack 5+) do not polyfill by default, so code
4149
+ * that calls `Buffer.from(...)` compiles fine but throws `ReferenceError:
4150
+ * Buffer is not defined` at runtime in a browser bundle (see issue #28).
4151
+ *
4152
+ * `utf8Encode`/`utf8Decode` still prefer the real `Buffer` when it's present
4153
+ * (i.e. under Node) so existing behavior — including `.toString()` producing
4154
+ * the original text back, which a plain `Uint8Array` cannot do — is
4155
+ * unchanged there; `typeof Buffer` is safe to evaluate even where `Buffer`
4156
+ * was never declared (unlike referencing it directly), so this doesn't
4157
+ * reintroduce the crash outside Node. `hexToBytes`/`bytesToHex`/`isBytes`
4158
+ * only ever need `Uint8Array`, so they don't need the same branch.
4159
+ */
4160
+ const hasBuffer = typeof Buffer !== 'undefined';
4161
+
4162
+ /**
4163
+ * @param {string} hex - Even-length hex string
4164
+ * @returns {Uint8Array}
4165
+ */
4166
+ function hexToBytes(hex) {
4167
+ const length = hex.length / 2;
4168
+ const bytes = new Uint8Array(length);
4169
+ for (let i = 0; i < length; i++) {
4170
+ bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
4171
+ }
4172
+ return bytes;
4173
+ }
4174
+
4175
+ /**
4176
+ * @param {string} str
4177
+ * @returns {Uint8Array} UTF-8 encoded bytes (a real `Buffer` under Node)
4178
+ */
4179
+ function utf8Encode(str) {
4180
+ return hasBuffer ? Buffer.from(str, 'utf-8') : new TextEncoder().encode(str);
4181
+ }
4182
+
4183
+ /**
4184
+ * @param {Uint8Array} bytes
4185
+ * @returns {string} UTF-8 decoded string
4186
+ */
4187
+ function utf8Decode(bytes) {
4188
+ return hasBuffer ? Buffer.from(bytes).toString('utf-8') : new TextDecoder('utf-8').decode(bytes);
4189
+ }
4190
+
4191
+ /**
4192
+ * True for anything that walks/quacks like binary content — a real Node
4193
+ * `Buffer` (which is itself a `Uint8Array` subclass) or a plain
4194
+ * `Uint8Array`/typed array, as produced by protobufjs in environments
4195
+ * without `Buffer` (e.g. a browser bundle).
4196
+ *
4197
+ * @param {any} value
4198
+ * @returns {boolean}
4199
+ */
4200
+ function isBytes(value) {
4201
+ return value instanceof Uint8Array;
4202
+ }
4203
+
4204
+ /**
4205
+ * ConflictModel - First-class conflict handling
4206
+ *
4207
+ * In JJ, conflicts are not errors but structured data that can be:
4208
+ * - Stored in commits
4209
+ * - Rebased along with changes
4210
+ * - Resolved incrementally
4211
+ * - Committed and shared
4212
+ *
4213
+ * This matches JJ's philosophy: conflicts never block you.
4214
+ */
4215
+
4216
+
4217
+ /**
4218
+ * Conflict types supported by JJ
4219
+ */
4220
+ const ConflictType = {
4221
+ CONTENT: 'content', // File content conflicts (3-way merge)
4222
+ DELETE_MODIFY: 'delete-modify', // File deleted in one side, modified in other
4223
+ MODIFY_DELETE: 'modify-delete', // File modified in one side, deleted in other
4224
+ ADD_ADD: 'add-add', // Same path added in both sides
4225
+ };
4226
+
4227
+ /**
4228
+ * ConflictModel manages conflict detection, storage, and resolution
4229
+ */
4230
+ class ConflictModel {
4231
+ /**
4232
+ * @param {any} storage
4233
+ * @param {any} fs
4234
+ * @param {any} [mergeDriverRegistry]
4235
+ */
4236
+ constructor(storage, fs, mergeDriverRegistry = null) {
4237
+ this.storage = storage;
4238
+ this.fs = fs;
4239
+ /** @type {any} */
4240
+ this.mergeDriverRegistry = mergeDriverRegistry; // v0.5: merge drivers
4241
+ this.conflicts = new Map(); // conflictId -> Conflict
3729
4242
  this.fileConflicts = new Map(); // path -> conflictId
3730
4243
  }
3731
4244
 
@@ -3860,6 +4373,15 @@ class ConflictModel {
3860
4373
  const conflict = this._detectPathConflict(path, baseContent, leftContent, rightContent);
3861
4374
  if (conflict) {
3862
4375
  conflicts.push(conflict);
4376
+ } else if (workingCopyDir) {
4377
+ // No conflict, but "no conflict" doesn't mean "nothing to do": of
4378
+ // _detectPathConflict's null-returning cases, every one *except*
4379
+ // "right side changed, left didn't" already matches what's on disk,
4380
+ // because `leftContent` **is** what's on disk (read straight from
4381
+ // the working copy above) -- so only that one case needs a write.
4382
+ if (baseContent === leftContent && baseContent !== rightContent) {
4383
+ await this._applyCleanResolution(workingCopyDir, path, rightContent);
4384
+ }
3863
4385
  }
3864
4386
  }
3865
4387
 
@@ -3947,6 +4469,30 @@ class ConflictModel {
3947
4469
  return null;
3948
4470
  }
3949
4471
 
4472
+ /**
4473
+ * Create a content conflict with more than two disagreeing versions —
4474
+ * every other conflict in this module has exactly two sides (`left`/
4475
+ * `right`, or `left`/`right` on an add-add), because every OTHER caller
4476
+ * (merge, rebase) only ever three-way-merges two changes at a time.
4477
+ * converge() (issue #32) can face more than two divergent copies of one
4478
+ * change in a single call, so `sides` here is `{ base, versions }`
4479
+ * instead: `versions` is every distinct value found across all copies
4480
+ * that differs from `base`, each paired with (one of) the commitId(s)
4481
+ * that has it.
4482
+ *
4483
+ * @param {string} path
4484
+ * @param {any} base
4485
+ * @param {Array<{commitId: string, content: any}>} versions
4486
+ */
4487
+ createNWayConflict(path, base, versions) {
4488
+ return this._createConflict({
4489
+ type: ConflictType.CONTENT,
4490
+ path,
4491
+ sides: { base, versions },
4492
+ message: `${versions.length} divergent copies disagree on this file's content`,
4493
+ });
4494
+ }
4495
+
3950
4496
  /**
3951
4497
  * Create a conflict object
3952
4498
  *
@@ -4190,6 +4736,36 @@ class ConflictModel {
4190
4736
  return result;
4191
4737
  }
4192
4738
 
4739
+ /**
4740
+ * Apply a clean (non-conflicting) three-way resolution to the working
4741
+ * copy, for the default (no merge driver) path in detectConflicts(): the
4742
+ * "right side wins" case, where the resolved content differs from what's
4743
+ * currently on disk (see the call site for why this is the only clean
4744
+ * case that needs one).
4745
+ *
4746
+ * @param {string} workingCopyDir - Working copy directory
4747
+ * @param {string} filePath - File path
4748
+ * @param {any} content - The winning (right) side's content, or
4749
+ * `undefined` to mean the file was deleted on that side
4750
+ */
4751
+ async _applyCleanResolution(workingCopyDir, filePath, content) {
4752
+ const fullPath = join(workingCopyDir, filePath);
4753
+
4754
+ if (content === undefined) {
4755
+ await this.fs.promises.unlink(fullPath).catch(() => {});
4756
+ return;
4757
+ }
4758
+
4759
+ const dir = dirname(fullPath);
4760
+ await mkdirp(this.fs, dir);
4761
+
4762
+ if (isBytes(content)) {
4763
+ await this.fs.promises.writeFile(fullPath, content);
4764
+ } else {
4765
+ await this.fs.promises.writeFile(fullPath, content, 'utf8');
4766
+ }
4767
+ }
4768
+
4193
4769
  /**
4194
4770
  * Write merge driver result to working copy (v0.5)
4195
4771
  *
@@ -4198,32 +4774,32 @@ class ConflictModel {
4198
4774
  * @param {Record<string, any>} result - Merge result
4199
4775
  */
4200
4776
  async _writeDriverResult(workingCopyDir, filePath, result) {
4201
- const fullPath = path.join(workingCopyDir, filePath);
4777
+ const fullPath = join(workingCopyDir, filePath);
4202
4778
 
4203
4779
  // Ensure directory exists
4204
- const dir = path.dirname(fullPath);
4205
- await this.fs.promises.mkdir(dir, { recursive: true });
4780
+ const dir = dirname(fullPath);
4781
+ await mkdirp(this.fs, dir);
4206
4782
 
4207
4783
  // Write main file
4208
4784
  if (result.content !== null && result.content !== undefined) {
4209
- if (Buffer.isBuffer(result.content)) {
4785
+ if (isBytes(result.content)) {
4210
4786
  await this.fs.promises.writeFile(fullPath, result.content);
4211
4787
  } else {
4212
- await this.fs.promises.writeFile(fullPath, result.content, 'utf-8');
4788
+ await this.fs.promises.writeFile(fullPath, result.content, 'utf8');
4213
4789
  }
4214
4790
  }
4215
4791
 
4216
4792
  // Write additional files if driver provided them
4217
4793
  if (result.additionalFiles) {
4218
4794
  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 });
4795
+ const additionalFullPath = join(workingCopyDir, additionalPath);
4796
+ const additionalDir = dirname(additionalFullPath);
4797
+ await mkdirp(this.fs, additionalDir);
4222
4798
 
4223
- if (Buffer.isBuffer(additionalContent)) {
4799
+ if (isBytes(additionalContent)) {
4224
4800
  await this.fs.promises.writeFile(additionalFullPath, additionalContent);
4225
4801
  } else {
4226
- await this.fs.promises.writeFile(additionalFullPath, additionalContent, 'utf-8');
4802
+ await this.fs.promises.writeFile(additionalFullPath, additionalContent, 'utf8');
4227
4803
  }
4228
4804
  }
4229
4805
  }
@@ -4269,11 +4845,11 @@ function matchesPattern(path, pattern) {
4269
4845
  /**
4270
4846
  * Check if a file is likely binary based on content
4271
4847
  *
4272
- * @param {string|Buffer} content - File content
4848
+ * @param {string|Uint8Array} content - File content
4273
4849
  * @returns {boolean} Whether content appears binary
4274
4850
  */
4275
4851
  function isBinaryContent(content) {
4276
- if (Buffer.isBuffer(content)) return true;
4852
+ if (isBytes(content)) return true;
4277
4853
  if (typeof content !== 'string') return false;
4278
4854
 
4279
4855
  // Check for null bytes (common in binary files)
@@ -4588,9 +5164,9 @@ class MergeDriverRegistry {
4588
5164
  /**
4589
5165
  * Prepare content for driver based on binary/text
4590
5166
  *
4591
- * @param {string|Buffer|null} content - Raw content
4592
- * @param {boolean} isBinary - Whether to return as Buffer
4593
- * @returns {string|Buffer|null} Prepared content
5167
+ * @param {string|Uint8Array|null} content - Raw content
5168
+ * @param {boolean} isBinary - Whether to return as bytes
5169
+ * @returns {string|Uint8Array|null} Prepared content
4594
5170
  */
4595
5171
  prepareContent(content, isBinary) {
4596
5172
  if (content === null || content === undefined) {
@@ -4598,14 +5174,14 @@ class MergeDriverRegistry {
4598
5174
  }
4599
5175
 
4600
5176
  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));
5177
+ // Convert to bytes if needed
5178
+ if (isBytes(content)) return content;
5179
+ if (typeof content === 'string') return utf8Encode(content);
5180
+ return utf8Encode(String(content));
4605
5181
  } else {
4606
5182
  // Convert to string if needed
4607
5183
  if (typeof content === 'string') return content;
4608
- if (Buffer.isBuffer(content)) return content.toString('utf-8');
5184
+ if (isBytes(content)) return utf8Decode(content);
4609
5185
  return String(content);
4610
5186
  }
4611
5187
  }
@@ -4766,25 +5342,25 @@ class WorkspaceManager {
4766
5342
  };
4767
5343
 
4768
5344
  // Create workspace directory
4769
- await this.fs.promises.mkdir(args.path, { recursive: true });
5345
+ await mkdirp(this.fs, args.path);
4770
5346
 
4771
5347
  // 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 });
5348
+ const workspaceCopyDir = join(this.repoDir, '.jj', 'working_copy', workspaceId);
5349
+ await mkdirp(this.fs, workspaceCopyDir);
4774
5350
 
4775
5351
  // Create .git file pointing to main repo's .git directory
4776
5352
  // This allows Git tools to work in the workspace
4777
5353
  // IMPORTANT: Must use absolute paths for Git compatibility
4778
- const gitFilePath = path.join(args.path, '.git');
4779
- const mainGitDir = path.resolve(this.repoDir, '.git');
5354
+ const gitFilePath = join(args.path, '.git');
5355
+ const mainGitDir = resolve(this.repoDir, '.git');
4780
5356
  const gitFileContent = `gitdir: ${mainGitDir}\n`;
4781
5357
  await this.fs.promises.writeFile(gitFilePath, gitFileContent, 'utf8');
4782
5358
 
4783
5359
  // Create .jj file pointing to main repo's .jj directory
4784
5360
  // This allows JJ tools to work in the workspace
4785
5361
  // 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');
5362
+ const jjFilePath = join(args.path, '.jj');
5363
+ const mainJJDir = resolve(this.repoDir, '.jj');
4788
5364
  const jjFileContent = `jjdir: ${mainJJDir}\n`;
4789
5365
  await this.fs.promises.writeFile(jjFilePath, jjFileContent, 'utf8');
4790
5366
 
@@ -5419,6 +5995,575 @@ class UserConfig {
5419
5995
  }
5420
5996
  }
5421
5997
 
5998
+ /**
5999
+ * Precompiled protobuf reflection JSON for local_working_copy.proto
6000
+ *
6001
+ * Generated from src/protos/local_working_copy.proto by scripts/build-protos.mjs. This
6002
+ * is bundled directly (instead of loaded from a filesystem path at runtime)
6003
+ * so that protobuf schema loading works identically in Node and in browser
6004
+ * bundles — protobuf.load() requires filesystem/network access that does
6005
+ * not exist in a browser bundle (see issue #28). Regenerate with:
6006
+ * node scripts/build-protos.mjs
6007
+ */
6008
+
6009
+ var localWorkingCopyJson = {
6010
+ nested: {
6011
+ local_working_copy: {
6012
+ nested: {
6013
+ FileType: {
6014
+ valuesOptions: {
6015
+ Conflict: {
6016
+ deprecated: true,
6017
+ },
6018
+ },
6019
+ values: {
6020
+ Normal: 0,
6021
+ Symlink: 1,
6022
+ Executable: 2,
6023
+ Conflict: 3,
6024
+ GitSubmodule: 4,
6025
+ },
6026
+ },
6027
+ MaterializedConflictData: {
6028
+ fields: {
6029
+ conflictMarkerLen: {
6030
+ type: 'uint32',
6031
+ id: 1,
6032
+ },
6033
+ },
6034
+ },
6035
+ FileState: {
6036
+ fields: {
6037
+ mtimeMillisSinceEpoch: {
6038
+ type: 'int64',
6039
+ id: 1,
6040
+ },
6041
+ size: {
6042
+ type: 'uint64',
6043
+ id: 2,
6044
+ },
6045
+ fileType: {
6046
+ type: 'FileType',
6047
+ id: 3,
6048
+ },
6049
+ materializedConflictData: {
6050
+ type: 'MaterializedConflictData',
6051
+ id: 5,
6052
+ },
6053
+ },
6054
+ reserved: [[4, 4]],
6055
+ },
6056
+ FileStateEntry: {
6057
+ fields: {
6058
+ path: {
6059
+ type: 'string',
6060
+ id: 1,
6061
+ },
6062
+ state: {
6063
+ type: 'FileState',
6064
+ id: 2,
6065
+ },
6066
+ },
6067
+ },
6068
+ SparsePatterns: {
6069
+ fields: {
6070
+ prefixes: {
6071
+ rule: 'repeated',
6072
+ type: 'string',
6073
+ id: 1,
6074
+ },
6075
+ },
6076
+ },
6077
+ TreeState: {
6078
+ fields: {
6079
+ legacyTreeId: {
6080
+ type: 'bytes',
6081
+ id: 1,
6082
+ options: {
6083
+ deprecated: true,
6084
+ },
6085
+ },
6086
+ treeIds: {
6087
+ rule: 'repeated',
6088
+ type: 'bytes',
6089
+ id: 5,
6090
+ },
6091
+ fileStates: {
6092
+ rule: 'repeated',
6093
+ type: 'FileStateEntry',
6094
+ id: 2,
6095
+ },
6096
+ isFileStatesSorted: {
6097
+ type: 'bool',
6098
+ id: 6,
6099
+ },
6100
+ sparsePatterns: {
6101
+ type: 'SparsePatterns',
6102
+ id: 3,
6103
+ },
6104
+ watchmanClock: {
6105
+ type: 'WatchmanClock',
6106
+ id: 4,
6107
+ },
6108
+ },
6109
+ },
6110
+ WatchmanClock: {
6111
+ oneofs: {
6112
+ watchmanClock: {
6113
+ oneof: ['stringClock', 'unixTimestamp'],
6114
+ },
6115
+ },
6116
+ fields: {
6117
+ stringClock: {
6118
+ type: 'string',
6119
+ id: 1,
6120
+ },
6121
+ unixTimestamp: {
6122
+ type: 'int64',
6123
+ id: 2,
6124
+ },
6125
+ },
6126
+ },
6127
+ Checkout: {
6128
+ fields: {
6129
+ operationId: {
6130
+ type: 'bytes',
6131
+ id: 2,
6132
+ },
6133
+ workspaceName: {
6134
+ type: 'string',
6135
+ id: 3,
6136
+ },
6137
+ },
6138
+ reserved: [[1, 1]],
6139
+ },
6140
+ },
6141
+ },
6142
+ },
6143
+ };
6144
+
6145
+ /**
6146
+ * Precompiled protobuf reflection JSON for simple_op_store.proto
6147
+ *
6148
+ * Generated from src/protos/simple_op_store.proto by scripts/build-protos.mjs. This
6149
+ * is bundled directly (instead of loaded from a filesystem path at runtime)
6150
+ * so that protobuf schema loading works identically in Node and in browser
6151
+ * bundles — protobuf.load() requires filesystem/network access that does
6152
+ * not exist in a browser bundle (see issue #28). Regenerate with:
6153
+ * node scripts/build-protos.mjs
6154
+ */
6155
+
6156
+ var simpleOpStoreJson = {
6157
+ nested: {
6158
+ simple_op_store: {
6159
+ nested: {
6160
+ RefConflictLegacy: {
6161
+ fields: {
6162
+ removes: {
6163
+ rule: 'repeated',
6164
+ type: 'bytes',
6165
+ id: 1,
6166
+ options: {
6167
+ deprecated: true,
6168
+ },
6169
+ },
6170
+ adds: {
6171
+ rule: 'repeated',
6172
+ type: 'bytes',
6173
+ id: 2,
6174
+ options: {
6175
+ deprecated: true,
6176
+ },
6177
+ },
6178
+ },
6179
+ },
6180
+ RefConflict: {
6181
+ fields: {
6182
+ removes: {
6183
+ rule: 'repeated',
6184
+ type: 'Term',
6185
+ id: 1,
6186
+ },
6187
+ adds: {
6188
+ rule: 'repeated',
6189
+ type: 'Term',
6190
+ id: 2,
6191
+ },
6192
+ },
6193
+ nested: {
6194
+ Term: {
6195
+ oneofs: {
6196
+ _value: {
6197
+ oneof: ['value'],
6198
+ },
6199
+ },
6200
+ fields: {
6201
+ value: {
6202
+ type: 'bytes',
6203
+ id: 1,
6204
+ options: {
6205
+ proto3_optional: true,
6206
+ },
6207
+ },
6208
+ },
6209
+ },
6210
+ },
6211
+ },
6212
+ RefTarget: {
6213
+ oneofs: {
6214
+ value: {
6215
+ oneof: ['commitId', 'conflictLegacy', 'conflict'],
6216
+ },
6217
+ },
6218
+ fields: {
6219
+ commitId: {
6220
+ type: 'bytes',
6221
+ id: 1,
6222
+ options: {
6223
+ deprecated: true,
6224
+ },
6225
+ },
6226
+ conflictLegacy: {
6227
+ type: 'RefConflictLegacy',
6228
+ id: 2,
6229
+ options: {
6230
+ deprecated: true,
6231
+ },
6232
+ },
6233
+ conflict: {
6234
+ type: 'RefConflict',
6235
+ id: 3,
6236
+ },
6237
+ },
6238
+ },
6239
+ RefTargetTerm: {
6240
+ oneofs: {
6241
+ _value: {
6242
+ oneof: ['value'],
6243
+ },
6244
+ },
6245
+ fields: {
6246
+ value: {
6247
+ type: 'bytes',
6248
+ id: 1,
6249
+ options: {
6250
+ proto3_optional: true,
6251
+ },
6252
+ },
6253
+ },
6254
+ },
6255
+ RemoteRefState: {
6256
+ values: {
6257
+ New: 0,
6258
+ Tracked: 1,
6259
+ },
6260
+ },
6261
+ RemoteBookmark: {
6262
+ oneofs: {
6263
+ _state: {
6264
+ oneof: ['state'],
6265
+ },
6266
+ },
6267
+ fields: {
6268
+ remoteName: {
6269
+ type: 'string',
6270
+ id: 1,
6271
+ },
6272
+ target: {
6273
+ type: 'RefTarget',
6274
+ id: 2,
6275
+ },
6276
+ state: {
6277
+ type: 'RemoteRefState',
6278
+ id: 3,
6279
+ options: {
6280
+ proto3_optional: true,
6281
+ },
6282
+ },
6283
+ },
6284
+ },
6285
+ Bookmark: {
6286
+ fields: {
6287
+ name: {
6288
+ type: 'string',
6289
+ id: 1,
6290
+ },
6291
+ localTarget: {
6292
+ type: 'RefTarget',
6293
+ id: 2,
6294
+ },
6295
+ remoteBookmarks: {
6296
+ rule: 'repeated',
6297
+ type: 'RemoteBookmark',
6298
+ id: 3,
6299
+ options: {
6300
+ deprecated: true,
6301
+ },
6302
+ },
6303
+ },
6304
+ },
6305
+ GitRef: {
6306
+ fields: {
6307
+ name: {
6308
+ type: 'string',
6309
+ id: 1,
6310
+ },
6311
+ commitId: {
6312
+ type: 'bytes',
6313
+ id: 2,
6314
+ options: {
6315
+ deprecated: true,
6316
+ },
6317
+ },
6318
+ target: {
6319
+ type: 'RefTarget',
6320
+ id: 3,
6321
+ },
6322
+ },
6323
+ },
6324
+ RemoteRef: {
6325
+ fields: {
6326
+ name: {
6327
+ type: 'string',
6328
+ id: 1,
6329
+ },
6330
+ targetTerms: {
6331
+ rule: 'repeated',
6332
+ type: 'RefTargetTerm',
6333
+ id: 2,
6334
+ },
6335
+ state: {
6336
+ type: 'RemoteRefState',
6337
+ id: 3,
6338
+ },
6339
+ },
6340
+ },
6341
+ Tag: {
6342
+ fields: {
6343
+ name: {
6344
+ type: 'string',
6345
+ id: 1,
6346
+ },
6347
+ target: {
6348
+ type: 'RefTarget',
6349
+ id: 2,
6350
+ },
6351
+ },
6352
+ },
6353
+ View: {
6354
+ fields: {
6355
+ headIds: {
6356
+ rule: 'repeated',
6357
+ type: 'bytes',
6358
+ id: 1,
6359
+ },
6360
+ wcCommitId: {
6361
+ type: 'bytes',
6362
+ id: 2,
6363
+ options: {
6364
+ deprecated: true,
6365
+ },
6366
+ },
6367
+ wcCommitIds: {
6368
+ keyType: 'string',
6369
+ type: 'bytes',
6370
+ id: 8,
6371
+ },
6372
+ bookmarks: {
6373
+ rule: 'repeated',
6374
+ type: 'Bookmark',
6375
+ id: 5,
6376
+ },
6377
+ localTags: {
6378
+ rule: 'repeated',
6379
+ type: 'Tag',
6380
+ id: 6,
6381
+ },
6382
+ remoteViews: {
6383
+ rule: 'repeated',
6384
+ type: 'RemoteView',
6385
+ id: 11,
6386
+ },
6387
+ gitRefs: {
6388
+ rule: 'repeated',
6389
+ type: 'GitRef',
6390
+ id: 3,
6391
+ },
6392
+ gitHeadLegacy: {
6393
+ type: 'bytes',
6394
+ id: 7,
6395
+ options: {
6396
+ deprecated: true,
6397
+ },
6398
+ },
6399
+ gitHead: {
6400
+ type: 'RefTarget',
6401
+ id: 9,
6402
+ },
6403
+ hasGitRefsMigratedToRemoteTags: {
6404
+ type: 'bool',
6405
+ id: 12,
6406
+ },
6407
+ },
6408
+ reserved: [
6409
+ [4, 4],
6410
+ [10, 10],
6411
+ ],
6412
+ },
6413
+ RemoteView: {
6414
+ fields: {
6415
+ name: {
6416
+ type: 'string',
6417
+ id: 1,
6418
+ },
6419
+ bookmarks: {
6420
+ rule: 'repeated',
6421
+ type: 'RemoteRef',
6422
+ id: 2,
6423
+ },
6424
+ tags: {
6425
+ rule: 'repeated',
6426
+ type: 'RemoteRef',
6427
+ id: 3,
6428
+ },
6429
+ },
6430
+ },
6431
+ Operation: {
6432
+ fields: {
6433
+ viewId: {
6434
+ type: 'bytes',
6435
+ id: 1,
6436
+ },
6437
+ parents: {
6438
+ rule: 'repeated',
6439
+ type: 'bytes',
6440
+ id: 2,
6441
+ },
6442
+ metadata: {
6443
+ type: 'OperationMetadata',
6444
+ id: 3,
6445
+ },
6446
+ commitPredecessors: {
6447
+ rule: 'repeated',
6448
+ type: 'CommitPredecessors',
6449
+ id: 4,
6450
+ },
6451
+ storesCommitPredecessors: {
6452
+ type: 'bool',
6453
+ id: 5,
6454
+ },
6455
+ },
6456
+ },
6457
+ Timestamp: {
6458
+ fields: {
6459
+ millisSinceEpoch: {
6460
+ type: 'int64',
6461
+ id: 1,
6462
+ },
6463
+ tzOffset: {
6464
+ type: 'int32',
6465
+ id: 2,
6466
+ },
6467
+ },
6468
+ },
6469
+ OperationMetadata: {
6470
+ fields: {
6471
+ startTime: {
6472
+ type: 'Timestamp',
6473
+ id: 1,
6474
+ },
6475
+ endTime: {
6476
+ type: 'Timestamp',
6477
+ id: 2,
6478
+ },
6479
+ description: {
6480
+ type: 'string',
6481
+ id: 3,
6482
+ },
6483
+ hostname: {
6484
+ type: 'string',
6485
+ id: 4,
6486
+ },
6487
+ username: {
6488
+ type: 'string',
6489
+ id: 5,
6490
+ },
6491
+ isSnapshot: {
6492
+ type: 'bool',
6493
+ id: 7,
6494
+ },
6495
+ tags: {
6496
+ keyType: 'string',
6497
+ type: 'string',
6498
+ id: 6,
6499
+ },
6500
+ },
6501
+ },
6502
+ CommitPredecessors: {
6503
+ fields: {
6504
+ commitId: {
6505
+ type: 'bytes',
6506
+ id: 1,
6507
+ },
6508
+ predecessorIds: {
6509
+ rule: 'repeated',
6510
+ type: 'bytes',
6511
+ id: 2,
6512
+ },
6513
+ },
6514
+ },
6515
+ },
6516
+ },
6517
+ },
6518
+ };
6519
+
6520
+ /**
6521
+ * Protobuf schema loader
6522
+ *
6523
+ * Builds protobuf.js `Root` instances from precompiled reflection JSON
6524
+ * (see local_working_copy.json.js / simple_op_store.json.js, generated from
6525
+ * the .proto files in this directory) instead of calling `protobuf.load()`
6526
+ * against a filesystem path.
6527
+ *
6528
+ * Why: `protobuf.load()` resolves its argument as a file path (Node `fs`) or
6529
+ * URL (XHR/fetch), which requires either a real filesystem or a network
6530
+ * request. In a bundled browser build there is no filesystem path to read
6531
+ * (see issue #28), and shipping the raw .proto text and fetching it at
6532
+ * runtime would require bundler-specific asset handling. Reflection JSON is
6533
+ * plain data, so it can be imported like any other JS module and works
6534
+ * identically under Node, Jest, and any browser bundler (Vite, esbuild,
6535
+ * webpack, Rollup, ...).
6536
+ *
6537
+ * Regenerate the JSON files after editing a .proto with:
6538
+ * npm run build:protos
6539
+ */
6540
+
6541
+
6542
+ /** @type {protobuf.Root|undefined} */
6543
+ let localWorkingCopyRoot;
6544
+ /** @type {protobuf.Root|undefined} */
6545
+ let simpleOpStoreRoot;
6546
+
6547
+ /**
6548
+ * @returns {protobuf.Root} Root for local_working_copy.proto (Checkout, TreeState, ...)
6549
+ */
6550
+ function getLocalWorkingCopyRoot() {
6551
+ if (!localWorkingCopyRoot) {
6552
+ localWorkingCopyRoot = protobuf.Root.fromJSON(localWorkingCopyJson);
6553
+ }
6554
+ return localWorkingCopyRoot;
6555
+ }
6556
+
6557
+ /**
6558
+ * @returns {protobuf.Root} Root for simple_op_store.proto (Operation, View, ...)
6559
+ */
6560
+ function getSimpleOpStoreRoot() {
6561
+ if (!simpleOpStoreRoot) {
6562
+ simpleOpStoreRoot = protobuf.Root.fromJSON(simpleOpStoreJson);
6563
+ }
6564
+ return simpleOpStoreRoot;
6565
+ }
6566
+
5422
6567
  /**
5423
6568
  * JJ Checkout File Manager
5424
6569
  *
@@ -5427,15 +6572,6 @@ class UserConfig {
5427
6572
  */
5428
6573
 
5429
6574
 
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
6575
  class JJCheckout {
5440
6576
  /**
5441
6577
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5444,7 +6580,6 @@ class JJCheckout {
5444
6580
  constructor(fs, dir) {
5445
6581
  this.fs = fs;
5446
6582
  this.dir = dir;
5447
- this.protoPath = path.join(protoDir$3, 'local_working_copy.proto');
5448
6583
  }
5449
6584
 
5450
6585
  /**
@@ -5455,11 +6590,11 @@ class JJCheckout {
5455
6590
  */
5456
6591
  async writeCheckout(operationId, workspaceName = 'default') {
5457
6592
  // Load protobuf schema
5458
- const root = await protobuf.load(this.protoPath);
6593
+ const root = getLocalWorkingCopyRoot();
5459
6594
  const Checkout = root.lookupType('local_working_copy.Checkout');
5460
6595
 
5461
6596
  // Convert hex operation ID to bytes
5462
- const opIdBuffer = Buffer.from(operationId, 'hex');
6597
+ const opIdBuffer = hexToBytes(operationId);
5463
6598
 
5464
6599
  // Create message (use camelCase for protobufjs)
5465
6600
  const message = Checkout.create({
@@ -5491,7 +6626,7 @@ class JJCheckout {
5491
6626
  const buffer = await this.fs.promises.readFile(checkoutPath);
5492
6627
 
5493
6628
  // Load protobuf schema
5494
- const root = await protobuf.load(this.protoPath);
6629
+ const root = getLocalWorkingCopyRoot();
5495
6630
  const Checkout = root.lookupType('local_working_copy.Checkout');
5496
6631
 
5497
6632
  // Decode
@@ -5519,6 +6654,7 @@ class JJCheckout {
5519
6654
  * calling `writeFile()` directly on the canonical path — see issue #16.
5520
6655
  */
5521
6656
 
6657
+
5522
6658
  /**
5523
6659
  * @param {any} fs - Filesystem implementation (Node fs, LightningFS, etc.)
5524
6660
  * @param {string} targetPath - Canonical path to write to
@@ -5534,7 +6670,7 @@ async function atomicWriteFile(fs, targetPath, content) {
5534
6670
  try {
5535
6671
  const dirPath = targetPath.substring(0, targetPath.lastIndexOf('/'));
5536
6672
  if (dirPath) {
5537
- await fs.promises.mkdir(dirPath, { recursive: true });
6673
+ await mkdirp(fs, dirPath);
5538
6674
  }
5539
6675
 
5540
6676
  await fs.promises.writeFile(tmpPath, content);
@@ -5558,15 +6694,6 @@ async function atomicWriteFile(fs, targetPath, content) {
5558
6694
  */
5559
6695
 
5560
6696
 
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
6697
  class JJTreeState {
5571
6698
  /**
5572
6699
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5575,7 +6702,6 @@ class JJTreeState {
5575
6702
  constructor(fs, dir) {
5576
6703
  this.fs = fs;
5577
6704
  this.dir = dir;
5578
- this.protoPath = path.join(protoDir$2, 'local_working_copy.proto');
5579
6705
  }
5580
6706
 
5581
6707
  /**
@@ -5586,11 +6712,11 @@ class JJTreeState {
5586
6712
  */
5587
6713
  async writeTreeState(treeId, fileStates = []) {
5588
6714
  // Load protobuf schema
5589
- const root = await protobuf.load(this.protoPath);
6715
+ const root = getLocalWorkingCopyRoot();
5590
6716
  const TreeState = root.lookupType('local_working_copy.TreeState');
5591
6717
 
5592
6718
  // Convert hex tree ID to bytes
5593
- const treeIdBuffer = Buffer.from(treeId, 'hex');
6719
+ const treeIdBuffer = hexToBytes(treeId);
5594
6720
 
5595
6721
  // Convert file states to protobuf format
5596
6722
  const fileStateEntries = fileStates.map((fs) => ({
@@ -5636,7 +6762,7 @@ class JJTreeState {
5636
6762
  const buffer = await this.fs.promises.readFile(treeStatePath);
5637
6763
 
5638
6764
  // Load protobuf schema
5639
- const root = await protobuf.load(this.protoPath);
6765
+ const root = getLocalWorkingCopyRoot();
5640
6766
  const TreeState = root.lookupType('local_working_copy.TreeState');
5641
6767
 
5642
6768
  // Decode
@@ -5660,15 +6786,6 @@ class JJTreeState {
5660
6786
  */
5661
6787
 
5662
6788
 
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
6789
  class JJOperationStore {
5673
6790
  /**
5674
6791
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5677,7 +6794,6 @@ class JJOperationStore {
5677
6794
  constructor(fs, dir) {
5678
6795
  this.fs = fs;
5679
6796
  this.dir = dir;
5680
- this.protoPath = path.join(protoDir$1, 'simple_op_store.proto');
5681
6797
  }
5682
6798
 
5683
6799
  /**
@@ -5690,12 +6806,12 @@ class JJOperationStore {
5690
6806
  */
5691
6807
  async writeOperation(operationId, viewId, parentIds, metadata) {
5692
6808
  // Load protobuf schema
5693
- const root = await protobuf.load(this.protoPath);
6809
+ const root = getSimpleOpStoreRoot();
5694
6810
  const Operation = root.lookupType('simple_op_store.Operation');
5695
6811
 
5696
6812
  // Convert hex IDs to bytes
5697
- const viewIdBuffer = Buffer.from(viewId, 'hex');
5698
- const parentBuffers = parentIds.map((id) => Buffer.from(id, 'hex'));
6813
+ const viewIdBuffer = hexToBytes(viewId);
6814
+ const parentBuffers = parentIds.map((id) => hexToBytes(id));
5699
6815
 
5700
6816
  // Create metadata message (use camelCase for protobufjs)
5701
6817
  const metadataMsg = {
@@ -5751,7 +6867,7 @@ class JJOperationStore {
5751
6867
  const buffer = await this.fs.promises.readFile(opPath);
5752
6868
 
5753
6869
  // Load protobuf schema
5754
- const root = await protobuf.load(this.protoPath);
6870
+ const root = getSimpleOpStoreRoot();
5755
6871
  const Operation = root.lookupType('simple_op_store.Operation');
5756
6872
 
5757
6873
  // Decode
@@ -5790,15 +6906,6 @@ class JJOperationStore {
5790
6906
  */
5791
6907
 
5792
6908
 
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
6909
  class JJViewStore {
5803
6910
  /**
5804
6911
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5807,7 +6914,6 @@ class JJViewStore {
5807
6914
  constructor(fs, dir) {
5808
6915
  this.fs = fs;
5809
6916
  this.dir = dir;
5810
- this.protoPath = path.join(protoDir, 'simple_op_store.proto');
5811
6917
  }
5812
6918
 
5813
6919
  /**
@@ -5819,17 +6925,17 @@ class JJViewStore {
5819
6925
  */
5820
6926
  async writeView(viewId, headIds, wcCommitIds = {}) {
5821
6927
  // Load protobuf schema
5822
- const root = await protobuf.load(this.protoPath);
6928
+ const root = getSimpleOpStoreRoot();
5823
6929
  const View = root.lookupType('simple_op_store.View');
5824
6930
 
5825
6931
  // Convert hex IDs to bytes
5826
- const headBuffers = headIds.map((id) => Buffer.from(id, 'hex'));
6932
+ const headBuffers = headIds.map((id) => hexToBytes(id));
5827
6933
 
5828
6934
  // Convert wcCommitIds object to map
5829
6935
  /** @type {Record<string, any>} */
5830
6936
  const wcCommitIdsMap = {};
5831
6937
  for (const [workspace, commitId] of Object.entries(wcCommitIds)) {
5832
- wcCommitIdsMap[workspace] = Buffer.from(commitId, 'hex');
6938
+ wcCommitIdsMap[workspace] = hexToBytes(commitId);
5833
6939
  }
5834
6940
 
5835
6941
  // Create view message (use camelCase for protobufjs)
@@ -5870,7 +6976,7 @@ class JJViewStore {
5870
6976
  const buffer = await this.fs.promises.readFile(viewPath);
5871
6977
 
5872
6978
  // Load protobuf schema
5873
- const root = await protobuf.load(this.protoPath);
6979
+ const root = getSimpleOpStoreRoot();
5874
6980
  const View = root.lookupType('simple_op_store.View');
5875
6981
 
5876
6982
  // Decode
@@ -6566,14 +7672,14 @@ class IsomorphicGitBackend {
6566
7672
  const jjRepo = `${jjDir}/repo`;
6567
7673
 
6568
7674
  // 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 });
7675
+ await mkdirp(this.fs, `${jjRepo}/store`);
7676
+ await mkdirp(this.fs, `${jjRepo}/store/extra/heads`);
7677
+ await mkdirp(this.fs, `${jjRepo}/op_store/operations`);
7678
+ await mkdirp(this.fs, `${jjRepo}/op_store/views`);
7679
+ await mkdirp(this.fs, `${jjRepo}/op_heads/heads`);
7680
+ await mkdirp(this.fs, `${jjRepo}/index`);
7681
+ await mkdirp(this.fs, `${jjRepo}/submodule_store`);
7682
+ await mkdirp(this.fs, `${jjDir}/working_copy`);
6577
7683
 
6578
7684
  // Write type files
6579
7685
  await this.fs.promises.writeFile(`${jjRepo}/store/type`, 'git');
@@ -6600,8 +7706,8 @@ class IsomorphicGitBackend {
6600
7706
  */
6601
7707
  async _createInitialJJState() {
6602
7708
  // 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');
7709
+ const operationId = randomHex(64); // 512-bit = 128 hex chars
7710
+ const viewId = randomHex(64);
6605
7711
  const emptyTreeId = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // Git's empty tree hash
6606
7712
 
6607
7713
  // Get the initial commit from Git
@@ -6613,7 +7719,7 @@ class IsomorphicGitBackend {
6613
7719
  ref: 'HEAD',
6614
7720
  });
6615
7721
  if (headOid) {
6616
- initialCommitId = crypto$1.randomBytes(64).toString('hex');
7722
+ initialCommitId = randomHex(64);
6617
7723
  }
6618
7724
  } catch (error) {
6619
7725
  // No initial commit yet
@@ -6741,60 +7847,320 @@ async function createJJ(options) {
6741
7847
  const workspaces = new WorkspaceManager(storage, fs, dir);
6742
7848
  const userConfig = new UserConfig(storage);
6743
7849
 
6744
- /**
6745
- * Helper to get user info for oplog operations
6746
- * @returns {Promise<import('../types').OperationUser>}
6747
- */
6748
- const getUserOplogInfo = async () => {
6749
- await userConfig.load();
6750
- const user = /** @type {any} */ (userConfig.getUser());
6751
- return { name: user.name, email: user.email, hostname: 'localhost' };
7850
+ /**
7851
+ * Helper to get user info for oplog operations
7852
+ * @returns {Promise<import('../types').OperationUser>}
7853
+ */
7854
+ const getUserOplogInfo = async () => {
7855
+ await userConfig.load();
7856
+ const user = /** @type {any} */ (userConfig.getUser());
7857
+ return { name: user.name, email: user.email, hostname: 'localhost' };
7858
+ };
7859
+
7860
+ /**
7861
+ * Helper to snapshot current filesystem state
7862
+ * This is called BEFORE every operation to enable undo
7863
+ * @returns {Promise<Record<string, string>>}
7864
+ */
7865
+ const snapshotFilesystem = async () => {
7866
+ /** @type {Record<string, string>} */
7867
+ const fileSnapshot = {};
7868
+
7869
+ try {
7870
+ const trackedFiles = await workingCopy.listFiles();
7871
+
7872
+ // Safeguards to prevent excessive memory usage
7873
+ const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB per file
7874
+ const MAX_TOTAL_SIZE = 10 * 1024 * 1024; // 10MB total snapshot size
7875
+ let totalSnapshotSize = 0;
7876
+
7877
+ for (const filePath of trackedFiles) {
7878
+ try {
7879
+ const fullPath = join(dir, filePath);
7880
+ const stats = await fs.promises.stat(fullPath);
7881
+
7882
+ // Skip files that are too large
7883
+ if (stats.size > MAX_FILE_SIZE) {
7884
+ continue;
7885
+ }
7886
+
7887
+ // Stop if total snapshot size would exceed limit
7888
+ if (totalSnapshotSize + stats.size > MAX_TOTAL_SIZE) {
7889
+ break;
7890
+ }
7891
+
7892
+ const content = await fs.promises.readFile(fullPath, 'utf8');
7893
+ fileSnapshot[filePath] = content;
7894
+ totalSnapshotSize += stats.size;
7895
+ } catch (error) {
7896
+ // Skip files that can't be read (binary, deleted, etc.)
7897
+ }
7898
+ }
7899
+ } catch (error) {
7900
+ // If we can't list files, return empty snapshot
7901
+ }
7902
+
7903
+ return fileSnapshot;
7904
+ };
7905
+
7906
+ /**
7907
+ * Fully sync the working directory to a target file snapshot: writes
7908
+ * every file the target has (create/update), AND deletes every currently
7909
+ * tracked file the target does NOT have.
7910
+ *
7911
+ * Both edit() and new() used to only overlay the target's files on top of
7912
+ * whatever was already on disk, so a file that existed in the previously
7913
+ * checked-out change but not in the target leaked into the next
7914
+ * snapshot — see issue #30. undo()/redo() had the same gap restoring
7915
+ * file state — see issue #29. This is the one place that does it
7916
+ * correctly, used by all four.
7917
+ *
7918
+ * @param {Record<string, string>} targetSnapshot - path -> file content
7919
+ * @returns {Promise<void>}
7920
+ */
7921
+ const syncWorkingCopyFiles = async (targetSnapshot) => {
7922
+ const snapshot = targetSnapshot || {};
7923
+ const currentFiles = await workingCopy.listFiles();
7924
+
7925
+ // Remove files that aren't part of the target.
7926
+ for (const filePath of currentFiles) {
7927
+ if (!(filePath in snapshot)) {
7928
+ try {
7929
+ const fullPath = join(dir, filePath);
7930
+ await fs.promises.unlink(fullPath);
7931
+ } catch (error) {
7932
+ // Already gone / inaccessible — fine, we just want it absent.
7933
+ }
7934
+ await workingCopy.untrackFile(filePath);
7935
+ }
7936
+ }
7937
+
7938
+ // Write/update every file the target has.
7939
+ for (const [filePath, content] of Object.entries(snapshot)) {
7940
+ const fullPath = join(dir, filePath);
7941
+ const pathParts = filePath.split('/');
7942
+ if (pathParts.length > 1) {
7943
+ const fullDirPath = join(dir, pathParts.slice(0, -1).join('/'));
7944
+ await mkdirp(fs, fullDirPath);
7945
+ }
7946
+ await fs.promises.writeFile(fullPath, content, 'utf8');
7947
+
7948
+ const stats = await fs.promises.stat(fullPath);
7949
+ await workingCopy.trackFile(filePath, {
7950
+ mtime: stats.mtime,
7951
+ size: stats.size,
7952
+ mode: stats.mode,
7953
+ });
7954
+ }
7955
+ };
7956
+
7957
+ /**
7958
+ * Compute the ChangeGraph/file/conflicts state that existed right after
7959
+ * `landingOp` finished — i.e. what undo()/redo()/operations.restore()
7960
+ * need to re-establish when winding the repository forward or back to
7961
+ * that point (see issue #29).
7962
+ *
7963
+ * Every operation that mutates EXISTING change records in place records
7964
+ * (additively — most operations touch none of this and that's fine,
7965
+ * there's nothing to reverse for them):
7966
+ * - changeSnapshot: { [changeId]: fullRecordBeforeThisOpRan }
7967
+ * - view.fileSnapshot: on-disk file state right BEFORE this op ran
7968
+ * - conflictsSnapshot: ConflictModel state right BEFORE this op ran
7969
+ *
7970
+ * Deliberately NOT reverted: changes an operation newly *created* (via
7971
+ * graph.addChange() — new(), squash()'s synthetic empty working-copy
7972
+ * change, ...). Undoing their creation only un-refs them (restores the
7973
+ * working-copy pointer/heads that made them reachable) — it does not
7974
+ * delete the change record — matching real jj, where an "unreachable"
7975
+ * change stays resolvable by its change id (hidden, not gone) until
7976
+ * explicitly gc'd, and this package's own history-editing tests rely on
7977
+ * exactly that: rebasing onto a change created by a since-undone new()
7978
+ * must still work.
7979
+ *
7980
+ * "The state right after landingOp" is therefore exactly what the FIRST
7981
+ * operation after landingOp recorded as ITS "before" state, merged across
7982
+ * every operation between landingOp and the current head in case
7983
+ * different operations touched different changeIds (a single op's own
7984
+ * snapshot only covers what IT touched). Concretely, per field:
7985
+ * - changeSnapshot: first (earliest / closest-to-landingOp) entry per
7986
+ * changeId wins — later entries in the range reflect that changeId's
7987
+ * state at a LATER point than we're restoring to.
7988
+ * - fileSnapshot / conflictsSnapshot: the first op in the range that
7989
+ * recorded one, since whole-state snapshots aren't per-changeId and
7990
+ * an op that didn't record one simply didn't change that state.
7991
+ *
7992
+ * This same helper backs undo() (landingOp = the parent of whatever's
7993
+ * being undone), redo() (landingOp = the operation a prior undo()
7994
+ * reverted), and operations.restore() (landingOp = the target operation
7995
+ * itself) — one reversal primitive, not three.
7996
+ *
7997
+ * @param {Array<any>} ops - Full chronological operation list (oplog.list())
7998
+ * @param {any} landingOp - The operation whose resulting state to recompute
7999
+ * @returns {{
8000
+ * changeSnapshotToApply: Record<string, any>,
8001
+ * fileSnapshot: Record<string, string>|undefined,
8002
+ * conflictsSnapshot: any,
8003
+ * }}
8004
+ */
8005
+ const computeGraphReversal = (/** @type {any[]} */ ops, /** @type {any} */ landingOp) => {
8006
+ const landingIndex = ops.findIndex((op) => op.id === landingOp.id);
8007
+
8008
+ /** @type {Record<string, any>} */
8009
+ const changeSnapshotToApply = {};
8010
+ /** @type {Record<string, string>|undefined} */
8011
+ let fileSnapshot;
8012
+ /** @type {any} */
8013
+ let conflictsSnapshot;
8014
+
8015
+ for (let i = landingIndex + 1; i < ops.length; i++) {
8016
+ const op = ops[i];
8017
+
8018
+ if (op.changeSnapshot) {
8019
+ for (const [changeId, snapshot] of Object.entries(op.changeSnapshot)) {
8020
+ if (!(changeId in changeSnapshotToApply)) {
8021
+ changeSnapshotToApply[changeId] = snapshot;
8022
+ }
8023
+ }
8024
+ }
8025
+
8026
+ // `!= null` (not `!== undefined`) on purpose: some operations record
8027
+ // an explicit `null` for "I ran, and I deliberately took no snapshot"
8028
+ // (e.g. describe() of a non-working-copy revision) — that's exactly
8029
+ // as uninformative as never having set the field, so keep scanning
8030
+ // rather than "finding" a null snapshot.
8031
+ if (fileSnapshot === undefined && op.view && op.view.fileSnapshot != null) {
8032
+ fileSnapshot = op.view.fileSnapshot;
8033
+ }
8034
+
8035
+ if (conflictsSnapshot === undefined && op.conflictsSnapshot != null) {
8036
+ conflictsSnapshot = op.conflictsSnapshot;
8037
+ }
8038
+ }
8039
+
8040
+ return { changeSnapshotToApply, fileSnapshot, conflictsSnapshot };
6752
8041
  };
6753
8042
 
6754
8043
  /**
6755
- * Helper to snapshot current filesystem state
6756
- * This is called BEFORE every operation to enable undo
6757
- * @returns {Promise<Record<string, string>>}
8044
+ * Apply a reversal computed by computeGraphReversal() to the live graph,
8045
+ * working-copy files, and conflict state. Does NOT touch the working-copy
8046
+ * pointer or bookmarks — callers set those from the landing op's own
8047
+ * `view` directly (pointers, unlike file/graph/conflict content, are
8048
+ * already correct as recorded, see computeGraphReversal's doc comment).
8049
+ *
8050
+ * @param {ReturnType<typeof computeGraphReversal>} reversal
8051
+ * @returns {Promise<void>}
6758
8052
  */
6759
- const snapshotFilesystem = async () => {
6760
- /** @type {Record<string, string>} */
6761
- const fileSnapshot = {};
6762
-
6763
- try {
6764
- const trackedFiles = await workingCopy.listFiles();
8053
+ const applyGraphReversal = async (reversal) => {
8054
+ await graph.load();
8055
+ for (const [changeId, snapshot] of Object.entries(reversal.changeSnapshotToApply)) {
8056
+ if (await graph.getChange(changeId)) {
8057
+ // Bypass the git-sync middleware (baseGraph, not the wrapped
8058
+ // `graph`): undo/restore are pure reverts, and `snapshot` already
8059
+ // carries the change's ORIGINAL commitId from before the reverted
8060
+ // operation(s) ran. Going through graph.updateChange() would fire
8061
+ // onUpdateChange -> syncChangeToGit(), which mints a brand-new
8062
+ // commit (and thus a new commitId) from the CURRENT tree instead
8063
+ // of restoring the one that was actually there — see issue #37(c):
8064
+ // "undo/restore give reverted commits new commit IDs". Writing the
8065
+ // snapshot verbatim via baseGraph puts the original commitId back.
8066
+ await baseGraph.updateChange(snapshot);
8067
+ }
8068
+ }
6765
8069
 
6766
- // Safeguards to prevent excessive memory usage
6767
- const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB per file
6768
- const MAX_TOTAL_SIZE = 10 * 1024 * 1024; // 10MB total snapshot size
6769
- let totalSnapshotSize = 0;
8070
+ if (reversal.fileSnapshot !== undefined) {
8071
+ await syncWorkingCopyFiles(reversal.fileSnapshot);
8072
+ }
6770
8073
 
6771
- for (const filePath of trackedFiles) {
6772
- try {
6773
- const fullPath = path.join(dir, filePath);
6774
- const stats = await fs.promises.stat(fullPath);
8074
+ await conflicts.load();
8075
+ if (reversal.conflictsSnapshot !== undefined) {
8076
+ conflicts.conflicts = new Map(Object.entries(reversal.conflictsSnapshot.conflicts || {}));
8077
+ conflicts.fileConflicts = new Map(
8078
+ Object.entries(reversal.conflictsSnapshot.fileConflicts || {})
8079
+ );
8080
+ await conflicts.save();
8081
+ }
8082
+ // Else: no operation in the reverted range touched conflicts — leave
8083
+ // current conflict state alone. (Previously undo() unconditionally
8084
+ // cleared ALL conflicts whenever the op being undone hadn't recorded
8085
+ // its own conflictsSnapshot, which is nearly every non-merge() op —
8086
+ // undoing an unrelated describe() would silently wipe conflicts from
8087
+ // an earlier, unrelated merge(). Fixed as part of issue #29.)
8088
+ };
6775
8089
 
6776
- // Skip files that are too large
6777
- if (stats.size > MAX_FILE_SIZE) {
6778
- continue;
6779
- }
8090
+ /**
8091
+ * Re-parent every child of `changeId` onto `newParents` instead — used
8092
+ * when a change is being elided from the graph (abandon(), issue #30;
8093
+ * converge(), issue #32) so its children don't end up pointing at
8094
+ * something no longer meant to be there. Mutates `changeSnapshot` in
8095
+ * place, adding each touched child's PRE-reparent record so the caller's
8096
+ * own oplog entry lets undo()/redo()/operations.restore() reverse this
8097
+ * too (the same mechanism the caller already uses for the elided
8098
+ * change's own snapshot).
8099
+ *
8100
+ * @param {string} changeId - The change being elided
8101
+ * @param {string[]} newParents - What its children should point at instead
8102
+ * @param {Record<string, any>} changeSnapshot - Accumulator to add each touched child's pre-state to
8103
+ * @returns {Promise<void>}
8104
+ */
8105
+ const reparentChildrenOnto = async (changeId, newParents, changeSnapshot) => {
8106
+ const childIds = graph.getChildren(changeId);
8107
+ for (const childId of childIds) {
8108
+ const child = await graph.getChange(childId);
8109
+ if (!child) continue;
8110
+ changeSnapshot[childId] = structuredClone(child);
8111
+ const reparented = child.parents.flatMap((/** @type {string} */ p) =>
8112
+ p === changeId ? newParents : [p]
8113
+ );
8114
+ // De-dupe (e.g. the child could already have one of the new parents
8115
+ // as another of its own parents) while preserving first-seen order.
8116
+ child.parents = [...new Set(reparented)];
8117
+ await graph.updateChange(child);
8118
+ }
8119
+ };
6780
8120
 
6781
- // Stop if total snapshot size would exceed limit
6782
- if (totalSnapshotSize + stats.size > MAX_TOTAL_SIZE) {
6783
- break;
6784
- }
8121
+ /**
8122
+ * Determine which operation a fresh undo() call should revert, matching
8123
+ * real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
8124
+ * a normal operation, this will undo that last operation by restoring its
8125
+ * parent. If jj undo is used repeatedly, it will restore increasingly
8126
+ * older operations, going further back into the past.").
8127
+ *
8128
+ * The raw op-log parent chain can't answer this directly once undo/redo
8129
+ * bookkeeping operations are mixed into the log: an 'undo' operation's
8130
+ * own parent is the operation it just undid, so naively targeting
8131
+ * `ops[ops.length - 1]` on a SECOND undo() would undo the undo (toggle
8132
+ * back to the future) instead of stepping one further step into the past
8133
+ * — this was issue #29's "undo() twice only undoes the undo" bug.
8134
+ *
8135
+ * @param {Array<any>} ops - Full chronological operation list
8136
+ * @returns {any} The operation object undo() should revert
8137
+ */
8138
+ const resolveUndoTarget = (ops) => {
8139
+ const head = ops[ops.length - 1];
8140
+ const findOp = (/** @type {any} */ id) => ops.find((op) => op.id === id);
8141
+
8142
+ if (head.eventType === 'undo') {
8143
+ // We're stepping further into the past: what should be undone now is
8144
+ // the operation right BEFORE the one `head` already undid — not
8145
+ // head's own parent, which IS the operation head undid (targeting it
8146
+ // again would just toggle back to the present).
8147
+ const previouslyUndone = head.targetOpId && findOp(head.targetOpId);
8148
+ const stepBack = previouslyUndone && findOp(previouslyUndone.parents[0]);
8149
+ return stepBack || previouslyUndone || head;
8150
+ }
6785
8151
 
6786
- const content = await fs.promises.readFile(fullPath, 'utf-8');
6787
- fileSnapshot[filePath] = content;
6788
- totalSnapshotSize += stats.size;
6789
- } catch (error) {
6790
- // Skip files that can't be read (binary, deleted, etc.)
6791
- }
6792
- }
6793
- } catch (error) {
6794
- // If we can't list files, return empty snapshot
8152
+ if (head.eventType === 'redo') {
8153
+ // A redo re-established some earlier operation as current; a fresh
8154
+ // undo right after it should behave exactly as if that operation
8155
+ // were freshly current and had never been touched by undo/redo at
8156
+ // all — i.e. undo it again (real jj: redo "moves in the direction of
8157
+ // the future"; undoing right after simply resumes stepping back).
8158
+ const redoneUndo = head.redoOf && findOp(head.redoOf);
8159
+ const target = redoneUndo && redoneUndo.targetOpId && findOp(redoneUndo.targetOpId);
8160
+ return target || head;
6795
8161
  }
6796
8162
 
6797
- return fileSnapshot;
8163
+ return head;
6798
8164
  };
6799
8165
 
6800
8166
  /**
@@ -6981,6 +8347,25 @@ async function createJJ(options) {
6981
8347
  }
6982
8348
  },
6983
8349
 
8350
+ // Used by undo()/redo()/operations.restore() to reverse an
8351
+ // addChange() when winding the graph back — see issue #29. No Git
8352
+ // sync hook: a change created after the operation being reverted was
8353
+ // never pushed anywhere to unwind, and the Git backend has no
8354
+ // "forget this commit" operation (nor should it — Git objects are
8355
+ // content-addressed and harmless to leave orphaned).
8356
+ deleteChange: (/** @type {any} */ changeId) => baseGraph.deleteChange(changeId),
8357
+
8358
+ // See issue #32 / converge(): a divergent copy is another commit
8359
+ // claiming an existing changeId; no Git sync hook, for the same
8360
+ // reason addChange()'s primary copy already goes through
8361
+ // onAddChange() when it's first created — the divergent copy needs
8362
+ // no additional sync of its own.
8363
+ addDivergentCopy: (/** @type {any} */ change) => baseGraph.addDivergentCopy(change),
8364
+ getDivergentSiblings: (/** @type {any} */ changeId) =>
8365
+ baseGraph.getDivergentSiblings(changeId),
8366
+ deleteDivergentCopy: (/** @type {any} */ changeId, /** @type {any} */ commitId) =>
8367
+ baseGraph.deleteDivergentCopy(changeId, commitId),
8368
+
6984
8369
  // Delegate other operations
6985
8370
  init: () => baseGraph.init(),
6986
8371
  };
@@ -7138,6 +8523,20 @@ async function createJJ(options) {
7138
8523
  /**
7139
8524
  * Write a file to the working copy
7140
8525
  *
8526
+ * Note (issue #30): this does NOT update the working-copy change's
8527
+ * `commitId`/`fileSnapshot` — those stay as they were until the next
8528
+ * command that snapshots the working copy (describe(), new(), edit(),
8529
+ * status(), or any other call that goes through
8530
+ * autoSnapshotWorkingCopy()/snapshot()). This is intentional, not a
8531
+ * bug: jj has no staging area, but it also doesn't recompute a git
8532
+ * tree/commit hash on every single file write (that would mean
8533
+ * hashing on every keystroke of an external editor) — real jj only
8534
+ * takes a fresh working-copy snapshot when an actual `jj` command
8535
+ * runs. write() is the equivalent of editing a file with your OS's own
8536
+ * tools outside of jj entirely; describe()/new()/edit()/status()/etc.
8537
+ * are the equivalent of running a jj command, which is what triggers
8538
+ * the (auto) snapshot in both real jj and here.
8539
+ *
7141
8540
  * @param {Record<string, any>} args - Arguments
7142
8541
  * @returns {Promise<Object>} File information including path, size, and mode
7143
8542
  */
@@ -7161,7 +8560,7 @@ async function createJJ(options) {
7161
8560
  if (pathParts.length > 1) {
7162
8561
  const dirPath = pathParts.slice(0, -1).join('/');
7163
8562
  const fullDirPath = `${dir}/${dirPath}`;
7164
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8563
+ await mkdirp(fs, fullDirPath);
7165
8564
  }
7166
8565
 
7167
8566
  // Write the file
@@ -7216,10 +8615,10 @@ async function createJJ(options) {
7216
8615
  });
7217
8616
  }
7218
8617
 
7219
- const fullPath = path.join(dir, args.path);
8618
+ const fullPath = join(dir, args.path);
7220
8619
  try {
7221
8620
  if (encoding === 'utf-8' || encoding === 'utf8') {
7222
- return await fs.promises.readFile(fullPath, 'utf-8');
8621
+ return await fs.promises.readFile(fullPath, 'utf8');
7223
8622
  } else {
7224
8623
  return await fs.promises.readFile(fullPath);
7225
8624
  }
@@ -7384,7 +8783,7 @@ async function createJJ(options) {
7384
8783
 
7385
8784
  // Reading from working copy
7386
8785
  if (!args.changeId) {
7387
- const fullPath = path.join(dir, args.path);
8786
+ const fullPath = join(dir, args.path);
7388
8787
  try {
7389
8788
  const stream = fs.createReadStream(
7390
8789
  fullPath,
@@ -7434,14 +8833,14 @@ async function createJJ(options) {
7434
8833
  });
7435
8834
  }
7436
8835
 
7437
- const fullPath = path.join(dir, args.path);
8836
+ const fullPath = join(dir, args.path);
7438
8837
 
7439
8838
  // Ensure directory exists
7440
8839
  const pathParts = args.path.split('/');
7441
8840
  if (pathParts.length > 1) {
7442
8841
  const dirPath = pathParts.slice(0, -1).join('/');
7443
- const fullDirPath = path.join(dir, dirPath);
7444
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8842
+ const fullDirPath = join(dir, dirPath);
8843
+ await mkdirp(fs, fullDirPath);
7445
8844
  }
7446
8845
 
7447
8846
  // Create writable stream
@@ -7554,7 +8953,7 @@ async function createJJ(options) {
7554
8953
  // Ensure destination directory exists
7555
8954
  const toDir = toPath.substring(0, toPath.lastIndexOf('/'));
7556
8955
  try {
7557
- await fs.promises.mkdir(toDir, { recursive: true });
8956
+ await mkdirp(fs, toDir);
7558
8957
  } catch (error) {
7559
8958
  throw new JJError(
7560
8959
  'FILE_SYSTEM_ERROR',
@@ -7672,6 +9071,51 @@ async function createJJ(options) {
7672
9071
  // TODO: Prevent creating cycles in the change graph
7673
9072
  // For now, we'll allow it but it should be validated
7674
9073
 
9074
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
9075
+ // reverse the reparenting — see issue #29.
9076
+ const changeSnapshotBefore = structuredClone(change);
9077
+
9078
+ // Three-way merge against the OLD base before reparenting (issue #31):
9079
+ // real jj's rebase re-derives the rebased change's content as
9080
+ // (old base, old commit, new base) — if a sibling changed the same
9081
+ // lines differently, that's a genuine conflict, not something
9082
+ // reparenting-and-copying-verbatim can silently paper over. This
9083
+ // reuses the exact same detectConflicts() machinery merge() already
9084
+ // uses (see merge(), a few hundred lines down) rather than
9085
+ // duplicating three-way-merge logic a third time in this codebase.
9086
+ const oldParentId = change.parents[0];
9087
+ const oldParentChange = oldParentId ? await graph.getChange(oldParentId) : null;
9088
+ const baseFiles = new Map(
9089
+ Object.entries((oldParentChange && oldParentChange.fileSnapshot) || {})
9090
+ );
9091
+ const leftFiles = new Map(Object.entries(change.fileSnapshot || {}));
9092
+ const rightFiles = new Map(Object.entries(newParentChange.fileSnapshot || {}));
9093
+
9094
+ await conflicts.load();
9095
+ const conflictsSnapshot = {
9096
+ conflicts: Object.fromEntries(conflicts.conflicts),
9097
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
9098
+ };
9099
+
9100
+ const detectedConflicts = oldParentChange
9101
+ ? await conflicts.detectConflicts({
9102
+ baseFiles,
9103
+ leftFiles,
9104
+ rightFiles,
9105
+ drivers: {},
9106
+ // Not necessarily the checked-out working copy, so don't let
9107
+ // merge drivers write to disk on its behalf.
9108
+ workingCopyDir: /** @type {any} */ (null),
9109
+ baseChange: oldParentId,
9110
+ leftChange: changeId,
9111
+ rightChange: newParent,
9112
+ })
9113
+ : [];
9114
+
9115
+ for (const conflict of detectedConflicts) {
9116
+ await conflicts.addConflict(conflict);
9117
+ }
9118
+
7675
9119
  // Update parent
7676
9120
  change.parents = [newParent];
7677
9121
  await graph.updateChange(change);
@@ -7682,6 +9126,8 @@ async function createJJ(options) {
7682
9126
  user: await getUserOplogInfo(),
7683
9127
  description: `move change ${changeId.slice(0, 8)} to ${newParent.slice(0, 8)}`,
7684
9128
  parents: [],
9129
+ changeSnapshot: { [changeId]: changeSnapshotBefore },
9130
+ conflictsSnapshot,
7685
9131
  view: {
7686
9132
  bookmarks: {},
7687
9133
  remoteBookmarks: {},
@@ -7690,7 +9136,7 @@ async function createJJ(options) {
7690
9136
  },
7691
9137
  });
7692
9138
 
7693
- return change;
9139
+ return { ...change, conflicts: detectedConflicts };
7694
9140
  },
7695
9141
 
7696
9142
  /**
@@ -7862,7 +9308,7 @@ async function createJJ(options) {
7862
9308
 
7863
9309
  for (const filePath of trackedFiles) {
7864
9310
  try {
7865
- const fullPath = path.join(dir, filePath);
9311
+ const fullPath = join(dir, filePath);
7866
9312
  const stats = await fs.promises.stat(fullPath);
7867
9313
 
7868
9314
  // Skip files that are too large (silently - this is an optimization)
@@ -7875,7 +9321,7 @@ async function createJJ(options) {
7875
9321
  break;
7876
9322
  }
7877
9323
 
7878
- const content = await fs.promises.readFile(fullPath, 'utf-8');
9324
+ const content = await fs.promises.readFile(fullPath, 'utf8');
7879
9325
  fileSnapshot[filePath] = content;
7880
9326
  totalSnapshotSize += stats.size;
7881
9327
  } catch (error) {
@@ -8073,6 +9519,8 @@ async function createJJ(options) {
8073
9519
 
8074
9520
  // Snapshot filesystem BEFORE operation (for undo) unless preserveSnapshot is true
8075
9521
  let fileSnapshot;
9522
+ /** @type {Record<string, any>} */
9523
+ const changeSnapshot = {};
8076
9524
  if (!args.preserveSnapshot) {
8077
9525
  fileSnapshot = await snapshotFilesystem();
8078
9526
 
@@ -8081,6 +9529,10 @@ async function createJJ(options) {
8081
9529
  const currentChangeId = workingCopy.getCurrentChangeId();
8082
9530
  const currentChange = await graph.getChange(currentChangeId);
8083
9531
  if (currentChange && fileSnapshot) {
9532
+ // Snapshot BEFORE mutating so undo()/redo() can revert this
9533
+ // in-place update too, not just the pointer/new-change creation
9534
+ // below — see issue #29.
9535
+ changeSnapshot[currentChangeId] = structuredClone(currentChange);
8084
9536
  currentChange.fileSnapshot = fileSnapshot;
8085
9537
  await graph.updateChange(currentChange);
8086
9538
  }
@@ -8108,6 +9560,7 @@ async function createJJ(options) {
8108
9560
  const user = /** @type {any} */ (userConfig.getUser());
8109
9561
 
8110
9562
  // Initialize with parent's file snapshot
9563
+ /** @type {Record<string, string>} */
8111
9564
  let initialSnapshot = {};
8112
9565
  if (parents && parents.length > 0) {
8113
9566
  const parent = await graph.getChange(parents[0]);
@@ -8151,10 +9604,19 @@ async function createJJ(options) {
8151
9604
  // Handle insertBefore: rebase target to have new change as parent
8152
9605
  if (args.insertBefore) {
8153
9606
  const targetChange = await graph.getChange(args.insertBefore);
9607
+ changeSnapshot[args.insertBefore] = structuredClone(targetChange);
8154
9608
  targetChange.parents = [newChangeId];
8155
9609
  await graph.updateChange(targetChange);
8156
9610
  }
8157
9611
 
9612
+ // Sync the working directory to the new change's snapshot: write
9613
+ // everything it has AND remove anything it doesn't (issue #30 — this
9614
+ // used to never touch disk at all, so `new({ parents: [...] })`
9615
+ // targeting anything other than the current head silently left the
9616
+ // OLD working copy's files in place instead of checking out the new
9617
+ // parent's).
9618
+ await syncWorkingCopyFiles(initialSnapshot);
9619
+
8158
9620
  await workingCopy.setCurrentChange(newChangeId);
8159
9621
 
8160
9622
  // Record operation with filesystem snapshot
@@ -8167,6 +9629,13 @@ async function createJJ(options) {
8167
9629
  },
8168
9630
  description: `new change ${newChangeId.slice(0, 8)}`,
8169
9631
  parents: [],
9632
+ // See issue #29: lets undo()/redo()/operations.restore() reverse
9633
+ // the existing records this op updated in place (the old
9634
+ // working-copy change's refreshed fileSnapshot, and insertBefore's
9635
+ // target's reparented parents). The newly created change itself
9636
+ // (newChangeId) is intentionally NOT torn down on undo — see
9637
+ // computeGraphReversal's doc comment.
9638
+ changeSnapshot,
8170
9639
  view: {
8171
9640
  bookmarks: {},
8172
9641
  remoteBookmarks: {},
@@ -8547,6 +10016,13 @@ async function createJJ(options) {
8547
10016
 
8548
10017
  const previousChangeId = workingCopy.getCurrentChangeId();
8549
10018
 
10019
+ /** @type {Record<string, any>} */
10020
+ const changeSnapshot = {};
10021
+
10022
+ // Capture disk state right before we touch anything, for undo() —
10023
+ // see issue #29.
10024
+ const fileSnapshotBeforeSwitch = await snapshotFilesystem();
10025
+
8550
10026
  // CRITICAL FIX: Clean working directory BEFORE snapshotting to prevent cross-branch pollution
8551
10027
  // Remove files that don't belong to the current changeId before we snapshot it
8552
10028
  if (previousChangeId !== args.changeId) {
@@ -8560,7 +10036,7 @@ async function createJJ(options) {
8560
10036
  for (const filePath of workingDirFiles) {
8561
10037
  if (!previousSnapshot[filePath]) {
8562
10038
  try {
8563
- const fullPath = path.join(dir, filePath);
10039
+ const fullPath = join(dir, filePath);
8564
10040
  await fs.promises.unlink(fullPath);
8565
10041
  await workingCopy.untrackFile(filePath);
8566
10042
  } catch (error) {
@@ -8568,13 +10044,14 @@ async function createJJ(options) {
8568
10044
  }
8569
10045
  }
8570
10046
  }
8571
- }
8572
10047
 
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);
10048
+ // Now snapshot the CLEAN working directory (no pollution!)
10049
+ const currentSnapshot = await snapshotFilesystem();
10050
+ if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
10051
+ changeSnapshot[previousChangeId] = structuredClone(previousChange);
10052
+ previousChange.fileSnapshot = currentSnapshot;
10053
+ await graph.updateChange(previousChange);
10054
+ }
8578
10055
  }
8579
10056
  }
8580
10057
 
@@ -8587,38 +10064,17 @@ async function createJJ(options) {
8587
10064
  timestamp: new Date().toISOString(),
8588
10065
  });
8589
10066
 
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
- }
10067
+ // Fully sync the working directory to the target change's snapshot:
10068
+ // write everything it has AND remove anything it doesn't (issue #30
10069
+ // — this used to only overlay the target's files on top of whatever
10070
+ // was already on disk, so a file present in the PREVIOUS change but
10071
+ // absent from the target leaked into the next snapshot).
10072
+ try {
10073
+ await syncWorkingCopyFiles(change.fileSnapshot || {});
10074
+ } catch (error) {
10075
+ throw new JJError('FILE_RESTORE_FAILED', `Failed to restore files: ${error.message}`, {
10076
+ originalError: error.message,
10077
+ });
8622
10078
  }
8623
10079
 
8624
10080
  // Set this change as the working copy
@@ -8634,11 +10090,13 @@ async function createJJ(options) {
8634
10090
  },
8635
10091
  description: `edit change ${args.changeId.slice(0, 8)}`,
8636
10092
  parents: [],
10093
+ changeSnapshot,
8637
10094
  view: {
8638
10095
  bookmarks: {},
8639
10096
  remoteBookmarks: {},
8640
10097
  heads: [args.changeId],
8641
10098
  workingCopy: args.changeId,
10099
+ fileSnapshot: fileSnapshotBeforeSwitch,
8642
10100
  },
8643
10101
  });
8644
10102
 
@@ -8669,106 +10127,118 @@ async function createJJ(options) {
8669
10127
  /**
8670
10128
  * Undo last operation
8671
10129
  *
10130
+ * Reverts the most recent operation — including graph-rewriting ones
10131
+ * (squash/rebase/abandon/new/edit), not just working-copy/file state
10132
+ * (see issue #29). Matches real jj's progressive-undo semantics
10133
+ * (`jj-undo(1)`): called again with nothing else in between, it steps
10134
+ * one further operation into the past rather than undoing itself.
10135
+ *
8672
10136
  * @returns {Promise<Object>} Information about the undo including the undone operation and restored state
8673
10137
  */
8674
10138
  async undo() {
8675
- // Get the operation we're about to undo to access its pre-state
8676
10139
  await oplog.load();
8677
10140
  const ops = await oplog.list();
8678
10141
  if (ops.length === 0) {
8679
10142
  throw new JJError('NOTHING_TO_UNDO', 'No operations to undo');
8680
10143
  }
8681
10144
 
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);
8689
-
8690
- // Restore ChangeGraph content mutated in place by the operation being
8691
- // undone (e.g. describe() editing change.description) — see issue #12.
8692
- // currentOp.changeSnapshot holds the affected change record(s) as they
8693
- // were BEFORE currentOp ran, captured the same way fileSnapshot is.
8694
- await graph.load();
8695
- if (currentOp && currentOp.changeSnapshot) {
8696
- for (const snapshot of Object.values(
8697
- /** @type {Record<string, any>} */ (currentOp.changeSnapshot)
8698
- )) {
8699
- if (snapshot && (await graph.getChange(snapshot.changeId))) {
8700
- await graph.updateChange(snapshot);
8701
- }
8702
- }
8703
- }
8704
-
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
- }
8730
- }
10145
+ const targetOp = resolveUndoTarget(ops);
10146
+ const landingOpId = targetOp.parents && targetOp.parents[0];
10147
+ const landingOp = landingOpId ? ops.find((op) => op.id === landingOpId) : null;
8731
10148
 
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();
10149
+ /** @type {ReturnType<typeof computeGraphReversal>} */
10150
+ let reversal;
10151
+ /** @type {any} */
10152
+ let landingView;
10153
+ if (landingOp) {
10154
+ reversal = computeGraphReversal(ops, landingOp);
10155
+ landingView = landingOp.view || {};
8742
10156
  } else {
8743
- // No conflicts snapshot - clear conflicts
8744
- await conflicts.clear();
10157
+ // Undoing the very first (root) operation: there's nothing before
10158
+ // it to restore to, so land on its own recorded state rather than
10159
+ // throwing (mirrors the previous no-parent fallback).
10160
+ reversal = {
10161
+ changeSnapshotToApply: {},
10162
+ fileSnapshot: undefined,
10163
+ conflictsSnapshot: undefined,
10164
+ };
10165
+ landingView = targetOp.view || {};
10166
+ }
10167
+
10168
+ // Capture EXACTLY what's about to be overwritten, from ground truth,
10169
+ // before applying the reversal — this is what redo() re-applies
10170
+ // later. It can't just recompute "the state after targetOp" the way
10171
+ // undo() itself computes "the state after landingOp" (by scanning
10172
+ // for a LATER op that also touched the same changeId/files/
10173
+ // conflicts): that scan only works going backward, where "nothing
10174
+ // touched it since" means "it's already correct". Going forward,
10175
+ // "nothing touched it since targetOp" means the opposite — its
10176
+ // effect is still live and needs to be captured now, not rederived.
10177
+ await graph.load();
10178
+ /** @type {Record<string, any>} */
10179
+ const redoChangeSnapshot = {};
10180
+ for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
10181
+ const current = await graph.getChange(changeId);
10182
+ if (current) redoChangeSnapshot[changeId] = current;
10183
+ }
10184
+ const redoFileSnapshot =
10185
+ reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
10186
+ let redoConflictsSnapshot;
10187
+ if (reversal.conflictsSnapshot !== undefined) {
10188
+ await conflicts.load();
10189
+ redoConflictsSnapshot = {
10190
+ conflicts: Object.fromEntries(conflicts.conflicts),
10191
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
10192
+ };
8745
10193
  }
8746
10194
 
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).
10195
+ if (landingView.workingCopy) {
10196
+ await workingCopy.setCurrentChange(landingView.workingCopy);
10197
+ }
10198
+ await applyGraphReversal(reversal);
10199
+
10200
+ // Record undo as its own operation (matching real jj) — tagged with
10201
+ // which operation it targeted so a following undo() can walk one
10202
+ // step further back (resolveUndoTarget) instead of toggling, and
10203
+ // carrying exactly what redo() needs to re-apply (captured above).
10204
+ //
10205
+ // Also record `changeSnapshot: redoChangeSnapshot` — the SAME
10206
+ // ground-truth-before-this-op values, but under the field name
10207
+ // computeGraphReversal() actually scans for. Without this, undo()
10208
+ // itself was the one mutating operation in the whole API that never
10209
+ // recorded its own changeSnapshot, so a LATER undo() targeting THIS
10210
+ // undo (to step back past it) had nothing to restore: it would
10211
+ // repoint the working copy and files, but leave whatever this
10212
+ // undo() overwrote (e.g. a change's description) exactly as this
10213
+ // undo() left it. See issue #37(a).
8749
10214
  await oplog.recordOperation({
8750
10215
  timestamp: new Date().toISOString(),
8751
10216
  user: await getUserOplogInfo(),
8752
10217
  description: 'undo operation',
8753
10218
  eventType: 'undo',
8754
- undoneOpId: currentOp.id,
8755
- undoneView: currentOp.view,
8756
- undoneConflictsSnapshot: currentOp.conflictsSnapshot || null,
10219
+ targetOpId: targetOp.id,
10220
+ undoneOpId: targetOp.id, // kept for API/type compatibility
10221
+ changeSnapshot: redoChangeSnapshot,
10222
+ redoChangeSnapshot,
10223
+ redoFileSnapshot,
10224
+ redoConflictsSnapshot,
8757
10225
  parents: [],
8758
- view: previousView,
10226
+ view: landingView,
8759
10227
  });
8760
10228
 
8761
- // Return information about what was undone
10229
+ const restoredFileCount =
10230
+ reversal.fileSnapshot !== undefined ? Object.keys(reversal.fileSnapshot).length : 0;
10231
+
8762
10232
  return {
8763
10233
  undoneOperation: {
8764
- description: currentOp.description,
8765
- timestamp: currentOp.timestamp,
8766
- user: currentOp.user,
10234
+ description: targetOp.description,
10235
+ timestamp: targetOp.timestamp,
10236
+ user: targetOp.user,
8767
10237
  },
8768
10238
  restoredState: {
8769
- workingCopy: previousView.workingCopy,
8770
- heads: previousView.heads,
8771
- fileCount: previousView.fileSnapshot ? Object.keys(previousView.fileSnapshot).length : 0,
10239
+ workingCopy: landingView.workingCopy,
10240
+ heads: landingView.heads,
10241
+ fileCount: restoredFileCount,
8772
10242
  },
8773
10243
  };
8774
10244
  },
@@ -8776,9 +10246,9 @@ async function createJJ(options) {
8776
10246
  /**
8777
10247
  * Redo an operation previously undone with undo() (v1.5)
8778
10248
  *
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.
10249
+ * Mirrors `jj redo` (jj v0.33): re-applies the operation that the most
10250
+ * recent not-yet-redone undo() reverted — including its graph rewrites,
10251
+ * not just working-copy/file state (see issue #29).
8782
10252
  *
8783
10253
  * @returns {Promise<Object>} Information about the redone operation
8784
10254
  */
@@ -8809,42 +10279,52 @@ async function createJJ(options) {
8809
10279
  });
8810
10280
  }
8811
10281
 
8812
- const redoneView = undoOp.undoneView || {};
10282
+ const targetOpId = undoOp.targetOpId || undoOp.undoneOpId;
10283
+ const targetOp = targetOpId && ops.find((op) => op.id === targetOpId);
10284
+ const redoneView = (targetOp && targetOp.view) || {};
8813
10285
 
8814
- // Restore the working-copy pointer.
8815
10286
  if (redoneView.workingCopy) {
8816
10287
  await workingCopy.setCurrentChange(redoneView.workingCopy);
8817
10288
  }
8818
10289
 
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
- }
8837
- }
10290
+ // Re-apply exactly what this undo captured right before it reverted
10291
+ // things — NOT computeGraphReversal(ops, targetOp): that scans for a
10292
+ // LATER op touching the same changeId/files/conflicts, which is the
10293
+ // right question for undo() ("has anything since made my target
10294
+ // stale?") but the wrong one here — after an undo, NOTHING later
10295
+ // re-established targetOp's own effect, so there's nothing to find
10296
+ // by scanning forward. undo() captured it directly for exactly this
10297
+ // reason (see its own comment).
10298
+ await graph.load();
10299
+
10300
+ // Capture ground truth right before overwriting, same as undo()
10301
+ // does for its own changeSnapshot (issue #37a) — lets a later
10302
+ // undo() targeting THIS redo op restore what redo() is about to
10303
+ // clobber, instead of finding no changeSnapshot at all.
10304
+ /** @type {Record<string, any>} */
10305
+ const undoChangeSnapshot = {};
10306
+ for (const changeId of Object.keys(undoOp.redoChangeSnapshot || {})) {
10307
+ const current = await graph.getChange(changeId);
10308
+ if (current) undoChangeSnapshot[changeId] = current;
8838
10309
  }
8839
10310
 
8840
- // Restore conflicts to the state that existed after the redone operation.
10311
+ for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
10312
+ if (await graph.getChange(changeId)) {
10313
+ // Bypass the git-sync middleware — see the matching comment in
10314
+ // applyGraphReversal(). `snapshot` already carries the change's
10315
+ // correct commitId from before the undo it's reversing ran
10316
+ // (issue #37c applies to redo() too).
10317
+ await baseGraph.updateChange(snapshot);
10318
+ }
10319
+ }
10320
+ if (undoOp.redoFileSnapshot !== undefined) {
10321
+ await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
10322
+ }
8841
10323
  await conflicts.load();
8842
- if (undoOp.undoneConflictsSnapshot) {
8843
- conflicts.conflicts = new Map(
8844
- Object.entries(undoOp.undoneConflictsSnapshot.conflicts || {})
8845
- );
10324
+ if (undoOp.redoConflictsSnapshot !== undefined) {
10325
+ conflicts.conflicts = new Map(Object.entries(undoOp.redoConflictsSnapshot.conflicts || {}));
8846
10326
  conflicts.fileConflicts = new Map(
8847
- Object.entries(undoOp.undoneConflictsSnapshot.fileConflicts || {})
10327
+ Object.entries(undoOp.redoConflictsSnapshot.fileConflicts || {})
8848
10328
  );
8849
10329
  await conflicts.save();
8850
10330
  }
@@ -8855,19 +10335,23 @@ async function createJJ(options) {
8855
10335
  description: 'redo operation',
8856
10336
  eventType: 'redo',
8857
10337
  redoOf: undoOp.id,
10338
+ changeSnapshot: undoChangeSnapshot,
8858
10339
  parents: [],
8859
10340
  view: redoneView,
8860
10341
  });
8861
10342
 
10343
+ const restoredFileCount =
10344
+ undoOp.redoFileSnapshot !== undefined ? Object.keys(undoOp.redoFileSnapshot).length : 0;
10345
+
8862
10346
  return {
8863
10347
  redoneOperation: {
8864
10348
  description: undoOp.description,
8865
- undoneOpId: undoOp.undoneOpId,
10349
+ undoneOpId: targetOpId,
8866
10350
  },
8867
10351
  restoredState: {
8868
10352
  workingCopy: redoneView.workingCopy,
8869
10353
  heads: redoneView.heads,
8870
- fileCount: redoneView.fileSnapshot ? Object.keys(redoneView.fileSnapshot).length : 0,
10354
+ fileCount: restoredFileCount,
8871
10355
  },
8872
10356
  };
8873
10357
  },
@@ -9094,6 +10578,12 @@ async function createJJ(options) {
9094
10578
  /**
9095
10579
  * Restore repository to a specific operation (matches `jj operation restore`)
9096
10580
  *
10581
+ * Fully reverts everything recorded after the target operation —
10582
+ * including graph rewrites (squash/rebase/abandon/new/edit), working
10583
+ * copy files, and conflict state, not just bookmarks/the working-copy
10584
+ * pointer (see issue #29) — using the same reversal primitive as
10585
+ * undo()/redo().
10586
+ *
9097
10587
  * @param {Record<string, any>} args - Arguments
9098
10588
  * @returns {Promise<Object>} Restore result
9099
10589
  */
@@ -9133,12 +10623,35 @@ async function createJJ(options) {
9133
10623
  await workingCopy.setCurrentChange(targetOp.view.workingCopy);
9134
10624
  }
9135
10625
 
10626
+ // Revert the change graph, working-copy files, and conflict state
10627
+ // to exactly what they were right after targetOp ran.
10628
+ const reversal = computeGraphReversal(ops, targetOp);
10629
+
10630
+ // Capture ground truth for every changeId this restore is about to
10631
+ // overwrite, BEFORE applying the reversal — exactly like undo()
10632
+ // does for its own changeSnapshot. Without this, restore()'s own
10633
+ // oplog entry carried no changeSnapshot at all, so undoing the
10634
+ // restore itself had nothing to put back: a later undo() would
10635
+ // repoint the working copy and files, but leave whatever the
10636
+ // restore had just overwritten (e.g. a change's description)
10637
+ // exactly as the restore left it. See issue #37(a).
10638
+ await graph.load();
10639
+ /** @type {Record<string, any>} */
10640
+ const changeSnapshot = {};
10641
+ for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
10642
+ const current = await graph.getChange(changeId);
10643
+ if (current) changeSnapshot[changeId] = current;
10644
+ }
10645
+
10646
+ await applyGraphReversal(reversal);
10647
+
9136
10648
  // Record this restoration as a new operation
9137
10649
  await oplog.recordOperation({
9138
10650
  timestamp: new Date().toISOString(),
9139
10651
  user: await getUserOplogInfo(),
9140
10652
  description: `restore to operation ${args.operation}`,
9141
10653
  parents: [],
10654
+ changeSnapshot,
9142
10655
  view: targetOp.view,
9143
10656
  });
9144
10657
 
@@ -9424,10 +10937,53 @@ async function createJJ(options) {
9424
10937
  timestamp: new Date().toISOString(),
9425
10938
  });
9426
10939
 
10940
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
10941
+ // reverse the squash (un-abandon source, restore dest's description
10942
+ // and content) — see issue #29.
10943
+ /** @type {Record<string, any>} */
10944
+ const changeSnapshot = {
10945
+ [source]: structuredClone(sourceChange),
10946
+ [destChangeId]: structuredClone(destChange),
10947
+ };
10948
+
9427
10949
  // Mark source as abandoned
9428
10950
  sourceChange.abandoned = true;
9429
10951
  await graph.updateChange(sourceChange);
9430
10952
 
10953
+ // Merge source's file content into dest (issue #30: squash() used to
10954
+ // only combine descriptions, leaving dest's tree untouched). Compute
10955
+ // what source itself actually changed relative to ITS OWN parent
10956
+ // (its "diff") and fold just that onto dest, rather than overwriting
10957
+ // dest's whole snapshot with source's — a path source never touched
10958
+ // (inherited unchanged from that parent) must not clobber a
10959
+ // dest-specific version of the same path.
10960
+ const sourceParentId = sourceChange.parents && sourceChange.parents[0];
10961
+ const sourceParentChange = sourceParentId ? await graph.getChange(sourceParentId) : null;
10962
+ const sourceBaseSnapshot = (sourceParentChange && sourceParentChange.fileSnapshot) || {};
10963
+ const sourceSnapshot = sourceChange.fileSnapshot || {};
10964
+ const mergedSnapshot = { ...(destChange.fileSnapshot || {}) };
10965
+ const touchedPaths = new Set([
10966
+ ...Object.keys(sourceBaseSnapshot),
10967
+ ...Object.keys(sourceSnapshot),
10968
+ ]);
10969
+ for (const filePath of touchedPaths) {
10970
+ const baseValue = sourceBaseSnapshot[filePath];
10971
+ const sourceValue = sourceSnapshot[filePath];
10972
+ if (sourceValue === baseValue) {
10973
+ // source never touched this path (inherited as-is from its own
10974
+ // parent) — leave whatever dest already had for it alone.
10975
+ continue;
10976
+ }
10977
+ if (sourceValue === undefined) {
10978
+ // source deleted a file it inherited from its parent.
10979
+ delete mergedSnapshot[filePath];
10980
+ } else {
10981
+ // source added or modified this path.
10982
+ mergedSnapshot[filePath] = sourceValue;
10983
+ }
10984
+ }
10985
+ destChange.fileSnapshot = mergedSnapshot;
10986
+
9431
10987
  // Update description of dest to indicate squash (middleware will sync to Git)
9432
10988
  destChange.description += `\n\n(squashed from ${source.slice(0, 8)})`;
9433
10989
  await graph.updateChange(destChange);
@@ -9455,10 +11011,22 @@ async function createJJ(options) {
9455
11011
  },
9456
11012
  description: '(no description set)',
9457
11013
  timestamp: new Date().toISOString(),
9458
- fileSnapshot: {},
11014
+ // Start from the DESTINATION's tree (which now includes source's
11015
+ // folded-in changes, set above), not an empty object — matches
11016
+ // real `jj squash`, where the new working-copy change looks
11017
+ // identical to its new parent until you touch something. Issue
11018
+ // #39: this used to hard-code `{}`, so status()/snapshot() saw
11019
+ // every on-disk file as untracked/new until something re-wrote
11020
+ // it and triggered a fresh auto-snapshot.
11021
+ fileSnapshot: { ...destChange.fileSnapshot },
9459
11022
  };
9460
11023
 
9461
11024
  await graph.addChange(newChange); // Middleware will sync to Git
11025
+ // Sync the working directory to match (issue #30's fix for
11026
+ // new()/edit(); squash()'s own synthetic post-squash change never
11027
+ // did this) — a no-op when source's parent was already dest (the
11028
+ // common case), but necessary whenever source/dest aren't adjacent.
11029
+ await syncWorkingCopyFiles(newChange.fileSnapshot);
9462
11030
  await workingCopy.setCurrentChange(newChangeId);
9463
11031
  newWorkingCopyId = newChangeId;
9464
11032
  }
@@ -9469,6 +11037,7 @@ async function createJJ(options) {
9469
11037
  user: await getUserOplogInfo(),
9470
11038
  description: `squash ${source.slice(0, 8)} into ${destChangeId.slice(0, 8)}`,
9471
11039
  parents: [],
11040
+ changeSnapshot,
9472
11041
  view: {
9473
11042
  bookmarks: {},
9474
11043
  remoteBookmarks: {},
@@ -9539,6 +11108,12 @@ async function createJJ(options) {
9539
11108
  throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
9540
11109
  }
9541
11110
 
11111
+ // Captured BEFORE anything mutates — see issue #38: abandoning the
11112
+ // change @ currently points at must move @ off it (real jj checks
11113
+ // out a fresh empty change on the abandoned change's own parent(s));
11114
+ // abandoning any OTHER change must leave @ exactly where it was.
11115
+ const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
11116
+
9542
11117
  // Dispatch change:abandoning event (preventable)
9543
11118
  await dispatchEventAsync(jj, 'change:abandoning', {
9544
11119
  operation: 'abandon',
@@ -9547,15 +11122,29 @@ async function createJJ(options) {
9547
11122
  timestamp: new Date().toISOString(),
9548
11123
  });
9549
11124
 
11125
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
11126
+ // reverse the abandon (un-abandon this change, un-reparent children)
11127
+ // — see issue #29.
11128
+ /** @type {Record<string, any>} */
11129
+ const changeSnapshot = { [changeId]: structuredClone(change) };
11130
+
9550
11131
  change.abandoned = true;
9551
11132
  await graph.updateChange(change);
9552
11133
 
11134
+ // Re-parent children onto the abandoned change's own parent(s)
11135
+ // (issue #30: this used to leave children pointing at the now-
11136
+ // abandoned commit). Matches real `jj abandon`: the abandoned
11137
+ // change is elided from the graph and its children attach directly
11138
+ // to whatever it was attached to.
11139
+ await reparentChildrenOnto(changeId, change.parents, changeSnapshot);
11140
+
9553
11141
  // Record operation
9554
11142
  await oplog.recordOperation({
9555
11143
  timestamp: new Date().toISOString(),
9556
11144
  user: { name: user.name, email: user.email, hostname: 'localhost' },
9557
11145
  description: `abandon change ${changeId.slice(0, 8)}`,
9558
11146
  parents: [],
11147
+ changeSnapshot,
9559
11148
  view: {
9560
11149
  bookmarks: {},
9561
11150
  remoteBookmarks: {},
@@ -9577,9 +11166,220 @@ async function createJJ(options) {
9577
11166
  { cancelable: false }
9578
11167
  );
9579
11168
 
11169
+ // If @ was pointing at the change we just abandoned, it can't stay
11170
+ // there — real jj moves @ to a new empty change on the abandoned
11171
+ // change's own parent(s) (issue #38). Reuse new()'s own "create an
11172
+ // empty change with given parents" logic rather than duplicating it
11173
+ // — this is exactly the workaround callers had to reach for
11174
+ // themselves (`new({ parents: [parentOfAt] })` before abandoning).
11175
+ if (wasWorkingCopy) {
11176
+ const newWorkingCopyChange = await jj.new({ parents: change.parents || [] });
11177
+ return { ...change, newWorkingCopyChangeId: newWorkingCopyChange.changeId };
11178
+ }
11179
+
9580
11180
  return change;
9581
11181
  },
9582
11182
 
11183
+ /**
11184
+ * Converge divergent copies of a change (matches `jj converge`, jj
11185
+ * v0.45.0+ — issue #32, extended for N-way per the isomorphic-jj v0.45
11186
+ * catch-up follow-up) — resolves ANY number of divergent copies of one
11187
+ * changeId in a single call. Real jj's own CLI currently only
11188
+ * converges one change-id per invocation (an explicit TODO in its own
11189
+ * source as of v0.45.1: "consider adding logic to deal with more than
11190
+ * one divergent change-id in one invocation"), which is what fixed
11191
+ * this package's own changeId-at-a-time API design — but its
11192
+ * underlying engine (`TruncatedEvolutionGraph`/`find_divergent_changes`)
11193
+ * already resolves every divergent revision of THAT one change-id
11194
+ * together, not just a pair. This generalizes the same per-path rule
11195
+ * accordingly: two-way "does the other side's value differ from base"
11196
+ * becomes N-way "how many DISTINCT values, across every copy, differ
11197
+ * from base" — 0 means nobody touched it, 1 means exactly one copy (or
11198
+ * several copies agreeing on the same new value) changed it and that
11199
+ * value wins, 2+ is a genuine conflict between that many copies.
11200
+ *
11201
+ * A change is divergent when more than one visible commit shares its
11202
+ * change id (see the `divergent()` revset, and
11203
+ * ChangeGraph.addDivergentCopy() for how a divergent copy gets added
11204
+ * in the first place — nothing in this library creates one on its
11205
+ * own yet; this resolves one once something has, e.g. a future git
11206
+ * fetch/concurrent-operation integration).
11207
+ *
11208
+ * Non-interactive-mode convention, matching this package's other
11209
+ * ambiguous-outcome APIs: like merge(), a genuine per-path conflict is
11210
+ * returned as data (`{ resolved: false, conflicts }`), not thrown —
11211
+ * you get a result back either way and decide what to do with
11212
+ * unresolved conflicts. What DOES throw is anything that isn't even a
11213
+ * well-formed convergence attempt (no divergence to resolve at all) —
11214
+ * the same category as merge()'s own `MERGE_ERROR` throw for "no
11215
+ * common ancestor found".
11216
+ *
11217
+ * @param {Record<string, any>|string} args - `{ changeId }`, or a bare changeId string
11218
+ * @returns {Promise<Object>} `{ changeId, resolved, conflicts, commitId? }`
11219
+ */
11220
+ async converge(args) {
11221
+ const changeId = typeof args === 'string' ? args : args && (args.changeId || args.change);
11222
+ if (!changeId || typeof changeId !== 'string') {
11223
+ throw new JJError('INVALID_ARGUMENT', 'Missing changeId argument', {
11224
+ suggestion: 'Provide the divergent changeId to converge: { changeId: "..." }',
11225
+ });
11226
+ }
11227
+
11228
+ await graph.load();
11229
+ await userConfig.load();
11230
+ const siblings = graph.getDivergentSiblings(changeId);
11231
+
11232
+ if (siblings.length < 2) {
11233
+ throw new JJError(
11234
+ 'NOT_DIVERGENT',
11235
+ `Change ${changeId} has no divergent copies to converge`,
11236
+ {
11237
+ changeId,
11238
+ suggestion:
11239
+ 'converge() is only meaningful for a changeId the divergent() revset reports',
11240
+ }
11241
+ );
11242
+ }
11243
+
11244
+ // The primary copy is whichever one getChange() resolves (stored
11245
+ // under the plain changeId key); every other one is a divergent copy
11246
+ // added via addDivergentCopy(). No cap on how many — see the doc
11247
+ // comment above.
11248
+ const primary = /** @type {any} */ (await graph.getChange(changeId));
11249
+ const others = siblings.filter((/** @type {any} */ s) => s.commitId !== primary.commitId);
11250
+
11251
+ // Base for the N-way merge: what this change looked like before it
11252
+ // diverged. This package doesn't retain historical snapshots (only
11253
+ // current content), so — same pragmatic choice rebase() makes for
11254
+ // "old base" — use the shared parent's current content; divergent
11255
+ // copies realistically share the same parent, since they're all
11256
+ // rewrites of one prior position in history, not unrelated commits
11257
+ // that happen to collide. Applied uniformly to every copy, not
11258
+ // per-copy — the same simplification the original two-way version
11259
+ // made, just not newly introduced by going N-way.
11260
+ const baseParentId = primary.parents && primary.parents[0];
11261
+ const baseParentChange = baseParentId ? await graph.getChange(baseParentId) : null;
11262
+ const baseFiles = new Map(
11263
+ Object.entries((baseParentChange && baseParentChange.fileSnapshot) || {})
11264
+ );
11265
+
11266
+ // Every copy (primary + others), each as its own file map, paired
11267
+ // with its commitId for conflict reporting.
11268
+ const copies = [primary, ...others].map((/** @type {any} */ c) => ({
11269
+ commitId: c.commitId,
11270
+ files: new Map(Object.entries(c.fileSnapshot || {})),
11271
+ }));
11272
+
11273
+ await conflicts.load();
11274
+ const conflictsSnapshot = {
11275
+ conflicts: Object.fromEntries(conflicts.conflicts),
11276
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
11277
+ };
11278
+
11279
+ const allPaths = new Set([
11280
+ ...baseFiles.keys(),
11281
+ ...copies.flatMap((c) => [...c.files.keys()]),
11282
+ ]);
11283
+
11284
+ /** @type {Record<string, string>} */
11285
+ const mergedSnapshot = {};
11286
+ /** @type {any[]} */
11287
+ const detectedConflicts = [];
11288
+
11289
+ for (const filePath of allPaths) {
11290
+ const baseValue = baseFiles.get(filePath);
11291
+ // Every DISTINCT value across all copies that differs from base,
11292
+ // each paired with (one of) the commitId(s) that has it — mirrors
11293
+ // ConflictModel._detectPathConflict's two-way "no conflict" rules,
11294
+ // generalized: 0 distinct changed values means nobody touched it
11295
+ // (base wins, i.e. nothing to write), 1 means only one side (or
11296
+ // several copies agreeing) changed it (that value wins), 2+ is a
11297
+ // genuine conflict.
11298
+ const changed = new Map();
11299
+ for (const copy of copies) {
11300
+ const value = copy.files.get(filePath);
11301
+ if (value !== baseValue && !changed.has(value)) {
11302
+ changed.set(value, copy.commitId);
11303
+ }
11304
+ }
11305
+
11306
+ if (changed.size === 0) {
11307
+ if (baseValue !== undefined) mergedSnapshot[filePath] = baseValue;
11308
+ } else if (changed.size === 1) {
11309
+ const [[value]] = changed;
11310
+ if (value !== undefined) mergedSnapshot[filePath] = value;
11311
+ } else {
11312
+ const versions = [...changed.entries()].map(([content, commitId]) => ({
11313
+ commitId,
11314
+ content,
11315
+ }));
11316
+ detectedConflicts.push(conflicts.createNWayConflict(filePath, baseValue, versions));
11317
+ }
11318
+ }
11319
+
11320
+ if (detectedConflicts.length > 0) {
11321
+ // Matches real jj: non-interactive mode aborts automatic
11322
+ // resolution rather than guessing — but per this package's own
11323
+ // merge() convention, that's reported back as data, not thrown.
11324
+ for (const conflict of detectedConflicts) {
11325
+ await conflicts.addConflict(conflict);
11326
+ }
11327
+ await oplog.recordOperation({
11328
+ timestamp: new Date().toISOString(),
11329
+ user: await getUserOplogInfo(),
11330
+ description: `converge ${changeId.slice(0, 8)} (unresolved)`,
11331
+ parents: [],
11332
+ conflictsSnapshot,
11333
+ view: {
11334
+ bookmarks: {},
11335
+ remoteBookmarks: {},
11336
+ heads: [],
11337
+ workingCopy: workingCopy.getCurrentChangeId(),
11338
+ },
11339
+ });
11340
+ return { changeId, resolved: false, conflicts: detectedConflicts };
11341
+ }
11342
+
11343
+ const user = /** @type {any} */ (userConfig.getUser());
11344
+ const changeSnapshot = { [changeId]: structuredClone(primary) };
11345
+ const otherIds = others.map((/** @type {any} */ o) => o.commitId.slice(0, 8)).join(', ');
11346
+
11347
+ const convergedChange = {
11348
+ ...primary,
11349
+ commitId: randomHex(20),
11350
+ fileSnapshot: mergedSnapshot,
11351
+ description: primary.description + `\n\n(converged divergent copies: ${otherIds})`,
11352
+ timestamp: new Date().toISOString(),
11353
+ committer: {
11354
+ name: user.name,
11355
+ email: user.email,
11356
+ timestamp: new Date().toISOString(),
11357
+ },
11358
+ divergent: false,
11359
+ };
11360
+ await graph.updateChange(convergedChange);
11361
+ for (const other of others) {
11362
+ await graph.deleteDivergentCopy(changeId, other.commitId);
11363
+ }
11364
+
11365
+ await oplog.recordOperation({
11366
+ timestamp: new Date().toISOString(),
11367
+ user: await getUserOplogInfo(),
11368
+ description: `converge ${changeId.slice(0, 8)}`,
11369
+ parents: [],
11370
+ changeSnapshot,
11371
+ conflictsSnapshot,
11372
+ view: {
11373
+ bookmarks: {},
11374
+ remoteBookmarks: {},
11375
+ heads: [changeId],
11376
+ workingCopy: workingCopy.getCurrentChangeId(),
11377
+ },
11378
+ });
11379
+
11380
+ return { changeId, resolved: true, conflicts: [], commitId: convergedChange.commitId };
11381
+ },
11382
+
9583
11383
  /**
9584
11384
  * Backout a change - create a new change that reverses the effect of the specified change
9585
11385
  *
@@ -11041,7 +12841,7 @@ async function createJJ(options) {
11041
12841
  .split('/')
11042
12842
  .pop()
11043
12843
  .replace(/\.git$/, '');
11044
- const fullCloneDir = path.join(dir, cloneDir);
12844
+ const fullCloneDir = join(dir, cloneDir);
11045
12845
 
11046
12846
  // Clone using isomorphic-git
11047
12847
  await git.clone({
@@ -11419,8 +13219,14 @@ async function createJJ(options) {
11419
13219
  * @returns {Promise<Object>} Repository root information
11420
13220
  */
11421
13221
  async root() {
11422
- // Return the directory containing .git
11423
- const gitDir = path.join(dir, '.git');
13222
+ // `dir` is a real host filesystem path (unlike the repo-relative
13223
+ // git/jj paths `path` — posix-path.js — is for), so it needs the
13224
+ // host's own separator here, not always '/': on Windows Node's real
13225
+ // `fs` still resolves a forward-slash-joined path fine, but this
13226
+ // method hands the string back to the caller as data, and jj's own
13227
+ // `git root` returns a platform-native path there.
13228
+ const isWin32 = typeof process !== 'undefined' && process.platform === 'win32';
13229
+ const gitDir = isWin32 ? `${dir.replace(/[\\/]+$/, '')}\\.git` : join(dir, '.git');
11424
13230
 
11425
13231
  try {
11426
13232
  await fs.promises.access(gitDir);
@@ -11499,7 +13305,7 @@ async function createJJ(options) {
11499
13305
  const wcFiles = await workingCopy.listFiles();
11500
13306
  for (const file of wcFiles) {
11501
13307
  try {
11502
- const content = await fs.promises.readFile(path.join(dir, file), 'utf-8');
13308
+ const content = await fs.promises.readFile(join(dir, file), 'utf8');
11503
13309
  leftFiles.set(file, content);
11504
13310
  } catch (error) {
11505
13311
  // File might be deleted or binary - throw error for explicit handling
@@ -11642,7 +13448,7 @@ async function createJJ(options) {
11642
13448
  }
11643
13449
 
11644
13450
  // Write resolved content to file
11645
- await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf-8');
13451
+ await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
11646
13452
 
11647
13453
  // Mark conflict as resolved
11648
13454
  await conflicts.resolveConflict(args.conflictId, 'manual');
@@ -11685,7 +13491,7 @@ async function createJJ(options) {
11685
13491
  const resolvedContent = _resolveWithStrategy(conflict, args.strategy);
11686
13492
 
11687
13493
  // Write resolved content
11688
- await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf-8');
13494
+ await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
11689
13495
 
11690
13496
  // Mark as resolved
11691
13497
  await conflicts.resolveConflict(conflict.conflictId, args.strategy);
@@ -12007,7 +13813,7 @@ async function createJJ(options) {
12007
13813
  );
12008
13814
  }
12009
13815
 
12010
- const fullPath = path.join(dir, args.path);
13816
+ const fullPath = join(dir, args.path);
12011
13817
 
12012
13818
  // Convert string mode to number if needed
12013
13819
  const mode = typeof args.mode === 'string' ? parseInt(args.mode, 8) : args.mode;
@@ -12118,14 +13924,14 @@ async function createJJ(options) {
12118
13924
  if (change.fileSnapshot) {
12119
13925
  for (const [filePath, content] of Object.entries(change.fileSnapshot)) {
12120
13926
  try {
12121
- const fullPath = path.join(args.path, filePath);
13927
+ const fullPath = join(args.path, filePath);
12122
13928
 
12123
13929
  // Ensure directory exists
12124
13930
  const pathParts = filePath.split('/');
12125
13931
  if (pathParts.length > 1) {
12126
13932
  const dirPath = pathParts.slice(0, -1).join('/');
12127
- const fullDirPath = path.join(args.path, dirPath);
12128
- await fs.promises.mkdir(fullDirPath, { recursive: true });
13933
+ const fullDirPath = join(args.path, dirPath);
13934
+ await mkdirp(fs, fullDirPath);
12129
13935
  }
12130
13936
 
12131
13937
  // Write file content