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.mjs CHANGED
@@ -1,8 +1,5 @@
1
- import path from 'path';
2
1
  import git from 'isomorphic-git';
3
- import protobuf from 'protobufjs';
4
- import { fileURLToPath } from 'url';
5
- import crypto$1 from 'crypto';
2
+ import protobuf from 'protobufjs/light.js';
6
3
 
7
4
  /**
8
5
  * JJError - Custom error class for isomorphic-jj
@@ -90,6 +87,48 @@ class KeyedMutex {
90
87
  }
91
88
  }
92
89
 
90
+ /**
91
+ * Recursive directory creation that works across filesystem backends.
92
+ *
93
+ * Why not `fs.promises.mkdir(path, { recursive: true })`: that's a Node
94
+ * `fs` feature. `@isomorphic-git/lightning-fs` (the filesystem this library
95
+ * documents for browser use — see issue #28) implements `mkdir()` but
96
+ * silently ignores the `recursive` option and throws `ENOENT` if any
97
+ * intermediate directory is missing, so code that assumed Node's recursive
98
+ * mkdir semantics would work identically against LightningFS actually
99
+ * broke the very first real repo init in a browser. This walks the path
100
+ * one segment at a time and creates each directory that doesn't exist yet,
101
+ * which is correct against both Node's `fs` and LightningFS (and is a
102
+ * harmless no-op-per-segment against Node's `fs` too).
103
+ *
104
+ * @param {any} fs - Filesystem implementation (isomorphic-git compatible;
105
+ * needs `fs.promises.mkdir(path)`)
106
+ * @param {string} dirPath - Absolute, POSIX-style directory path to create
107
+ * @returns {Promise<void>}
108
+ */
109
+ async function mkdirp(fs, dirPath) {
110
+ const isAbsolute = dirPath.charCodeAt(0) === 47; // '/'
111
+ const prefix = isAbsolute ? '/' : '';
112
+ const segments = dirPath.split('/').filter(Boolean);
113
+
114
+ // Compare against `current === ''` (not a truthiness check) below: the
115
+ // first accumulated segment of an absolute path is itself just `/x`,
116
+ // which parses fine, but a naive `current ? ... : ...` truthiness check
117
+ // would treat the *string* `''` the same as "unset" forever and never
118
+ // prepend the leading `/`.
119
+ let current = '';
120
+ for (const segment of segments) {
121
+ current = current === '' ? prefix + segment : `${current}/${segment}`;
122
+ try {
123
+ await fs.promises.mkdir(current);
124
+ } catch (err) {
125
+ // Already exists — fine, that's exactly what "recursive" means.
126
+ if (err && (err.code === 'EEXIST' || err.code === 'EISDIR')) continue;
127
+ throw err;
128
+ }
129
+ }
130
+ }
131
+
93
132
  /**
94
133
  * Storage Manager for isomorphic-jj
95
134
  *
@@ -130,17 +169,17 @@ class Storage {
130
169
  async init() {
131
170
  try {
132
171
  // Create main directories
133
- await this.fs.promises.mkdir(this.jjDir, { recursive: true });
134
- await this.fs.promises.mkdir(this.repoDir, { recursive: true });
135
- await this.fs.promises.mkdir(this.workingCopyDir, { recursive: true });
172
+ await mkdirp(this.fs, this.jjDir);
173
+ await mkdirp(this.fs, this.repoDir);
174
+ await mkdirp(this.fs, this.workingCopyDir);
136
175
 
137
176
  // Create repo subdirectories
138
- await this.fs.promises.mkdir(`${this.repoDir}/store`, { recursive: true });
139
- await this.fs.promises.mkdir(`${this.repoDir}/op_log`, { recursive: true });
140
- await this.fs.promises.mkdir(`${this.repoDir}/conflicts`, { recursive: true });
177
+ await mkdirp(this.fs, `${this.repoDir}/store`);
178
+ await mkdirp(this.fs, `${this.repoDir}/op_log`);
179
+ await mkdirp(this.fs, `${this.repoDir}/conflicts`);
141
180
 
142
181
  // Create default workspace directory
143
- await this.fs.promises.mkdir(`${this.workingCopyDir}/default`, { recursive: true });
182
+ await mkdirp(this.fs, `${this.workingCopyDir}/default`);
144
183
  } catch (error) {
145
184
  throw new JJError(
146
185
  'STORAGE_INIT_FAILED',
@@ -217,7 +256,7 @@ class Storage {
217
256
  try {
218
257
  // Ensure parent directory exists
219
258
  const dirPath = fullPath.substring(0, fullPath.lastIndexOf('/'));
220
- await this.fs.promises.mkdir(dirPath, { recursive: true });
259
+ await mkdirp(this.fs, dirPath);
221
260
 
222
261
  // Write to temp file
223
262
  await this.fs.promises.writeFile(tmpPath, jsonData, 'utf8');
@@ -473,6 +512,75 @@ function validateBookmarkName(name) {
473
512
  }
474
513
  }
475
514
 
515
+ /**
516
+ * ID Generation utilities for isomorphic-jj
517
+ *
518
+ * Generates stable change IDs and operation IDs using cryptographically secure random values.
519
+ */
520
+
521
+ /**
522
+ * Generate `byteLength` cryptographically random bytes as a lowercase hex string.
523
+ *
524
+ * Uses the standard Web Crypto API (`crypto.getRandomValues`), available as
525
+ * a global in both Node.js (18.14+) and every browser — unlike Node's
526
+ * `crypto.randomBytes`, which requires `import 'crypto'` and does not exist
527
+ * in a browser bundle (see issue #28).
528
+ *
529
+ * @param {number} byteLength - Number of random bytes to generate
530
+ * @returns {string} `byteLength * 2`-character lowercase hex string
531
+ */
532
+ function randomHex(byteLength) {
533
+ const bytes = crypto.getRandomValues(new Uint8Array(byteLength));
534
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
535
+ }
536
+
537
+ /**
538
+ * Generate a stable change ID
539
+ *
540
+ * Uses 128-bit cryptographically random value encoded as 32-character hex string.
541
+ * Collision probability is negligible (2^128 space).
542
+ *
543
+ * @returns {string} 32-character lowercase hex string
544
+ */
545
+ function generateChangeId() {
546
+ return randomHex(16);
547
+ }
548
+
549
+ /**
550
+ * Generate an operation ID from operation content
551
+ *
552
+ * Uses SHA-256 hash of operation content for integrity verification.
553
+ *
554
+ * @param {Object.<string, any>} operation - Operation data
555
+ * @returns {Promise<string>} 64-character lowercase hex string (SHA-256)
556
+ */
557
+ async function generateOperationId(operation) {
558
+ const content = JSON.stringify({
559
+ timestamp: operation.timestamp,
560
+ user: operation.user,
561
+ description: operation.description,
562
+ parents: operation.parents,
563
+ view: operation.view,
564
+ });
565
+
566
+ const encoder = new TextEncoder();
567
+ const data = encoder.encode(content);
568
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
569
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
570
+ return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
571
+ }
572
+
573
+ /**
574
+ * Generate a generic ID with optional prefix
575
+ *
576
+ * @param {string} prefix - Optional prefix (e.g., 'conflict', 'bookmark')
577
+ * @returns {string} ID string
578
+ */
579
+ function generateId(prefix = '') {
580
+ const id = randomHex(16);
581
+ return prefix ? `${prefix}-${id}` : id;
582
+ }
583
+
476
584
  /**
477
585
  * ChangeGraph - Manages the change graph with stable change IDs
478
586
  *
@@ -568,6 +676,68 @@ class ChangeGraph {
568
676
  await this.save();
569
677
  }
570
678
 
679
+ /**
680
+ * Add a DIVERGENT copy of an existing change: another commit that also
681
+ * claims `change.changeId` (see issue #32 / the `divergent()` revset —
682
+ * "multiple visible commits sharing one change id"). `addChange()`
683
+ * can't represent this: `nodes` is a `Map<changeId, change>`, one entry
684
+ * per key, by design — every other API (getChange, getParents,
685
+ * getChildren, ...) keys on plain changeId and would be ambiguous if
686
+ * that stopped being unique.
687
+ *
688
+ * Divergent copies are therefore stored under a synthetic internal key
689
+ * (`${changeId}\0${commitId}`) so the `nodes` Map stays a true map, but
690
+ * each copy's *own* `changeId` field is unchanged — so `getAll()` (and
691
+ * therefore the existing `divergent()` revset filter, which counts
692
+ * occurrences of `c.changeId` across `getAll()`) sees both/all of them
693
+ * and correctly reports the change as divergent. `getChange(changeId)`
694
+ * deliberately keeps resolving to just the primary copy (the one
695
+ * actually stored under the plain `changeId` key) — for anything that
696
+ * needs every copy, see getDivergentSiblings().
697
+ *
698
+ * @param {Record<string, any>} change - The divergent copy (must have
699
+ * the same `changeId` as an existing change, and its own `commitId`)
700
+ */
701
+ async addDivergentCopy(change) {
702
+ validateChangeId(change.changeId);
703
+
704
+ if (!this.nodes.has(change.changeId)) {
705
+ throw new JJError(
706
+ 'CHANGE_NOT_FOUND',
707
+ `Cannot add a divergent copy of ${change.changeId}: no existing change with that id`,
708
+ { changeId: change.changeId, suggestion: 'Use addChange() to create the first copy' }
709
+ );
710
+ }
711
+
712
+ const key = `${change.changeId}\0${change.commitId}`;
713
+ if (this.nodes.has(key)) {
714
+ throw new JJError(
715
+ 'CHANGE_EXISTS',
716
+ `Change ${change.changeId} already has a divergent copy with commit ${change.commitId}`,
717
+ { changeId: change.changeId, commitId: change.commitId }
718
+ );
719
+ }
720
+
721
+ change.divergent = true;
722
+ this.nodes.set(key, change);
723
+ this.commitIndex.set(change.commitId, change.changeId);
724
+
725
+ await this.save();
726
+ }
727
+
728
+ /**
729
+ * Get every visible copy of a (possibly divergent) change id — just the
730
+ * one copy in the common case, or the primary plus every divergent copy
731
+ * added via addDivergentCopy().
732
+ *
733
+ * @param {string} changeId - Change ID
734
+ * @returns {Array<any>} All copies sharing this changeId, primary first
735
+ */
736
+ getDivergentSiblings(changeId) {
737
+ validateChangeId(changeId);
738
+ return this.getAll().filter((change) => change.changeId === changeId);
739
+ }
740
+
571
741
  /**
572
742
  * Get a change by ID
573
743
  *
@@ -679,6 +849,65 @@ class ChangeGraph {
679
849
  await this.save();
680
850
  }
681
851
 
852
+ /**
853
+ * Remove one divergent copy added via addDivergentCopy() — e.g. after
854
+ * converge() (issue #32) resolves a divergence and only needs to drop
855
+ * the now-superseded copy. The *primary* copy (stored under the plain
856
+ * `changeId` key) is never touched by this — use deleteChange() (or
857
+ * updateChange() to overwrite it with a resolved result) for that.
858
+ *
859
+ * @param {string} changeId
860
+ * @param {string} commitId - The divergent copy's own commit id
861
+ * @returns {Promise<boolean>} Whether a copy was actually removed
862
+ */
863
+ async deleteDivergentCopy(changeId, commitId) {
864
+ validateChangeId(changeId);
865
+
866
+ const key = `${changeId}\0${commitId}`;
867
+ const change = this.nodes.get(key);
868
+ if (!change) {
869
+ return false;
870
+ }
871
+
872
+ this.nodes.delete(key);
873
+ if (this.commitIndex.get(commitId) === changeId) {
874
+ this.commitIndex.delete(commitId);
875
+ }
876
+
877
+ await this.save();
878
+ return true;
879
+ }
880
+
881
+ /**
882
+ * Remove a change from the graph outright.
883
+ *
884
+ * Used to reverse an addChange() — e.g. undoing an operation that created
885
+ * a change (new(), or squash()'s synthetic empty working-copy change) —
886
+ * where restoring a "before" snapshot doesn't apply because there was no
887
+ * "before": the change simply didn't exist yet. A soft-delete (setting
888
+ * `abandoned: true` via updateChange()) is NOT the same thing and is used
889
+ * separately for abandon() itself.
890
+ *
891
+ * @param {string} changeId - Change ID to remove
892
+ * @returns {Promise<boolean>} Whether a change was actually removed
893
+ */
894
+ async deleteChange(changeId) {
895
+ validateChangeId(changeId);
896
+
897
+ const change = this.nodes.get(changeId);
898
+ if (!change) {
899
+ return false;
900
+ }
901
+
902
+ this.nodes.delete(changeId);
903
+ if (this.commitIndex.get(change.commitId) === changeId) {
904
+ this.commitIndex.delete(change.commitId);
905
+ }
906
+
907
+ await this.save();
908
+ return true;
909
+ }
910
+
682
911
  /**
683
912
  * Get all ancestors of a change (recursive parent traversal)
684
913
  *
@@ -716,10 +945,9 @@ class ChangeGraph {
716
945
  * @returns {Promise<Record<string, any>>} Created change object
717
946
  */
718
947
  async createChange(params = {}) {
719
- const crypto = await import('crypto');
720
- const changeId = params.changeId || crypto.randomBytes(16).toString('hex');
721
- const commitId = params.commitId || crypto.randomBytes(20).toString('hex');
722
- const tree = params.tree || crypto.randomBytes(20).toString('hex');
948
+ const changeId = params.changeId || randomHex(16);
949
+ const commitId = params.commitId || randomHex(20);
950
+ const tree = params.tree || randomHex(20);
723
951
  const timestamp = params.timestamp || new Date().toISOString();
724
952
 
725
953
  /** @type {Record<string, any>} */
@@ -761,6 +989,44 @@ class ChangeGraph {
761
989
  */
762
990
 
763
991
 
992
+ /**
993
+ * Normalize an mtime value to a millisecond epoch number so two stat()
994
+ * results (or a stat() result and a stored value) can be compared by VALUE
995
+ * instead of by object identity (see issue #41).
996
+ *
997
+ * `fs.promises.stat()` returns a fresh `Date` instance on every call, so two
998
+ * separate stats of the same unchanged file are never `===`/`!==`-equal even
999
+ * when the underlying timestamp is identical. Additionally, once a tracked
1000
+ * mtime has been through a `JSON.stringify`/`JSON.parse` round-trip (Storage
1001
+ * persists working-copy state as JSON), a `Date` becomes an ISO string, not a
1002
+ * `Date` — so the stored side of the comparison may be a `Date`, a plain
1003
+ * epoch-ms number (this module now stores numbers going forward), or an ISO
1004
+ * string (older/reloaded-from-disk state). This normalizes all three to a
1005
+ * comparable number.
1006
+ *
1007
+ * Deliberately duck-types the Date case (`typeof value.getTime ===
1008
+ * 'function'`) instead of `value instanceof Date`: Node's built-in `fs`
1009
+ * module constructs `Stats.mtime` using its own realm's `Date`, which is not
1010
+ * always the same `Date` constructor as this module's under test runners
1011
+ * that sandbox each test file in its own VM context (observed under Jest
1012
+ * with `--experimental-vm-modules`) — there, a real `Date` instance from
1013
+ * `fs.promises.stat()` fails `instanceof Date` here even though it's a
1014
+ * genuine Date. Duck-typing survives that realm mismatch.
1015
+ *
1016
+ * @param {Date|number|string|undefined} value
1017
+ * @returns {number}
1018
+ */
1019
+ function mtimeMs(value) {
1020
+ if (value === null || value === undefined) return NaN;
1021
+ if (typeof value === 'number') return value;
1022
+ if (typeof value === 'string') return new Date(value).getTime();
1023
+ // Per the type contract above, anything left is Date-like -- duck-typed
1024
+ // (see the comment above) rather than `instanceof Date`, so no runtime
1025
+ // check is needed here either; a value outside this contract is a caller
1026
+ // bug, not something to silently coerce.
1027
+ return /** @type {any} */ (value).getTime();
1028
+ }
1029
+
764
1030
  class WorkingCopy {
765
1031
  /**
766
1032
  * @param {Storage} storage - Storage manager instance
@@ -932,8 +1198,10 @@ class WorkingCopy {
932
1198
  try {
933
1199
  const stats = await this.fs.promises.stat(fullPath);
934
1200
 
935
- // Fast path: check mtime and size
936
- if (stats.mtime !== tracked.mtime || stats.size !== tracked.size) {
1201
+ // Fast path: check mtime and size. Compare by VALUE (see mtimeMs
1202
+ // above) — comparing the raw stat()/stored values with !== is
1203
+ // always "different" even when nothing changed (issue #41).
1204
+ if (mtimeMs(stats.mtime) !== mtimeMs(tracked.mtime) || stats.size !== tracked.size) {
937
1205
  modified.push(path);
938
1206
  }
939
1207
  } catch (error) {
@@ -1044,12 +1312,19 @@ class WorkingCopy {
1044
1312
  }
1045
1313
 
1046
1314
  const prev = this.state.fileStates[rel];
1047
- const next = { mtime: stats.mtime, size: stats.size, mode: stats.mode };
1315
+ // Store mtime as a plain epoch-ms number (not the raw stat() value):
1316
+ // numbers round-trip through JSON exactly, so state reloaded from disk
1317
+ // later compares identically to state still cached in-memory. Goes
1318
+ // through mtimeMs() rather than assuming `.getTime()` exists directly —
1319
+ // some fs implementations (e.g. the in-memory mock used in tests)
1320
+ // return mtime as a plain epoch-ms number already, not a Date (see
1321
+ // mtimeMs above and issue #41).
1322
+ const next = { mtime: mtimeMs(stats.mtime), size: stats.size, mode: stats.mode };
1048
1323
 
1049
1324
  if (!prev) {
1050
1325
  this.state.fileStates[rel] = next;
1051
1326
  added.push(rel);
1052
- } else if (prev.mtime !== stats.mtime || prev.size !== stats.size) {
1327
+ } else if (mtimeMs(stats.mtime) !== mtimeMs(prev.mtime) || prev.size !== stats.size) {
1053
1328
  this.state.fileStates[rel] = next;
1054
1329
  modified.push(rel);
1055
1330
  }
@@ -1183,61 +1458,6 @@ class WorkingCopy {
1183
1458
  }
1184
1459
  }
1185
1460
 
1186
- /**
1187
- * ID Generation utilities for isomorphic-jj
1188
- *
1189
- * Generates stable change IDs and operation IDs using cryptographically secure random values.
1190
- */
1191
-
1192
- /**
1193
- * Generate a stable change ID
1194
- *
1195
- * Uses 128-bit cryptographically random value encoded as 32-character hex string.
1196
- * Collision probability is negligible (2^128 space).
1197
- *
1198
- * @returns {string} 32-character lowercase hex string
1199
- */
1200
- function generateChangeId() {
1201
- const bytes = crypto.getRandomValues(new Uint8Array(16));
1202
- return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
1203
- }
1204
-
1205
- /**
1206
- * Generate an operation ID from operation content
1207
- *
1208
- * Uses SHA-256 hash of operation content for integrity verification.
1209
- *
1210
- * @param {Object.<string, any>} operation - Operation data
1211
- * @returns {Promise<string>} 64-character lowercase hex string (SHA-256)
1212
- */
1213
- async function generateOperationId(operation) {
1214
- const content = JSON.stringify({
1215
- timestamp: operation.timestamp,
1216
- user: operation.user,
1217
- description: operation.description,
1218
- parents: operation.parents,
1219
- view: operation.view,
1220
- });
1221
-
1222
- const encoder = new TextEncoder();
1223
- const data = encoder.encode(content);
1224
- const hashBuffer = await crypto.subtle.digest('SHA-256', data);
1225
- const hashArray = Array.from(new Uint8Array(hashBuffer));
1226
- return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
1227
- }
1228
-
1229
- /**
1230
- * Generate a generic ID with optional prefix
1231
- *
1232
- * @param {string} prefix - Optional prefix (e.g., 'conflict', 'bookmark')
1233
- * @returns {string} ID string
1234
- */
1235
- function generateId(prefix = '') {
1236
- const bytes = crypto.getRandomValues(new Uint8Array(16));
1237
- const id = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
1238
- return prefix ? `${prefix}-${id}` : id;
1239
- }
1240
-
1241
1461
  /**
1242
1462
  * OperationLog - Manages the append-only operation log
1243
1463
  *
@@ -1854,11 +2074,7 @@ class TagStore {
1854
2074
  async save(tags) {
1855
2075
  // Ensure the store directory exists
1856
2076
  const storeDir = `${this.jjDir}/store`;
1857
- try {
1858
- await this.fs.promises.mkdir(storeDir, { recursive: true });
1859
- } catch (err) {
1860
- // Directory might already exist, ignore
1861
- }
2077
+ await mkdirp(this.fs, storeDir);
1862
2078
  await this.fs.promises.writeFile(
1863
2079
  this.tagsFile,
1864
2080
  JSON.stringify({ tags, tracked: this.tracking || {} }, null, 2)
@@ -2497,9 +2713,12 @@ class RevsetEngine {
2497
2713
  const unquote = (/** @type {string} */ s) => s.trim().replace(/^['"]|['"]$/g, '');
2498
2714
 
2499
2715
  switch (name) {
2500
- case 'all':
2716
+ case 'all': {
2501
2717
  await this.graph.load();
2502
- return this.graph.getAll().map((c) => c.changeId);
2718
+ const allChanges = this.graph.getAll();
2719
+ const hidden = await this._computeHiddenOrphans(allChanges);
2720
+ return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
2721
+ }
2503
2722
 
2504
2723
  // `builtin_log()` (jj v0.44) is the revset the built-in `jj log` uses
2505
2724
  // when no user override is configured. isomorphic-jj's `log()` already
@@ -2507,9 +2726,12 @@ class RevsetEngine {
2507
2726
  // `builtin_log()` is a pure alias for that same default — this lets
2508
2727
  // callers compose it (e.g. `builtin_log() & mine()`) instead of
2509
2728
  // duplicating `all()`.
2510
- case 'builtin_log':
2729
+ case 'builtin_log': {
2511
2730
  await this.graph.load();
2512
- return this.graph.getAll().map((c) => c.changeId);
2731
+ const allChanges = this.graph.getAll();
2732
+ const hidden = await this._computeHiddenOrphans(allChanges);
2733
+ return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
2734
+ }
2513
2735
 
2514
2736
  case 'none':
2515
2737
  return [];
@@ -2624,15 +2846,27 @@ class RevsetEngine {
2624
2846
  }
2625
2847
 
2626
2848
  case 'divergent': {
2849
+ // See issue #32 / converge(): a divergent change has more than one
2850
+ // visible commit sharing its changeId (added via
2851
+ // ChangeGraph.addDivergentCopy(), which is why counting `changeId`
2852
+ // occurrences across getAll() actually finds something now —
2853
+ // every other change has exactly one entry here). `c.divergent ===
2854
+ // true` also matches directly, since addDivergentCopy() sets that
2855
+ // flag on the copy it adds; the count check catches the *primary*
2856
+ // copy too (it's never itself flagged, since nothing about IT
2857
+ // changed) so both/all copies of a divergent changeId are
2858
+ // reported, not just the one(s) added after the first.
2627
2859
  await this.graph.load();
2628
2860
  const all = this.graph.getAll();
2629
2861
  const counts = new Map();
2630
2862
  for (const c of all) {
2631
2863
  counts.set(c.changeId, (counts.get(c.changeId) || 0) + 1);
2632
2864
  }
2633
- return all
2634
- .filter((c) => c.divergent === true || counts.get(c.changeId) > 1)
2635
- .map((c) => c.changeId);
2865
+ const matched = all.filter((c) => c.divergent === true || counts.get(c.changeId) > 1);
2866
+ // De-duplicate: multiple copies of the same divergent changeId
2867
+ // would otherwise each contribute their own (identical) changeId
2868
+ // to the result.
2869
+ return Array.from(new Set(matched.map((c) => c.changeId)));
2636
2870
  }
2637
2871
 
2638
2872
  case 'merges':
@@ -2958,6 +3192,108 @@ class RevsetEngine {
2958
3192
  return ancestors;
2959
3193
  }
2960
3194
 
3195
+ /**
3196
+ * Change IDs that `all()`/`builtin_log()` (and therefore the default
3197
+ * `jj.log()`) should exclude — issue #37(b).
3198
+ *
3199
+ * undo()/redo()/operations.restore() deliberately don't delete a change
3200
+ * record for a `new()`/`squash()` that gets reverted — they only un-ref
3201
+ * it (restore the working-copy pointer/heads that made it reachable),
3202
+ * matching real jj where an unreachable change stays resolvable by its
3203
+ * change id (hidden, not gone) until explicitly gc'd — see
3204
+ * computeGraphReversal's doc comment in repository.js. But that means
3205
+ * the orphaned change record lingers in the graph, and before this fix
3206
+ * `all()` (a flat `graph.getAll()`) surfaced it in `log()` right
3207
+ * alongside everything still actually reachable.
3208
+ *
3209
+ * Real jj hides a commit from the default log when it is BOTH:
3210
+ * - unreachable from any visible head (not itself a head, and not an
3211
+ * ancestor of one), AND
3212
+ * - "discardable": empty (no file diff versus its own parent) and
3213
+ * undescribed.
3214
+ * Merge commits (2+ parents) are conservatively never treated as
3215
+ * "empty" here — real jj's diff-emptiness rule for merges is more
3216
+ * involved and isn't needed by anything this package creates today.
3217
+ *
3218
+ * "Visible head" here deliberately does NOT mean "any change with no
3219
+ * children" (that's what the `visible_heads()` revset function computes,
3220
+ * structurally, from the raw graph) — an orphan `new()`/`squash()`
3221
+ * change that undo() un-referenced has no children EITHER, so it would
3222
+ * trivially count as its own head under that definition and never be
3223
+ * excludable. Real jj instead tracks an explicit, maintained set of
3224
+ * heads in its View (updated as operations run — abandoning/squashing a
3225
+ * change removes it, rebasing children onto a new parent removes the
3226
+ * old parent, etc.). This package doesn't thread the operation log's
3227
+ * `view.heads` through to the revset engine, but the working-copy
3228
+ * pointer plus every bookmark/tag target is the closest available
3229
+ * proxy for "what's actually still in play" — anything only reachable
3230
+ * from those, not from every dangling graph leaf.
3231
+ *
3232
+ * @param {any[]} allChanges - `graph.getAll()`
3233
+ * @returns {Promise<Set<string>>} change IDs to exclude
3234
+ */
3235
+ async _computeHiddenOrphans(allChanges) {
3236
+ const trackedHeads = new Set();
3237
+ try {
3238
+ const wcId = this.workingCopy && this.workingCopy.getCurrentChangeId();
3239
+ if (wcId) trackedHeads.add(wcId);
3240
+ } catch {
3241
+ // working copy not loaded/available — fine, just no wc-based root.
3242
+ }
3243
+ if (this.bookmarkStore) {
3244
+ await this.bookmarkStore.load();
3245
+ for (const bookmark of await this.bookmarkStore.list()) {
3246
+ if (bookmark && bookmark.changeId) trackedHeads.add(bookmark.changeId);
3247
+ }
3248
+ }
3249
+ if (this.tagStore) {
3250
+ for (const tag of await this.tagStore.list()) {
3251
+ if (tag && tag.changeId) trackedHeads.add(tag.changeId);
3252
+ }
3253
+ }
3254
+
3255
+ const reachable = new Set();
3256
+ for (const headId of trackedHeads) {
3257
+ for (const id of await this.getAncestors(headId)) {
3258
+ reachable.add(id);
3259
+ }
3260
+ }
3261
+
3262
+ // Sentinel "no message given" descriptions this package itself writes
3263
+ // (new()'s '(no description)', squash()'s post-squash working-copy
3264
+ // change '(no description set)') are, for visibility purposes, just
3265
+ // as undescribed as a genuinely empty/missing description field.
3266
+ const UNDESCRIBED = new Set([undefined, null, '', '(no description)', '(no description set)']);
3267
+
3268
+ const byId = new Map(allChanges.map((c) => [c.changeId, c]));
3269
+ const isEmpty = (/** @type {any} */ change) => {
3270
+ const parents = change.parents || [];
3271
+ if (parents.length === 0) {
3272
+ return !change.fileSnapshot || Object.keys(change.fileSnapshot).length === 0;
3273
+ }
3274
+ if (parents.length > 1) return false; // merges: not handled by this rule
3275
+ const parent = byId.get(parents[0]);
3276
+ const parentSnapshot = (parent && parent.fileSnapshot) || {};
3277
+ const ownSnapshot = change.fileSnapshot || {};
3278
+ const parentKeys = Object.keys(parentSnapshot);
3279
+ const ownKeys = Object.keys(ownSnapshot);
3280
+ if (parentKeys.length !== ownKeys.length) return false;
3281
+ return parentKeys.every((k) => parentSnapshot[k] === ownSnapshot[k]);
3282
+ };
3283
+
3284
+ const hidden = new Set();
3285
+ for (const change of allChanges) {
3286
+ if (
3287
+ !reachable.has(change.changeId) &&
3288
+ isEmpty(change) &&
3289
+ UNDESCRIBED.has(change.description)
3290
+ ) {
3291
+ hidden.add(change.changeId);
3292
+ }
3293
+ }
3294
+ return hidden;
3295
+ }
3296
+
2961
3297
  /**
2962
3298
  * Resolve a revset argument to a set of change IDs (v1.5).
2963
3299
  *
@@ -3686,43 +4022,221 @@ class RevsetEngine {
3686
4022
  }
3687
4023
 
3688
4024
  /**
3689
- * ConflictModel - First-class conflict handling
4025
+ * Minimal POSIX-only path utilities (join / dirname / resolve).
3690
4026
  *
3691
- * In JJ, conflicts are not errors but structured data that can be:
3692
- * - Stored in commits
3693
- * - Rebased along with changes
3694
- * - Resolved incrementally
3695
- * - Committed and shared
4027
+ * Why not Node's `path` module: this library is isomorphic — it also needs
4028
+ * to run in browser bundles (see issue #28), where bare `import path from
4029
+ * 'path'` fails unless a bundler-specific polyfill is configured. Git/jj
4030
+ * repository paths are always POSIX-style (forward slashes), regardless of
4031
+ * host OS, so depending on Node's `path` module was also subtly wrong on
4032
+ * Windows: `path.join()` there uses `path.win32` semantics and would
4033
+ * produce backslash-joined paths for repository-relative paths, which is
4034
+ * not what git/jj object storage or working-copy paths want.
3696
4035
  *
3697
- * This matches JJ's philosophy: conflicts never block you.
4036
+ * These implement the same normalization rules as Node's `path.posix` for
4037
+ * the operations this codebase actually uses.
4038
+ */
4039
+
4040
+ /**
4041
+ * @param {string} path
4042
+ * @returns {string} `path` with any run of `/` collapsed to a single `/`,
4043
+ * and `.`/`..` segments resolved. Preserves a leading `/` (absolute) and
4044
+ * a trailing `/` when present in the input.
3698
4045
  */
4046
+ function normalize(path) {
4047
+ if (path === '') return '.';
4048
+
4049
+ const isAbsolute = path.charCodeAt(0) === 47; // '/'
4050
+ const trailingSlash = path.charCodeAt(path.length - 1) === 47;
4051
+
4052
+ const segments = path.split('/');
4053
+ /** @type {string[]} */
4054
+ const out = [];
4055
+ for (const segment of segments) {
4056
+ if (segment === '' || segment === '.') continue;
4057
+ if (segment === '..') {
4058
+ if (out.length && out[out.length - 1] !== '..') {
4059
+ out.pop();
4060
+ } else if (!isAbsolute) {
4061
+ out.push('..');
4062
+ }
4063
+ continue;
4064
+ }
4065
+ out.push(segment);
4066
+ }
3699
4067
 
4068
+ let result = out.join('/');
4069
+ if (!result && !isAbsolute) result = '.';
4070
+ if (isAbsolute) result = '/' + result;
4071
+ if (trailingSlash && result.charCodeAt(result.length - 1) !== 47) result += '/';
4072
+ return result;
4073
+ }
3700
4074
 
3701
4075
  /**
3702
- * Conflict types supported by JJ
4076
+ * @param {...string} parts
4077
+ * @returns {string} All `parts` joined with `/` and normalized.
3703
4078
  */
3704
- const ConflictType = {
3705
- CONTENT: 'content', // File content conflicts (3-way merge)
3706
- DELETE_MODIFY: 'delete-modify', // File deleted in one side, modified in other
3707
- MODIFY_DELETE: 'modify-delete', // File modified in one side, deleted in other
3708
- ADD_ADD: 'add-add', // Same path added in both sides
3709
- };
4079
+ function join(...parts) {
4080
+ if (parts.length === 0) return '.';
4081
+ const joined = parts.filter((p) => p !== '').join('/');
4082
+ return joined === '' ? '.' : normalize(joined);
4083
+ }
3710
4084
 
3711
4085
  /**
3712
- * ConflictModel manages conflict detection, storage, and resolution
4086
+ * @param {string} path
4087
+ * @returns {string} The directory portion of `path` (Node `path.dirname` semantics).
3713
4088
  */
3714
- class ConflictModel {
3715
- /**
3716
- * @param {any} storage
3717
- * @param {any} fs
3718
- * @param {any} [mergeDriverRegistry]
3719
- */
3720
- constructor(storage, fs, mergeDriverRegistry = null) {
3721
- this.storage = storage;
3722
- this.fs = fs;
3723
- /** @type {any} */
3724
- this.mergeDriverRegistry = mergeDriverRegistry; // v0.5: merge drivers
3725
- this.conflicts = new Map(); // conflictId -> Conflict
4089
+ function dirname(path) {
4090
+ if (path.length === 0) return '.';
4091
+ const isAbsolute = path.charCodeAt(0) === 47;
4092
+ let end = -1;
4093
+ let matchedSlash = true;
4094
+ for (let i = path.length - 1; i >= 1; i--) {
4095
+ if (path.charCodeAt(i) === 47) {
4096
+ if (!matchedSlash) {
4097
+ end = i;
4098
+ break;
4099
+ }
4100
+ } else {
4101
+ matchedSlash = false;
4102
+ }
4103
+ }
4104
+
4105
+ if (end === -1) return isAbsolute ? '/' : '.';
4106
+ if (isAbsolute && end === 0) return '/';
4107
+ return path.slice(0, end);
4108
+ }
4109
+
4110
+ /**
4111
+ * Resolves a sequence of path segments into an absolute path.
4112
+ *
4113
+ * Unlike Node's `path.resolve`, this never consults the real process cwd —
4114
+ * segments are resolved right-to-left, and if no absolute segment is found
4115
+ * the result is left relative to a virtual `/` root (this library always
4116
+ * deals in absolute repository-relative paths, so callers are expected to
4117
+ * supply an absolute base).
4118
+ *
4119
+ * @param {...string} parts
4120
+ * @returns {string}
4121
+ */
4122
+ function resolve(...parts) {
4123
+ let resolved = '';
4124
+ let isAbsolute = false;
4125
+
4126
+ for (let i = parts.length - 1; i >= -1 && !isAbsolute; i--) {
4127
+ const path = i >= 0 ? parts[i] : '/';
4128
+ if (path.length === 0) continue;
4129
+ resolved = path + '/' + resolved;
4130
+ isAbsolute = path.charCodeAt(0) === 47;
4131
+ }
4132
+
4133
+ resolved = normalize(resolved);
4134
+ // normalize() preserves a trailing slash; path.resolve() never returns one
4135
+ // (except for the root itself).
4136
+ if (resolved.length > 1 && resolved.endsWith('/')) {
4137
+ resolved = resolved.slice(0, -1);
4138
+ }
4139
+ return isAbsolute ? resolved : '/' + resolved;
4140
+ }
4141
+
4142
+ /**
4143
+ * Cross-environment byte/string conversion helpers.
4144
+ *
4145
+ * Why not just Node's `Buffer`: it's a Node global that most browser
4146
+ * bundlers (Vite, esbuild, webpack 5+) do not polyfill by default, so code
4147
+ * that calls `Buffer.from(...)` compiles fine but throws `ReferenceError:
4148
+ * Buffer is not defined` at runtime in a browser bundle (see issue #28).
4149
+ *
4150
+ * `utf8Encode`/`utf8Decode` still prefer the real `Buffer` when it's present
4151
+ * (i.e. under Node) so existing behavior — including `.toString()` producing
4152
+ * the original text back, which a plain `Uint8Array` cannot do — is
4153
+ * unchanged there; `typeof Buffer` is safe to evaluate even where `Buffer`
4154
+ * was never declared (unlike referencing it directly), so this doesn't
4155
+ * reintroduce the crash outside Node. `hexToBytes`/`bytesToHex`/`isBytes`
4156
+ * only ever need `Uint8Array`, so they don't need the same branch.
4157
+ */
4158
+ const hasBuffer = typeof Buffer !== 'undefined';
4159
+
4160
+ /**
4161
+ * @param {string} hex - Even-length hex string
4162
+ * @returns {Uint8Array}
4163
+ */
4164
+ function hexToBytes(hex) {
4165
+ const length = hex.length / 2;
4166
+ const bytes = new Uint8Array(length);
4167
+ for (let i = 0; i < length; i++) {
4168
+ bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
4169
+ }
4170
+ return bytes;
4171
+ }
4172
+
4173
+ /**
4174
+ * @param {string} str
4175
+ * @returns {Uint8Array} UTF-8 encoded bytes (a real `Buffer` under Node)
4176
+ */
4177
+ function utf8Encode(str) {
4178
+ return hasBuffer ? Buffer.from(str, 'utf-8') : new TextEncoder().encode(str);
4179
+ }
4180
+
4181
+ /**
4182
+ * @param {Uint8Array} bytes
4183
+ * @returns {string} UTF-8 decoded string
4184
+ */
4185
+ function utf8Decode(bytes) {
4186
+ return hasBuffer ? Buffer.from(bytes).toString('utf-8') : new TextDecoder('utf-8').decode(bytes);
4187
+ }
4188
+
4189
+ /**
4190
+ * True for anything that walks/quacks like binary content — a real Node
4191
+ * `Buffer` (which is itself a `Uint8Array` subclass) or a plain
4192
+ * `Uint8Array`/typed array, as produced by protobufjs in environments
4193
+ * without `Buffer` (e.g. a browser bundle).
4194
+ *
4195
+ * @param {any} value
4196
+ * @returns {boolean}
4197
+ */
4198
+ function isBytes(value) {
4199
+ return value instanceof Uint8Array;
4200
+ }
4201
+
4202
+ /**
4203
+ * ConflictModel - First-class conflict handling
4204
+ *
4205
+ * In JJ, conflicts are not errors but structured data that can be:
4206
+ * - Stored in commits
4207
+ * - Rebased along with changes
4208
+ * - Resolved incrementally
4209
+ * - Committed and shared
4210
+ *
4211
+ * This matches JJ's philosophy: conflicts never block you.
4212
+ */
4213
+
4214
+
4215
+ /**
4216
+ * Conflict types supported by JJ
4217
+ */
4218
+ const ConflictType = {
4219
+ CONTENT: 'content', // File content conflicts (3-way merge)
4220
+ DELETE_MODIFY: 'delete-modify', // File deleted in one side, modified in other
4221
+ MODIFY_DELETE: 'modify-delete', // File modified in one side, deleted in other
4222
+ ADD_ADD: 'add-add', // Same path added in both sides
4223
+ };
4224
+
4225
+ /**
4226
+ * ConflictModel manages conflict detection, storage, and resolution
4227
+ */
4228
+ class ConflictModel {
4229
+ /**
4230
+ * @param {any} storage
4231
+ * @param {any} fs
4232
+ * @param {any} [mergeDriverRegistry]
4233
+ */
4234
+ constructor(storage, fs, mergeDriverRegistry = null) {
4235
+ this.storage = storage;
4236
+ this.fs = fs;
4237
+ /** @type {any} */
4238
+ this.mergeDriverRegistry = mergeDriverRegistry; // v0.5: merge drivers
4239
+ this.conflicts = new Map(); // conflictId -> Conflict
3726
4240
  this.fileConflicts = new Map(); // path -> conflictId
3727
4241
  }
3728
4242
 
@@ -3857,6 +4371,15 @@ class ConflictModel {
3857
4371
  const conflict = this._detectPathConflict(path, baseContent, leftContent, rightContent);
3858
4372
  if (conflict) {
3859
4373
  conflicts.push(conflict);
4374
+ } else if (workingCopyDir) {
4375
+ // No conflict, but "no conflict" doesn't mean "nothing to do": of
4376
+ // _detectPathConflict's null-returning cases, every one *except*
4377
+ // "right side changed, left didn't" already matches what's on disk,
4378
+ // because `leftContent` **is** what's on disk (read straight from
4379
+ // the working copy above) -- so only that one case needs a write.
4380
+ if (baseContent === leftContent && baseContent !== rightContent) {
4381
+ await this._applyCleanResolution(workingCopyDir, path, rightContent);
4382
+ }
3860
4383
  }
3861
4384
  }
3862
4385
 
@@ -3944,6 +4467,30 @@ class ConflictModel {
3944
4467
  return null;
3945
4468
  }
3946
4469
 
4470
+ /**
4471
+ * Create a content conflict with more than two disagreeing versions —
4472
+ * every other conflict in this module has exactly two sides (`left`/
4473
+ * `right`, or `left`/`right` on an add-add), because every OTHER caller
4474
+ * (merge, rebase) only ever three-way-merges two changes at a time.
4475
+ * converge() (issue #32) can face more than two divergent copies of one
4476
+ * change in a single call, so `sides` here is `{ base, versions }`
4477
+ * instead: `versions` is every distinct value found across all copies
4478
+ * that differs from `base`, each paired with (one of) the commitId(s)
4479
+ * that has it.
4480
+ *
4481
+ * @param {string} path
4482
+ * @param {any} base
4483
+ * @param {Array<{commitId: string, content: any}>} versions
4484
+ */
4485
+ createNWayConflict(path, base, versions) {
4486
+ return this._createConflict({
4487
+ type: ConflictType.CONTENT,
4488
+ path,
4489
+ sides: { base, versions },
4490
+ message: `${versions.length} divergent copies disagree on this file's content`,
4491
+ });
4492
+ }
4493
+
3947
4494
  /**
3948
4495
  * Create a conflict object
3949
4496
  *
@@ -4187,6 +4734,36 @@ class ConflictModel {
4187
4734
  return result;
4188
4735
  }
4189
4736
 
4737
+ /**
4738
+ * Apply a clean (non-conflicting) three-way resolution to the working
4739
+ * copy, for the default (no merge driver) path in detectConflicts(): the
4740
+ * "right side wins" case, where the resolved content differs from what's
4741
+ * currently on disk (see the call site for why this is the only clean
4742
+ * case that needs one).
4743
+ *
4744
+ * @param {string} workingCopyDir - Working copy directory
4745
+ * @param {string} filePath - File path
4746
+ * @param {any} content - The winning (right) side's content, or
4747
+ * `undefined` to mean the file was deleted on that side
4748
+ */
4749
+ async _applyCleanResolution(workingCopyDir, filePath, content) {
4750
+ const fullPath = join(workingCopyDir, filePath);
4751
+
4752
+ if (content === undefined) {
4753
+ await this.fs.promises.unlink(fullPath).catch(() => {});
4754
+ return;
4755
+ }
4756
+
4757
+ const dir = dirname(fullPath);
4758
+ await mkdirp(this.fs, dir);
4759
+
4760
+ if (isBytes(content)) {
4761
+ await this.fs.promises.writeFile(fullPath, content);
4762
+ } else {
4763
+ await this.fs.promises.writeFile(fullPath, content, 'utf8');
4764
+ }
4765
+ }
4766
+
4190
4767
  /**
4191
4768
  * Write merge driver result to working copy (v0.5)
4192
4769
  *
@@ -4195,32 +4772,32 @@ class ConflictModel {
4195
4772
  * @param {Record<string, any>} result - Merge result
4196
4773
  */
4197
4774
  async _writeDriverResult(workingCopyDir, filePath, result) {
4198
- const fullPath = path.join(workingCopyDir, filePath);
4775
+ const fullPath = join(workingCopyDir, filePath);
4199
4776
 
4200
4777
  // Ensure directory exists
4201
- const dir = path.dirname(fullPath);
4202
- await this.fs.promises.mkdir(dir, { recursive: true });
4778
+ const dir = dirname(fullPath);
4779
+ await mkdirp(this.fs, dir);
4203
4780
 
4204
4781
  // Write main file
4205
4782
  if (result.content !== null && result.content !== undefined) {
4206
- if (Buffer.isBuffer(result.content)) {
4783
+ if (isBytes(result.content)) {
4207
4784
  await this.fs.promises.writeFile(fullPath, result.content);
4208
4785
  } else {
4209
- await this.fs.promises.writeFile(fullPath, result.content, 'utf-8');
4786
+ await this.fs.promises.writeFile(fullPath, result.content, 'utf8');
4210
4787
  }
4211
4788
  }
4212
4789
 
4213
4790
  // Write additional files if driver provided them
4214
4791
  if (result.additionalFiles) {
4215
4792
  for (const [additionalPath, additionalContent] of Object.entries(result.additionalFiles)) {
4216
- const additionalFullPath = path.join(workingCopyDir, additionalPath);
4217
- const additionalDir = path.dirname(additionalFullPath);
4218
- await this.fs.promises.mkdir(additionalDir, { recursive: true });
4793
+ const additionalFullPath = join(workingCopyDir, additionalPath);
4794
+ const additionalDir = dirname(additionalFullPath);
4795
+ await mkdirp(this.fs, additionalDir);
4219
4796
 
4220
- if (Buffer.isBuffer(additionalContent)) {
4797
+ if (isBytes(additionalContent)) {
4221
4798
  await this.fs.promises.writeFile(additionalFullPath, additionalContent);
4222
4799
  } else {
4223
- await this.fs.promises.writeFile(additionalFullPath, additionalContent, 'utf-8');
4800
+ await this.fs.promises.writeFile(additionalFullPath, additionalContent, 'utf8');
4224
4801
  }
4225
4802
  }
4226
4803
  }
@@ -4266,11 +4843,11 @@ function matchesPattern(path, pattern) {
4266
4843
  /**
4267
4844
  * Check if a file is likely binary based on content
4268
4845
  *
4269
- * @param {string|Buffer} content - File content
4846
+ * @param {string|Uint8Array} content - File content
4270
4847
  * @returns {boolean} Whether content appears binary
4271
4848
  */
4272
4849
  function isBinaryContent(content) {
4273
- if (Buffer.isBuffer(content)) return true;
4850
+ if (isBytes(content)) return true;
4274
4851
  if (typeof content !== 'string') return false;
4275
4852
 
4276
4853
  // Check for null bytes (common in binary files)
@@ -4585,9 +5162,9 @@ class MergeDriverRegistry {
4585
5162
  /**
4586
5163
  * Prepare content for driver based on binary/text
4587
5164
  *
4588
- * @param {string|Buffer|null} content - Raw content
4589
- * @param {boolean} isBinary - Whether to return as Buffer
4590
- * @returns {string|Buffer|null} Prepared content
5165
+ * @param {string|Uint8Array|null} content - Raw content
5166
+ * @param {boolean} isBinary - Whether to return as bytes
5167
+ * @returns {string|Uint8Array|null} Prepared content
4591
5168
  */
4592
5169
  prepareContent(content, isBinary) {
4593
5170
  if (content === null || content === undefined) {
@@ -4595,14 +5172,14 @@ class MergeDriverRegistry {
4595
5172
  }
4596
5173
 
4597
5174
  if (isBinary) {
4598
- // Convert to Buffer if needed
4599
- if (Buffer.isBuffer(content)) return content;
4600
- if (typeof content === 'string') return Buffer.from(content, 'utf-8');
4601
- return Buffer.from(String(content));
5175
+ // Convert to bytes if needed
5176
+ if (isBytes(content)) return content;
5177
+ if (typeof content === 'string') return utf8Encode(content);
5178
+ return utf8Encode(String(content));
4602
5179
  } else {
4603
5180
  // Convert to string if needed
4604
5181
  if (typeof content === 'string') return content;
4605
- if (Buffer.isBuffer(content)) return content.toString('utf-8');
5182
+ if (isBytes(content)) return utf8Decode(content);
4606
5183
  return String(content);
4607
5184
  }
4608
5185
  }
@@ -4763,25 +5340,25 @@ class WorkspaceManager {
4763
5340
  };
4764
5341
 
4765
5342
  // Create workspace directory
4766
- await this.fs.promises.mkdir(args.path, { recursive: true });
5343
+ await mkdirp(this.fs, args.path);
4767
5344
 
4768
5345
  // Create workspace-specific working_copy directory in .jj
4769
- const workspaceCopyDir = path.join(this.repoDir, '.jj', 'working_copy', workspaceId);
4770
- await this.fs.promises.mkdir(workspaceCopyDir, { recursive: true });
5346
+ const workspaceCopyDir = join(this.repoDir, '.jj', 'working_copy', workspaceId);
5347
+ await mkdirp(this.fs, workspaceCopyDir);
4771
5348
 
4772
5349
  // Create .git file pointing to main repo's .git directory
4773
5350
  // This allows Git tools to work in the workspace
4774
5351
  // IMPORTANT: Must use absolute paths for Git compatibility
4775
- const gitFilePath = path.join(args.path, '.git');
4776
- const mainGitDir = path.resolve(this.repoDir, '.git');
5352
+ const gitFilePath = join(args.path, '.git');
5353
+ const mainGitDir = resolve(this.repoDir, '.git');
4777
5354
  const gitFileContent = `gitdir: ${mainGitDir}\n`;
4778
5355
  await this.fs.promises.writeFile(gitFilePath, gitFileContent, 'utf8');
4779
5356
 
4780
5357
  // Create .jj file pointing to main repo's .jj directory
4781
5358
  // This allows JJ tools to work in the workspace
4782
5359
  // IMPORTANT: Must use absolute paths for JJ CLI compatibility
4783
- const jjFilePath = path.join(args.path, '.jj');
4784
- const mainJJDir = path.resolve(this.repoDir, '.jj');
5360
+ const jjFilePath = join(args.path, '.jj');
5361
+ const mainJJDir = resolve(this.repoDir, '.jj');
4785
5362
  const jjFileContent = `jjdir: ${mainJJDir}\n`;
4786
5363
  await this.fs.promises.writeFile(jjFilePath, jjFileContent, 'utf8');
4787
5364
 
@@ -5416,6 +5993,575 @@ class UserConfig {
5416
5993
  }
5417
5994
  }
5418
5995
 
5996
+ /**
5997
+ * Precompiled protobuf reflection JSON for local_working_copy.proto
5998
+ *
5999
+ * Generated from src/protos/local_working_copy.proto by scripts/build-protos.mjs. This
6000
+ * is bundled directly (instead of loaded from a filesystem path at runtime)
6001
+ * so that protobuf schema loading works identically in Node and in browser
6002
+ * bundles — protobuf.load() requires filesystem/network access that does
6003
+ * not exist in a browser bundle (see issue #28). Regenerate with:
6004
+ * node scripts/build-protos.mjs
6005
+ */
6006
+
6007
+ var localWorkingCopyJson = {
6008
+ nested: {
6009
+ local_working_copy: {
6010
+ nested: {
6011
+ FileType: {
6012
+ valuesOptions: {
6013
+ Conflict: {
6014
+ deprecated: true,
6015
+ },
6016
+ },
6017
+ values: {
6018
+ Normal: 0,
6019
+ Symlink: 1,
6020
+ Executable: 2,
6021
+ Conflict: 3,
6022
+ GitSubmodule: 4,
6023
+ },
6024
+ },
6025
+ MaterializedConflictData: {
6026
+ fields: {
6027
+ conflictMarkerLen: {
6028
+ type: 'uint32',
6029
+ id: 1,
6030
+ },
6031
+ },
6032
+ },
6033
+ FileState: {
6034
+ fields: {
6035
+ mtimeMillisSinceEpoch: {
6036
+ type: 'int64',
6037
+ id: 1,
6038
+ },
6039
+ size: {
6040
+ type: 'uint64',
6041
+ id: 2,
6042
+ },
6043
+ fileType: {
6044
+ type: 'FileType',
6045
+ id: 3,
6046
+ },
6047
+ materializedConflictData: {
6048
+ type: 'MaterializedConflictData',
6049
+ id: 5,
6050
+ },
6051
+ },
6052
+ reserved: [[4, 4]],
6053
+ },
6054
+ FileStateEntry: {
6055
+ fields: {
6056
+ path: {
6057
+ type: 'string',
6058
+ id: 1,
6059
+ },
6060
+ state: {
6061
+ type: 'FileState',
6062
+ id: 2,
6063
+ },
6064
+ },
6065
+ },
6066
+ SparsePatterns: {
6067
+ fields: {
6068
+ prefixes: {
6069
+ rule: 'repeated',
6070
+ type: 'string',
6071
+ id: 1,
6072
+ },
6073
+ },
6074
+ },
6075
+ TreeState: {
6076
+ fields: {
6077
+ legacyTreeId: {
6078
+ type: 'bytes',
6079
+ id: 1,
6080
+ options: {
6081
+ deprecated: true,
6082
+ },
6083
+ },
6084
+ treeIds: {
6085
+ rule: 'repeated',
6086
+ type: 'bytes',
6087
+ id: 5,
6088
+ },
6089
+ fileStates: {
6090
+ rule: 'repeated',
6091
+ type: 'FileStateEntry',
6092
+ id: 2,
6093
+ },
6094
+ isFileStatesSorted: {
6095
+ type: 'bool',
6096
+ id: 6,
6097
+ },
6098
+ sparsePatterns: {
6099
+ type: 'SparsePatterns',
6100
+ id: 3,
6101
+ },
6102
+ watchmanClock: {
6103
+ type: 'WatchmanClock',
6104
+ id: 4,
6105
+ },
6106
+ },
6107
+ },
6108
+ WatchmanClock: {
6109
+ oneofs: {
6110
+ watchmanClock: {
6111
+ oneof: ['stringClock', 'unixTimestamp'],
6112
+ },
6113
+ },
6114
+ fields: {
6115
+ stringClock: {
6116
+ type: 'string',
6117
+ id: 1,
6118
+ },
6119
+ unixTimestamp: {
6120
+ type: 'int64',
6121
+ id: 2,
6122
+ },
6123
+ },
6124
+ },
6125
+ Checkout: {
6126
+ fields: {
6127
+ operationId: {
6128
+ type: 'bytes',
6129
+ id: 2,
6130
+ },
6131
+ workspaceName: {
6132
+ type: 'string',
6133
+ id: 3,
6134
+ },
6135
+ },
6136
+ reserved: [[1, 1]],
6137
+ },
6138
+ },
6139
+ },
6140
+ },
6141
+ };
6142
+
6143
+ /**
6144
+ * Precompiled protobuf reflection JSON for simple_op_store.proto
6145
+ *
6146
+ * Generated from src/protos/simple_op_store.proto by scripts/build-protos.mjs. This
6147
+ * is bundled directly (instead of loaded from a filesystem path at runtime)
6148
+ * so that protobuf schema loading works identically in Node and in browser
6149
+ * bundles — protobuf.load() requires filesystem/network access that does
6150
+ * not exist in a browser bundle (see issue #28). Regenerate with:
6151
+ * node scripts/build-protos.mjs
6152
+ */
6153
+
6154
+ var simpleOpStoreJson = {
6155
+ nested: {
6156
+ simple_op_store: {
6157
+ nested: {
6158
+ RefConflictLegacy: {
6159
+ fields: {
6160
+ removes: {
6161
+ rule: 'repeated',
6162
+ type: 'bytes',
6163
+ id: 1,
6164
+ options: {
6165
+ deprecated: true,
6166
+ },
6167
+ },
6168
+ adds: {
6169
+ rule: 'repeated',
6170
+ type: 'bytes',
6171
+ id: 2,
6172
+ options: {
6173
+ deprecated: true,
6174
+ },
6175
+ },
6176
+ },
6177
+ },
6178
+ RefConflict: {
6179
+ fields: {
6180
+ removes: {
6181
+ rule: 'repeated',
6182
+ type: 'Term',
6183
+ id: 1,
6184
+ },
6185
+ adds: {
6186
+ rule: 'repeated',
6187
+ type: 'Term',
6188
+ id: 2,
6189
+ },
6190
+ },
6191
+ nested: {
6192
+ Term: {
6193
+ oneofs: {
6194
+ _value: {
6195
+ oneof: ['value'],
6196
+ },
6197
+ },
6198
+ fields: {
6199
+ value: {
6200
+ type: 'bytes',
6201
+ id: 1,
6202
+ options: {
6203
+ proto3_optional: true,
6204
+ },
6205
+ },
6206
+ },
6207
+ },
6208
+ },
6209
+ },
6210
+ RefTarget: {
6211
+ oneofs: {
6212
+ value: {
6213
+ oneof: ['commitId', 'conflictLegacy', 'conflict'],
6214
+ },
6215
+ },
6216
+ fields: {
6217
+ commitId: {
6218
+ type: 'bytes',
6219
+ id: 1,
6220
+ options: {
6221
+ deprecated: true,
6222
+ },
6223
+ },
6224
+ conflictLegacy: {
6225
+ type: 'RefConflictLegacy',
6226
+ id: 2,
6227
+ options: {
6228
+ deprecated: true,
6229
+ },
6230
+ },
6231
+ conflict: {
6232
+ type: 'RefConflict',
6233
+ id: 3,
6234
+ },
6235
+ },
6236
+ },
6237
+ RefTargetTerm: {
6238
+ oneofs: {
6239
+ _value: {
6240
+ oneof: ['value'],
6241
+ },
6242
+ },
6243
+ fields: {
6244
+ value: {
6245
+ type: 'bytes',
6246
+ id: 1,
6247
+ options: {
6248
+ proto3_optional: true,
6249
+ },
6250
+ },
6251
+ },
6252
+ },
6253
+ RemoteRefState: {
6254
+ values: {
6255
+ New: 0,
6256
+ Tracked: 1,
6257
+ },
6258
+ },
6259
+ RemoteBookmark: {
6260
+ oneofs: {
6261
+ _state: {
6262
+ oneof: ['state'],
6263
+ },
6264
+ },
6265
+ fields: {
6266
+ remoteName: {
6267
+ type: 'string',
6268
+ id: 1,
6269
+ },
6270
+ target: {
6271
+ type: 'RefTarget',
6272
+ id: 2,
6273
+ },
6274
+ state: {
6275
+ type: 'RemoteRefState',
6276
+ id: 3,
6277
+ options: {
6278
+ proto3_optional: true,
6279
+ },
6280
+ },
6281
+ },
6282
+ },
6283
+ Bookmark: {
6284
+ fields: {
6285
+ name: {
6286
+ type: 'string',
6287
+ id: 1,
6288
+ },
6289
+ localTarget: {
6290
+ type: 'RefTarget',
6291
+ id: 2,
6292
+ },
6293
+ remoteBookmarks: {
6294
+ rule: 'repeated',
6295
+ type: 'RemoteBookmark',
6296
+ id: 3,
6297
+ options: {
6298
+ deprecated: true,
6299
+ },
6300
+ },
6301
+ },
6302
+ },
6303
+ GitRef: {
6304
+ fields: {
6305
+ name: {
6306
+ type: 'string',
6307
+ id: 1,
6308
+ },
6309
+ commitId: {
6310
+ type: 'bytes',
6311
+ id: 2,
6312
+ options: {
6313
+ deprecated: true,
6314
+ },
6315
+ },
6316
+ target: {
6317
+ type: 'RefTarget',
6318
+ id: 3,
6319
+ },
6320
+ },
6321
+ },
6322
+ RemoteRef: {
6323
+ fields: {
6324
+ name: {
6325
+ type: 'string',
6326
+ id: 1,
6327
+ },
6328
+ targetTerms: {
6329
+ rule: 'repeated',
6330
+ type: 'RefTargetTerm',
6331
+ id: 2,
6332
+ },
6333
+ state: {
6334
+ type: 'RemoteRefState',
6335
+ id: 3,
6336
+ },
6337
+ },
6338
+ },
6339
+ Tag: {
6340
+ fields: {
6341
+ name: {
6342
+ type: 'string',
6343
+ id: 1,
6344
+ },
6345
+ target: {
6346
+ type: 'RefTarget',
6347
+ id: 2,
6348
+ },
6349
+ },
6350
+ },
6351
+ View: {
6352
+ fields: {
6353
+ headIds: {
6354
+ rule: 'repeated',
6355
+ type: 'bytes',
6356
+ id: 1,
6357
+ },
6358
+ wcCommitId: {
6359
+ type: 'bytes',
6360
+ id: 2,
6361
+ options: {
6362
+ deprecated: true,
6363
+ },
6364
+ },
6365
+ wcCommitIds: {
6366
+ keyType: 'string',
6367
+ type: 'bytes',
6368
+ id: 8,
6369
+ },
6370
+ bookmarks: {
6371
+ rule: 'repeated',
6372
+ type: 'Bookmark',
6373
+ id: 5,
6374
+ },
6375
+ localTags: {
6376
+ rule: 'repeated',
6377
+ type: 'Tag',
6378
+ id: 6,
6379
+ },
6380
+ remoteViews: {
6381
+ rule: 'repeated',
6382
+ type: 'RemoteView',
6383
+ id: 11,
6384
+ },
6385
+ gitRefs: {
6386
+ rule: 'repeated',
6387
+ type: 'GitRef',
6388
+ id: 3,
6389
+ },
6390
+ gitHeadLegacy: {
6391
+ type: 'bytes',
6392
+ id: 7,
6393
+ options: {
6394
+ deprecated: true,
6395
+ },
6396
+ },
6397
+ gitHead: {
6398
+ type: 'RefTarget',
6399
+ id: 9,
6400
+ },
6401
+ hasGitRefsMigratedToRemoteTags: {
6402
+ type: 'bool',
6403
+ id: 12,
6404
+ },
6405
+ },
6406
+ reserved: [
6407
+ [4, 4],
6408
+ [10, 10],
6409
+ ],
6410
+ },
6411
+ RemoteView: {
6412
+ fields: {
6413
+ name: {
6414
+ type: 'string',
6415
+ id: 1,
6416
+ },
6417
+ bookmarks: {
6418
+ rule: 'repeated',
6419
+ type: 'RemoteRef',
6420
+ id: 2,
6421
+ },
6422
+ tags: {
6423
+ rule: 'repeated',
6424
+ type: 'RemoteRef',
6425
+ id: 3,
6426
+ },
6427
+ },
6428
+ },
6429
+ Operation: {
6430
+ fields: {
6431
+ viewId: {
6432
+ type: 'bytes',
6433
+ id: 1,
6434
+ },
6435
+ parents: {
6436
+ rule: 'repeated',
6437
+ type: 'bytes',
6438
+ id: 2,
6439
+ },
6440
+ metadata: {
6441
+ type: 'OperationMetadata',
6442
+ id: 3,
6443
+ },
6444
+ commitPredecessors: {
6445
+ rule: 'repeated',
6446
+ type: 'CommitPredecessors',
6447
+ id: 4,
6448
+ },
6449
+ storesCommitPredecessors: {
6450
+ type: 'bool',
6451
+ id: 5,
6452
+ },
6453
+ },
6454
+ },
6455
+ Timestamp: {
6456
+ fields: {
6457
+ millisSinceEpoch: {
6458
+ type: 'int64',
6459
+ id: 1,
6460
+ },
6461
+ tzOffset: {
6462
+ type: 'int32',
6463
+ id: 2,
6464
+ },
6465
+ },
6466
+ },
6467
+ OperationMetadata: {
6468
+ fields: {
6469
+ startTime: {
6470
+ type: 'Timestamp',
6471
+ id: 1,
6472
+ },
6473
+ endTime: {
6474
+ type: 'Timestamp',
6475
+ id: 2,
6476
+ },
6477
+ description: {
6478
+ type: 'string',
6479
+ id: 3,
6480
+ },
6481
+ hostname: {
6482
+ type: 'string',
6483
+ id: 4,
6484
+ },
6485
+ username: {
6486
+ type: 'string',
6487
+ id: 5,
6488
+ },
6489
+ isSnapshot: {
6490
+ type: 'bool',
6491
+ id: 7,
6492
+ },
6493
+ tags: {
6494
+ keyType: 'string',
6495
+ type: 'string',
6496
+ id: 6,
6497
+ },
6498
+ },
6499
+ },
6500
+ CommitPredecessors: {
6501
+ fields: {
6502
+ commitId: {
6503
+ type: 'bytes',
6504
+ id: 1,
6505
+ },
6506
+ predecessorIds: {
6507
+ rule: 'repeated',
6508
+ type: 'bytes',
6509
+ id: 2,
6510
+ },
6511
+ },
6512
+ },
6513
+ },
6514
+ },
6515
+ },
6516
+ };
6517
+
6518
+ /**
6519
+ * Protobuf schema loader
6520
+ *
6521
+ * Builds protobuf.js `Root` instances from precompiled reflection JSON
6522
+ * (see local_working_copy.json.js / simple_op_store.json.js, generated from
6523
+ * the .proto files in this directory) instead of calling `protobuf.load()`
6524
+ * against a filesystem path.
6525
+ *
6526
+ * Why: `protobuf.load()` resolves its argument as a file path (Node `fs`) or
6527
+ * URL (XHR/fetch), which requires either a real filesystem or a network
6528
+ * request. In a bundled browser build there is no filesystem path to read
6529
+ * (see issue #28), and shipping the raw .proto text and fetching it at
6530
+ * runtime would require bundler-specific asset handling. Reflection JSON is
6531
+ * plain data, so it can be imported like any other JS module and works
6532
+ * identically under Node, Jest, and any browser bundler (Vite, esbuild,
6533
+ * webpack, Rollup, ...).
6534
+ *
6535
+ * Regenerate the JSON files after editing a .proto with:
6536
+ * npm run build:protos
6537
+ */
6538
+
6539
+
6540
+ /** @type {protobuf.Root|undefined} */
6541
+ let localWorkingCopyRoot;
6542
+ /** @type {protobuf.Root|undefined} */
6543
+ let simpleOpStoreRoot;
6544
+
6545
+ /**
6546
+ * @returns {protobuf.Root} Root for local_working_copy.proto (Checkout, TreeState, ...)
6547
+ */
6548
+ function getLocalWorkingCopyRoot() {
6549
+ if (!localWorkingCopyRoot) {
6550
+ localWorkingCopyRoot = protobuf.Root.fromJSON(localWorkingCopyJson);
6551
+ }
6552
+ return localWorkingCopyRoot;
6553
+ }
6554
+
6555
+ /**
6556
+ * @returns {protobuf.Root} Root for simple_op_store.proto (Operation, View, ...)
6557
+ */
6558
+ function getSimpleOpStoreRoot() {
6559
+ if (!simpleOpStoreRoot) {
6560
+ simpleOpStoreRoot = protobuf.Root.fromJSON(simpleOpStoreJson);
6561
+ }
6562
+ return simpleOpStoreRoot;
6563
+ }
6564
+
5419
6565
  /**
5420
6566
  * JJ Checkout File Manager
5421
6567
  *
@@ -5424,15 +6570,6 @@ class UserConfig {
5424
6570
  */
5425
6571
 
5426
6572
 
5427
- const __filename$4 = fileURLToPath(import.meta.url);
5428
- const __dirname$4 = path.dirname(__filename$4);
5429
-
5430
- // Determine proto path based on whether we're running from dist or src
5431
- const inDist$3 = __dirname$4.includes('/dist') || __dirname$4.includes('\\dist');
5432
- const protoDir$3 = inDist$3
5433
- ? path.join(__dirname$4, '..', 'src', 'protos')
5434
- : path.join(__dirname$4, '..', 'protos');
5435
-
5436
6573
  class JJCheckout {
5437
6574
  /**
5438
6575
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5441,7 +6578,6 @@ class JJCheckout {
5441
6578
  constructor(fs, dir) {
5442
6579
  this.fs = fs;
5443
6580
  this.dir = dir;
5444
- this.protoPath = path.join(protoDir$3, 'local_working_copy.proto');
5445
6581
  }
5446
6582
 
5447
6583
  /**
@@ -5452,11 +6588,11 @@ class JJCheckout {
5452
6588
  */
5453
6589
  async writeCheckout(operationId, workspaceName = 'default') {
5454
6590
  // Load protobuf schema
5455
- const root = await protobuf.load(this.protoPath);
6591
+ const root = getLocalWorkingCopyRoot();
5456
6592
  const Checkout = root.lookupType('local_working_copy.Checkout');
5457
6593
 
5458
6594
  // Convert hex operation ID to bytes
5459
- const opIdBuffer = Buffer.from(operationId, 'hex');
6595
+ const opIdBuffer = hexToBytes(operationId);
5460
6596
 
5461
6597
  // Create message (use camelCase for protobufjs)
5462
6598
  const message = Checkout.create({
@@ -5488,7 +6624,7 @@ class JJCheckout {
5488
6624
  const buffer = await this.fs.promises.readFile(checkoutPath);
5489
6625
 
5490
6626
  // Load protobuf schema
5491
- const root = await protobuf.load(this.protoPath);
6627
+ const root = getLocalWorkingCopyRoot();
5492
6628
  const Checkout = root.lookupType('local_working_copy.Checkout');
5493
6629
 
5494
6630
  // Decode
@@ -5516,6 +6652,7 @@ class JJCheckout {
5516
6652
  * calling `writeFile()` directly on the canonical path — see issue #16.
5517
6653
  */
5518
6654
 
6655
+
5519
6656
  /**
5520
6657
  * @param {any} fs - Filesystem implementation (Node fs, LightningFS, etc.)
5521
6658
  * @param {string} targetPath - Canonical path to write to
@@ -5531,7 +6668,7 @@ async function atomicWriteFile(fs, targetPath, content) {
5531
6668
  try {
5532
6669
  const dirPath = targetPath.substring(0, targetPath.lastIndexOf('/'));
5533
6670
  if (dirPath) {
5534
- await fs.promises.mkdir(dirPath, { recursive: true });
6671
+ await mkdirp(fs, dirPath);
5535
6672
  }
5536
6673
 
5537
6674
  await fs.promises.writeFile(tmpPath, content);
@@ -5555,15 +6692,6 @@ async function atomicWriteFile(fs, targetPath, content) {
5555
6692
  */
5556
6693
 
5557
6694
 
5558
- const __filename$3 = fileURLToPath(import.meta.url);
5559
- const __dirname$3 = path.dirname(__filename$3);
5560
-
5561
- // Determine proto path based on whether we're running from dist or src
5562
- const inDist$2 = __dirname$3.includes('/dist') || __dirname$3.includes('\\dist');
5563
- const protoDir$2 = inDist$2
5564
- ? path.join(__dirname$3, '..', 'src', 'protos')
5565
- : path.join(__dirname$3, '..', 'protos');
5566
-
5567
6695
  class JJTreeState {
5568
6696
  /**
5569
6697
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5572,7 +6700,6 @@ class JJTreeState {
5572
6700
  constructor(fs, dir) {
5573
6701
  this.fs = fs;
5574
6702
  this.dir = dir;
5575
- this.protoPath = path.join(protoDir$2, 'local_working_copy.proto');
5576
6703
  }
5577
6704
 
5578
6705
  /**
@@ -5583,11 +6710,11 @@ class JJTreeState {
5583
6710
  */
5584
6711
  async writeTreeState(treeId, fileStates = []) {
5585
6712
  // Load protobuf schema
5586
- const root = await protobuf.load(this.protoPath);
6713
+ const root = getLocalWorkingCopyRoot();
5587
6714
  const TreeState = root.lookupType('local_working_copy.TreeState');
5588
6715
 
5589
6716
  // Convert hex tree ID to bytes
5590
- const treeIdBuffer = Buffer.from(treeId, 'hex');
6717
+ const treeIdBuffer = hexToBytes(treeId);
5591
6718
 
5592
6719
  // Convert file states to protobuf format
5593
6720
  const fileStateEntries = fileStates.map((fs) => ({
@@ -5633,7 +6760,7 @@ class JJTreeState {
5633
6760
  const buffer = await this.fs.promises.readFile(treeStatePath);
5634
6761
 
5635
6762
  // Load protobuf schema
5636
- const root = await protobuf.load(this.protoPath);
6763
+ const root = getLocalWorkingCopyRoot();
5637
6764
  const TreeState = root.lookupType('local_working_copy.TreeState');
5638
6765
 
5639
6766
  // Decode
@@ -5657,15 +6784,6 @@ class JJTreeState {
5657
6784
  */
5658
6785
 
5659
6786
 
5660
- const __filename$2 = fileURLToPath(import.meta.url);
5661
- const __dirname$2 = path.dirname(__filename$2);
5662
-
5663
- // Determine proto path based on whether we're running from dist or src
5664
- const inDist$1 = __dirname$2.includes('/dist') || __dirname$2.includes('\\dist');
5665
- const protoDir$1 = inDist$1
5666
- ? path.join(__dirname$2, '..', 'src', 'protos')
5667
- : path.join(__dirname$2, '..', 'protos');
5668
-
5669
6787
  class JJOperationStore {
5670
6788
  /**
5671
6789
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5674,7 +6792,6 @@ class JJOperationStore {
5674
6792
  constructor(fs, dir) {
5675
6793
  this.fs = fs;
5676
6794
  this.dir = dir;
5677
- this.protoPath = path.join(protoDir$1, 'simple_op_store.proto');
5678
6795
  }
5679
6796
 
5680
6797
  /**
@@ -5687,12 +6804,12 @@ class JJOperationStore {
5687
6804
  */
5688
6805
  async writeOperation(operationId, viewId, parentIds, metadata) {
5689
6806
  // Load protobuf schema
5690
- const root = await protobuf.load(this.protoPath);
6807
+ const root = getSimpleOpStoreRoot();
5691
6808
  const Operation = root.lookupType('simple_op_store.Operation');
5692
6809
 
5693
6810
  // Convert hex IDs to bytes
5694
- const viewIdBuffer = Buffer.from(viewId, 'hex');
5695
- const parentBuffers = parentIds.map((id) => Buffer.from(id, 'hex'));
6811
+ const viewIdBuffer = hexToBytes(viewId);
6812
+ const parentBuffers = parentIds.map((id) => hexToBytes(id));
5696
6813
 
5697
6814
  // Create metadata message (use camelCase for protobufjs)
5698
6815
  const metadataMsg = {
@@ -5748,7 +6865,7 @@ class JJOperationStore {
5748
6865
  const buffer = await this.fs.promises.readFile(opPath);
5749
6866
 
5750
6867
  // Load protobuf schema
5751
- const root = await protobuf.load(this.protoPath);
6868
+ const root = getSimpleOpStoreRoot();
5752
6869
  const Operation = root.lookupType('simple_op_store.Operation');
5753
6870
 
5754
6871
  // Decode
@@ -5787,15 +6904,6 @@ class JJOperationStore {
5787
6904
  */
5788
6905
 
5789
6906
 
5790
- const __filename$1 = fileURLToPath(import.meta.url);
5791
- const __dirname$1 = path.dirname(__filename$1);
5792
-
5793
- // Determine proto path based on whether we're running from dist or src
5794
- const inDist = __dirname$1.includes('/dist') || __dirname$1.includes('\\dist');
5795
- const protoDir = inDist
5796
- ? path.join(__dirname$1, '..', 'src', 'protos')
5797
- : path.join(__dirname$1, '..', 'protos');
5798
-
5799
6907
  class JJViewStore {
5800
6908
  /**
5801
6909
  * @param {any} fs - File system module (isomorphic-git compatible)
@@ -5804,7 +6912,6 @@ class JJViewStore {
5804
6912
  constructor(fs, dir) {
5805
6913
  this.fs = fs;
5806
6914
  this.dir = dir;
5807
- this.protoPath = path.join(protoDir, 'simple_op_store.proto');
5808
6915
  }
5809
6916
 
5810
6917
  /**
@@ -5816,17 +6923,17 @@ class JJViewStore {
5816
6923
  */
5817
6924
  async writeView(viewId, headIds, wcCommitIds = {}) {
5818
6925
  // Load protobuf schema
5819
- const root = await protobuf.load(this.protoPath);
6926
+ const root = getSimpleOpStoreRoot();
5820
6927
  const View = root.lookupType('simple_op_store.View');
5821
6928
 
5822
6929
  // Convert hex IDs to bytes
5823
- const headBuffers = headIds.map((id) => Buffer.from(id, 'hex'));
6930
+ const headBuffers = headIds.map((id) => hexToBytes(id));
5824
6931
 
5825
6932
  // Convert wcCommitIds object to map
5826
6933
  /** @type {Record<string, any>} */
5827
6934
  const wcCommitIdsMap = {};
5828
6935
  for (const [workspace, commitId] of Object.entries(wcCommitIds)) {
5829
- wcCommitIdsMap[workspace] = Buffer.from(commitId, 'hex');
6936
+ wcCommitIdsMap[workspace] = hexToBytes(commitId);
5830
6937
  }
5831
6938
 
5832
6939
  // Create view message (use camelCase for protobufjs)
@@ -5867,7 +6974,7 @@ class JJViewStore {
5867
6974
  const buffer = await this.fs.promises.readFile(viewPath);
5868
6975
 
5869
6976
  // Load protobuf schema
5870
- const root = await protobuf.load(this.protoPath);
6977
+ const root = getSimpleOpStoreRoot();
5871
6978
  const View = root.lookupType('simple_op_store.View');
5872
6979
 
5873
6980
  // Decode
@@ -6563,14 +7670,14 @@ class IsomorphicGitBackend {
6563
7670
  const jjRepo = `${jjDir}/repo`;
6564
7671
 
6565
7672
  // Create directory structure
6566
- await this.fs.promises.mkdir(`${jjRepo}/store`, { recursive: true });
6567
- await this.fs.promises.mkdir(`${jjRepo}/store/extra/heads`, { recursive: true });
6568
- await this.fs.promises.mkdir(`${jjRepo}/op_store/operations`, { recursive: true });
6569
- await this.fs.promises.mkdir(`${jjRepo}/op_store/views`, { recursive: true });
6570
- await this.fs.promises.mkdir(`${jjRepo}/op_heads/heads`, { recursive: true });
6571
- await this.fs.promises.mkdir(`${jjRepo}/index`, { recursive: true });
6572
- await this.fs.promises.mkdir(`${jjRepo}/submodule_store`, { recursive: true });
6573
- await this.fs.promises.mkdir(`${jjDir}/working_copy`, { recursive: true });
7673
+ await mkdirp(this.fs, `${jjRepo}/store`);
7674
+ await mkdirp(this.fs, `${jjRepo}/store/extra/heads`);
7675
+ await mkdirp(this.fs, `${jjRepo}/op_store/operations`);
7676
+ await mkdirp(this.fs, `${jjRepo}/op_store/views`);
7677
+ await mkdirp(this.fs, `${jjRepo}/op_heads/heads`);
7678
+ await mkdirp(this.fs, `${jjRepo}/index`);
7679
+ await mkdirp(this.fs, `${jjRepo}/submodule_store`);
7680
+ await mkdirp(this.fs, `${jjDir}/working_copy`);
6574
7681
 
6575
7682
  // Write type files
6576
7683
  await this.fs.promises.writeFile(`${jjRepo}/store/type`, 'git');
@@ -6597,8 +7704,8 @@ class IsomorphicGitBackend {
6597
7704
  */
6598
7705
  async _createInitialJJState() {
6599
7706
  // Generate unique IDs for initial state
6600
- const operationId = crypto$1.randomBytes(64).toString('hex'); // 512-bit = 128 hex chars
6601
- const viewId = crypto$1.randomBytes(64).toString('hex');
7707
+ const operationId = randomHex(64); // 512-bit = 128 hex chars
7708
+ const viewId = randomHex(64);
6602
7709
  const emptyTreeId = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // Git's empty tree hash
6603
7710
 
6604
7711
  // Get the initial commit from Git
@@ -6610,7 +7717,7 @@ class IsomorphicGitBackend {
6610
7717
  ref: 'HEAD',
6611
7718
  });
6612
7719
  if (headOid) {
6613
- initialCommitId = crypto$1.randomBytes(64).toString('hex');
7720
+ initialCommitId = randomHex(64);
6614
7721
  }
6615
7722
  } catch (error) {
6616
7723
  // No initial commit yet
@@ -6738,60 +7845,320 @@ async function createJJ(options) {
6738
7845
  const workspaces = new WorkspaceManager(storage, fs, dir);
6739
7846
  const userConfig = new UserConfig(storage);
6740
7847
 
6741
- /**
6742
- * Helper to get user info for oplog operations
6743
- * @returns {Promise<import('../types').OperationUser>}
6744
- */
6745
- const getUserOplogInfo = async () => {
6746
- await userConfig.load();
6747
- const user = /** @type {any} */ (userConfig.getUser());
6748
- return { name: user.name, email: user.email, hostname: 'localhost' };
7848
+ /**
7849
+ * Helper to get user info for oplog operations
7850
+ * @returns {Promise<import('../types').OperationUser>}
7851
+ */
7852
+ const getUserOplogInfo = async () => {
7853
+ await userConfig.load();
7854
+ const user = /** @type {any} */ (userConfig.getUser());
7855
+ return { name: user.name, email: user.email, hostname: 'localhost' };
7856
+ };
7857
+
7858
+ /**
7859
+ * Helper to snapshot current filesystem state
7860
+ * This is called BEFORE every operation to enable undo
7861
+ * @returns {Promise<Record<string, string>>}
7862
+ */
7863
+ const snapshotFilesystem = async () => {
7864
+ /** @type {Record<string, string>} */
7865
+ const fileSnapshot = {};
7866
+
7867
+ try {
7868
+ const trackedFiles = await workingCopy.listFiles();
7869
+
7870
+ // Safeguards to prevent excessive memory usage
7871
+ const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB per file
7872
+ const MAX_TOTAL_SIZE = 10 * 1024 * 1024; // 10MB total snapshot size
7873
+ let totalSnapshotSize = 0;
7874
+
7875
+ for (const filePath of trackedFiles) {
7876
+ try {
7877
+ const fullPath = join(dir, filePath);
7878
+ const stats = await fs.promises.stat(fullPath);
7879
+
7880
+ // Skip files that are too large
7881
+ if (stats.size > MAX_FILE_SIZE) {
7882
+ continue;
7883
+ }
7884
+
7885
+ // Stop if total snapshot size would exceed limit
7886
+ if (totalSnapshotSize + stats.size > MAX_TOTAL_SIZE) {
7887
+ break;
7888
+ }
7889
+
7890
+ const content = await fs.promises.readFile(fullPath, 'utf8');
7891
+ fileSnapshot[filePath] = content;
7892
+ totalSnapshotSize += stats.size;
7893
+ } catch (error) {
7894
+ // Skip files that can't be read (binary, deleted, etc.)
7895
+ }
7896
+ }
7897
+ } catch (error) {
7898
+ // If we can't list files, return empty snapshot
7899
+ }
7900
+
7901
+ return fileSnapshot;
7902
+ };
7903
+
7904
+ /**
7905
+ * Fully sync the working directory to a target file snapshot: writes
7906
+ * every file the target has (create/update), AND deletes every currently
7907
+ * tracked file the target does NOT have.
7908
+ *
7909
+ * Both edit() and new() used to only overlay the target's files on top of
7910
+ * whatever was already on disk, so a file that existed in the previously
7911
+ * checked-out change but not in the target leaked into the next
7912
+ * snapshot — see issue #30. undo()/redo() had the same gap restoring
7913
+ * file state — see issue #29. This is the one place that does it
7914
+ * correctly, used by all four.
7915
+ *
7916
+ * @param {Record<string, string>} targetSnapshot - path -> file content
7917
+ * @returns {Promise<void>}
7918
+ */
7919
+ const syncWorkingCopyFiles = async (targetSnapshot) => {
7920
+ const snapshot = targetSnapshot || {};
7921
+ const currentFiles = await workingCopy.listFiles();
7922
+
7923
+ // Remove files that aren't part of the target.
7924
+ for (const filePath of currentFiles) {
7925
+ if (!(filePath in snapshot)) {
7926
+ try {
7927
+ const fullPath = join(dir, filePath);
7928
+ await fs.promises.unlink(fullPath);
7929
+ } catch (error) {
7930
+ // Already gone / inaccessible — fine, we just want it absent.
7931
+ }
7932
+ await workingCopy.untrackFile(filePath);
7933
+ }
7934
+ }
7935
+
7936
+ // Write/update every file the target has.
7937
+ for (const [filePath, content] of Object.entries(snapshot)) {
7938
+ const fullPath = join(dir, filePath);
7939
+ const pathParts = filePath.split('/');
7940
+ if (pathParts.length > 1) {
7941
+ const fullDirPath = join(dir, pathParts.slice(0, -1).join('/'));
7942
+ await mkdirp(fs, fullDirPath);
7943
+ }
7944
+ await fs.promises.writeFile(fullPath, content, 'utf8');
7945
+
7946
+ const stats = await fs.promises.stat(fullPath);
7947
+ await workingCopy.trackFile(filePath, {
7948
+ mtime: stats.mtime,
7949
+ size: stats.size,
7950
+ mode: stats.mode,
7951
+ });
7952
+ }
7953
+ };
7954
+
7955
+ /**
7956
+ * Compute the ChangeGraph/file/conflicts state that existed right after
7957
+ * `landingOp` finished — i.e. what undo()/redo()/operations.restore()
7958
+ * need to re-establish when winding the repository forward or back to
7959
+ * that point (see issue #29).
7960
+ *
7961
+ * Every operation that mutates EXISTING change records in place records
7962
+ * (additively — most operations touch none of this and that's fine,
7963
+ * there's nothing to reverse for them):
7964
+ * - changeSnapshot: { [changeId]: fullRecordBeforeThisOpRan }
7965
+ * - view.fileSnapshot: on-disk file state right BEFORE this op ran
7966
+ * - conflictsSnapshot: ConflictModel state right BEFORE this op ran
7967
+ *
7968
+ * Deliberately NOT reverted: changes an operation newly *created* (via
7969
+ * graph.addChange() — new(), squash()'s synthetic empty working-copy
7970
+ * change, ...). Undoing their creation only un-refs them (restores the
7971
+ * working-copy pointer/heads that made them reachable) — it does not
7972
+ * delete the change record — matching real jj, where an "unreachable"
7973
+ * change stays resolvable by its change id (hidden, not gone) until
7974
+ * explicitly gc'd, and this package's own history-editing tests rely on
7975
+ * exactly that: rebasing onto a change created by a since-undone new()
7976
+ * must still work.
7977
+ *
7978
+ * "The state right after landingOp" is therefore exactly what the FIRST
7979
+ * operation after landingOp recorded as ITS "before" state, merged across
7980
+ * every operation between landingOp and the current head in case
7981
+ * different operations touched different changeIds (a single op's own
7982
+ * snapshot only covers what IT touched). Concretely, per field:
7983
+ * - changeSnapshot: first (earliest / closest-to-landingOp) entry per
7984
+ * changeId wins — later entries in the range reflect that changeId's
7985
+ * state at a LATER point than we're restoring to.
7986
+ * - fileSnapshot / conflictsSnapshot: the first op in the range that
7987
+ * recorded one, since whole-state snapshots aren't per-changeId and
7988
+ * an op that didn't record one simply didn't change that state.
7989
+ *
7990
+ * This same helper backs undo() (landingOp = the parent of whatever's
7991
+ * being undone), redo() (landingOp = the operation a prior undo()
7992
+ * reverted), and operations.restore() (landingOp = the target operation
7993
+ * itself) — one reversal primitive, not three.
7994
+ *
7995
+ * @param {Array<any>} ops - Full chronological operation list (oplog.list())
7996
+ * @param {any} landingOp - The operation whose resulting state to recompute
7997
+ * @returns {{
7998
+ * changeSnapshotToApply: Record<string, any>,
7999
+ * fileSnapshot: Record<string, string>|undefined,
8000
+ * conflictsSnapshot: any,
8001
+ * }}
8002
+ */
8003
+ const computeGraphReversal = (/** @type {any[]} */ ops, /** @type {any} */ landingOp) => {
8004
+ const landingIndex = ops.findIndex((op) => op.id === landingOp.id);
8005
+
8006
+ /** @type {Record<string, any>} */
8007
+ const changeSnapshotToApply = {};
8008
+ /** @type {Record<string, string>|undefined} */
8009
+ let fileSnapshot;
8010
+ /** @type {any} */
8011
+ let conflictsSnapshot;
8012
+
8013
+ for (let i = landingIndex + 1; i < ops.length; i++) {
8014
+ const op = ops[i];
8015
+
8016
+ if (op.changeSnapshot) {
8017
+ for (const [changeId, snapshot] of Object.entries(op.changeSnapshot)) {
8018
+ if (!(changeId in changeSnapshotToApply)) {
8019
+ changeSnapshotToApply[changeId] = snapshot;
8020
+ }
8021
+ }
8022
+ }
8023
+
8024
+ // `!= null` (not `!== undefined`) on purpose: some operations record
8025
+ // an explicit `null` for "I ran, and I deliberately took no snapshot"
8026
+ // (e.g. describe() of a non-working-copy revision) — that's exactly
8027
+ // as uninformative as never having set the field, so keep scanning
8028
+ // rather than "finding" a null snapshot.
8029
+ if (fileSnapshot === undefined && op.view && op.view.fileSnapshot != null) {
8030
+ fileSnapshot = op.view.fileSnapshot;
8031
+ }
8032
+
8033
+ if (conflictsSnapshot === undefined && op.conflictsSnapshot != null) {
8034
+ conflictsSnapshot = op.conflictsSnapshot;
8035
+ }
8036
+ }
8037
+
8038
+ return { changeSnapshotToApply, fileSnapshot, conflictsSnapshot };
6749
8039
  };
6750
8040
 
6751
8041
  /**
6752
- * Helper to snapshot current filesystem state
6753
- * This is called BEFORE every operation to enable undo
6754
- * @returns {Promise<Record<string, string>>}
8042
+ * Apply a reversal computed by computeGraphReversal() to the live graph,
8043
+ * working-copy files, and conflict state. Does NOT touch the working-copy
8044
+ * pointer or bookmarks — callers set those from the landing op's own
8045
+ * `view` directly (pointers, unlike file/graph/conflict content, are
8046
+ * already correct as recorded, see computeGraphReversal's doc comment).
8047
+ *
8048
+ * @param {ReturnType<typeof computeGraphReversal>} reversal
8049
+ * @returns {Promise<void>}
6755
8050
  */
6756
- const snapshotFilesystem = async () => {
6757
- /** @type {Record<string, string>} */
6758
- const fileSnapshot = {};
6759
-
6760
- try {
6761
- const trackedFiles = await workingCopy.listFiles();
8051
+ const applyGraphReversal = async (reversal) => {
8052
+ await graph.load();
8053
+ for (const [changeId, snapshot] of Object.entries(reversal.changeSnapshotToApply)) {
8054
+ if (await graph.getChange(changeId)) {
8055
+ // Bypass the git-sync middleware (baseGraph, not the wrapped
8056
+ // `graph`): undo/restore are pure reverts, and `snapshot` already
8057
+ // carries the change's ORIGINAL commitId from before the reverted
8058
+ // operation(s) ran. Going through graph.updateChange() would fire
8059
+ // onUpdateChange -> syncChangeToGit(), which mints a brand-new
8060
+ // commit (and thus a new commitId) from the CURRENT tree instead
8061
+ // of restoring the one that was actually there — see issue #37(c):
8062
+ // "undo/restore give reverted commits new commit IDs". Writing the
8063
+ // snapshot verbatim via baseGraph puts the original commitId back.
8064
+ await baseGraph.updateChange(snapshot);
8065
+ }
8066
+ }
6762
8067
 
6763
- // Safeguards to prevent excessive memory usage
6764
- const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB per file
6765
- const MAX_TOTAL_SIZE = 10 * 1024 * 1024; // 10MB total snapshot size
6766
- let totalSnapshotSize = 0;
8068
+ if (reversal.fileSnapshot !== undefined) {
8069
+ await syncWorkingCopyFiles(reversal.fileSnapshot);
8070
+ }
6767
8071
 
6768
- for (const filePath of trackedFiles) {
6769
- try {
6770
- const fullPath = path.join(dir, filePath);
6771
- const stats = await fs.promises.stat(fullPath);
8072
+ await conflicts.load();
8073
+ if (reversal.conflictsSnapshot !== undefined) {
8074
+ conflicts.conflicts = new Map(Object.entries(reversal.conflictsSnapshot.conflicts || {}));
8075
+ conflicts.fileConflicts = new Map(
8076
+ Object.entries(reversal.conflictsSnapshot.fileConflicts || {})
8077
+ );
8078
+ await conflicts.save();
8079
+ }
8080
+ // Else: no operation in the reverted range touched conflicts — leave
8081
+ // current conflict state alone. (Previously undo() unconditionally
8082
+ // cleared ALL conflicts whenever the op being undone hadn't recorded
8083
+ // its own conflictsSnapshot, which is nearly every non-merge() op —
8084
+ // undoing an unrelated describe() would silently wipe conflicts from
8085
+ // an earlier, unrelated merge(). Fixed as part of issue #29.)
8086
+ };
6772
8087
 
6773
- // Skip files that are too large
6774
- if (stats.size > MAX_FILE_SIZE) {
6775
- continue;
6776
- }
8088
+ /**
8089
+ * Re-parent every child of `changeId` onto `newParents` instead — used
8090
+ * when a change is being elided from the graph (abandon(), issue #30;
8091
+ * converge(), issue #32) so its children don't end up pointing at
8092
+ * something no longer meant to be there. Mutates `changeSnapshot` in
8093
+ * place, adding each touched child's PRE-reparent record so the caller's
8094
+ * own oplog entry lets undo()/redo()/operations.restore() reverse this
8095
+ * too (the same mechanism the caller already uses for the elided
8096
+ * change's own snapshot).
8097
+ *
8098
+ * @param {string} changeId - The change being elided
8099
+ * @param {string[]} newParents - What its children should point at instead
8100
+ * @param {Record<string, any>} changeSnapshot - Accumulator to add each touched child's pre-state to
8101
+ * @returns {Promise<void>}
8102
+ */
8103
+ const reparentChildrenOnto = async (changeId, newParents, changeSnapshot) => {
8104
+ const childIds = graph.getChildren(changeId);
8105
+ for (const childId of childIds) {
8106
+ const child = await graph.getChange(childId);
8107
+ if (!child) continue;
8108
+ changeSnapshot[childId] = structuredClone(child);
8109
+ const reparented = child.parents.flatMap((/** @type {string} */ p) =>
8110
+ p === changeId ? newParents : [p]
8111
+ );
8112
+ // De-dupe (e.g. the child could already have one of the new parents
8113
+ // as another of its own parents) while preserving first-seen order.
8114
+ child.parents = [...new Set(reparented)];
8115
+ await graph.updateChange(child);
8116
+ }
8117
+ };
6777
8118
 
6778
- // Stop if total snapshot size would exceed limit
6779
- if (totalSnapshotSize + stats.size > MAX_TOTAL_SIZE) {
6780
- break;
6781
- }
8119
+ /**
8120
+ * Determine which operation a fresh undo() call should revert, matching
8121
+ * real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
8122
+ * a normal operation, this will undo that last operation by restoring its
8123
+ * parent. If jj undo is used repeatedly, it will restore increasingly
8124
+ * older operations, going further back into the past.").
8125
+ *
8126
+ * The raw op-log parent chain can't answer this directly once undo/redo
8127
+ * bookkeeping operations are mixed into the log: an 'undo' operation's
8128
+ * own parent is the operation it just undid, so naively targeting
8129
+ * `ops[ops.length - 1]` on a SECOND undo() would undo the undo (toggle
8130
+ * back to the future) instead of stepping one further step into the past
8131
+ * — this was issue #29's "undo() twice only undoes the undo" bug.
8132
+ *
8133
+ * @param {Array<any>} ops - Full chronological operation list
8134
+ * @returns {any} The operation object undo() should revert
8135
+ */
8136
+ const resolveUndoTarget = (ops) => {
8137
+ const head = ops[ops.length - 1];
8138
+ const findOp = (/** @type {any} */ id) => ops.find((op) => op.id === id);
8139
+
8140
+ if (head.eventType === 'undo') {
8141
+ // We're stepping further into the past: what should be undone now is
8142
+ // the operation right BEFORE the one `head` already undid — not
8143
+ // head's own parent, which IS the operation head undid (targeting it
8144
+ // again would just toggle back to the present).
8145
+ const previouslyUndone = head.targetOpId && findOp(head.targetOpId);
8146
+ const stepBack = previouslyUndone && findOp(previouslyUndone.parents[0]);
8147
+ return stepBack || previouslyUndone || head;
8148
+ }
6782
8149
 
6783
- const content = await fs.promises.readFile(fullPath, 'utf-8');
6784
- fileSnapshot[filePath] = content;
6785
- totalSnapshotSize += stats.size;
6786
- } catch (error) {
6787
- // Skip files that can't be read (binary, deleted, etc.)
6788
- }
6789
- }
6790
- } catch (error) {
6791
- // If we can't list files, return empty snapshot
8150
+ if (head.eventType === 'redo') {
8151
+ // A redo re-established some earlier operation as current; a fresh
8152
+ // undo right after it should behave exactly as if that operation
8153
+ // were freshly current and had never been touched by undo/redo at
8154
+ // all — i.e. undo it again (real jj: redo "moves in the direction of
8155
+ // the future"; undoing right after simply resumes stepping back).
8156
+ const redoneUndo = head.redoOf && findOp(head.redoOf);
8157
+ const target = redoneUndo && redoneUndo.targetOpId && findOp(redoneUndo.targetOpId);
8158
+ return target || head;
6792
8159
  }
6793
8160
 
6794
- return fileSnapshot;
8161
+ return head;
6795
8162
  };
6796
8163
 
6797
8164
  /**
@@ -6978,6 +8345,25 @@ async function createJJ(options) {
6978
8345
  }
6979
8346
  },
6980
8347
 
8348
+ // Used by undo()/redo()/operations.restore() to reverse an
8349
+ // addChange() when winding the graph back — see issue #29. No Git
8350
+ // sync hook: a change created after the operation being reverted was
8351
+ // never pushed anywhere to unwind, and the Git backend has no
8352
+ // "forget this commit" operation (nor should it — Git objects are
8353
+ // content-addressed and harmless to leave orphaned).
8354
+ deleteChange: (/** @type {any} */ changeId) => baseGraph.deleteChange(changeId),
8355
+
8356
+ // See issue #32 / converge(): a divergent copy is another commit
8357
+ // claiming an existing changeId; no Git sync hook, for the same
8358
+ // reason addChange()'s primary copy already goes through
8359
+ // onAddChange() when it's first created — the divergent copy needs
8360
+ // no additional sync of its own.
8361
+ addDivergentCopy: (/** @type {any} */ change) => baseGraph.addDivergentCopy(change),
8362
+ getDivergentSiblings: (/** @type {any} */ changeId) =>
8363
+ baseGraph.getDivergentSiblings(changeId),
8364
+ deleteDivergentCopy: (/** @type {any} */ changeId, /** @type {any} */ commitId) =>
8365
+ baseGraph.deleteDivergentCopy(changeId, commitId),
8366
+
6981
8367
  // Delegate other operations
6982
8368
  init: () => baseGraph.init(),
6983
8369
  };
@@ -7135,6 +8521,20 @@ async function createJJ(options) {
7135
8521
  /**
7136
8522
  * Write a file to the working copy
7137
8523
  *
8524
+ * Note (issue #30): this does NOT update the working-copy change's
8525
+ * `commitId`/`fileSnapshot` — those stay as they were until the next
8526
+ * command that snapshots the working copy (describe(), new(), edit(),
8527
+ * status(), or any other call that goes through
8528
+ * autoSnapshotWorkingCopy()/snapshot()). This is intentional, not a
8529
+ * bug: jj has no staging area, but it also doesn't recompute a git
8530
+ * tree/commit hash on every single file write (that would mean
8531
+ * hashing on every keystroke of an external editor) — real jj only
8532
+ * takes a fresh working-copy snapshot when an actual `jj` command
8533
+ * runs. write() is the equivalent of editing a file with your OS's own
8534
+ * tools outside of jj entirely; describe()/new()/edit()/status()/etc.
8535
+ * are the equivalent of running a jj command, which is what triggers
8536
+ * the (auto) snapshot in both real jj and here.
8537
+ *
7138
8538
  * @param {Record<string, any>} args - Arguments
7139
8539
  * @returns {Promise<Object>} File information including path, size, and mode
7140
8540
  */
@@ -7158,7 +8558,7 @@ async function createJJ(options) {
7158
8558
  if (pathParts.length > 1) {
7159
8559
  const dirPath = pathParts.slice(0, -1).join('/');
7160
8560
  const fullDirPath = `${dir}/${dirPath}`;
7161
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8561
+ await mkdirp(fs, fullDirPath);
7162
8562
  }
7163
8563
 
7164
8564
  // Write the file
@@ -7213,10 +8613,10 @@ async function createJJ(options) {
7213
8613
  });
7214
8614
  }
7215
8615
 
7216
- const fullPath = path.join(dir, args.path);
8616
+ const fullPath = join(dir, args.path);
7217
8617
  try {
7218
8618
  if (encoding === 'utf-8' || encoding === 'utf8') {
7219
- return await fs.promises.readFile(fullPath, 'utf-8');
8619
+ return await fs.promises.readFile(fullPath, 'utf8');
7220
8620
  } else {
7221
8621
  return await fs.promises.readFile(fullPath);
7222
8622
  }
@@ -7381,7 +8781,7 @@ async function createJJ(options) {
7381
8781
 
7382
8782
  // Reading from working copy
7383
8783
  if (!args.changeId) {
7384
- const fullPath = path.join(dir, args.path);
8784
+ const fullPath = join(dir, args.path);
7385
8785
  try {
7386
8786
  const stream = fs.createReadStream(
7387
8787
  fullPath,
@@ -7431,14 +8831,14 @@ async function createJJ(options) {
7431
8831
  });
7432
8832
  }
7433
8833
 
7434
- const fullPath = path.join(dir, args.path);
8834
+ const fullPath = join(dir, args.path);
7435
8835
 
7436
8836
  // Ensure directory exists
7437
8837
  const pathParts = args.path.split('/');
7438
8838
  if (pathParts.length > 1) {
7439
8839
  const dirPath = pathParts.slice(0, -1).join('/');
7440
- const fullDirPath = path.join(dir, dirPath);
7441
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8840
+ const fullDirPath = join(dir, dirPath);
8841
+ await mkdirp(fs, fullDirPath);
7442
8842
  }
7443
8843
 
7444
8844
  // Create writable stream
@@ -7551,7 +8951,7 @@ async function createJJ(options) {
7551
8951
  // Ensure destination directory exists
7552
8952
  const toDir = toPath.substring(0, toPath.lastIndexOf('/'));
7553
8953
  try {
7554
- await fs.promises.mkdir(toDir, { recursive: true });
8954
+ await mkdirp(fs, toDir);
7555
8955
  } catch (error) {
7556
8956
  throw new JJError(
7557
8957
  'FILE_SYSTEM_ERROR',
@@ -7669,6 +9069,51 @@ async function createJJ(options) {
7669
9069
  // TODO: Prevent creating cycles in the change graph
7670
9070
  // For now, we'll allow it but it should be validated
7671
9071
 
9072
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
9073
+ // reverse the reparenting — see issue #29.
9074
+ const changeSnapshotBefore = structuredClone(change);
9075
+
9076
+ // Three-way merge against the OLD base before reparenting (issue #31):
9077
+ // real jj's rebase re-derives the rebased change's content as
9078
+ // (old base, old commit, new base) — if a sibling changed the same
9079
+ // lines differently, that's a genuine conflict, not something
9080
+ // reparenting-and-copying-verbatim can silently paper over. This
9081
+ // reuses the exact same detectConflicts() machinery merge() already
9082
+ // uses (see merge(), a few hundred lines down) rather than
9083
+ // duplicating three-way-merge logic a third time in this codebase.
9084
+ const oldParentId = change.parents[0];
9085
+ const oldParentChange = oldParentId ? await graph.getChange(oldParentId) : null;
9086
+ const baseFiles = new Map(
9087
+ Object.entries((oldParentChange && oldParentChange.fileSnapshot) || {})
9088
+ );
9089
+ const leftFiles = new Map(Object.entries(change.fileSnapshot || {}));
9090
+ const rightFiles = new Map(Object.entries(newParentChange.fileSnapshot || {}));
9091
+
9092
+ await conflicts.load();
9093
+ const conflictsSnapshot = {
9094
+ conflicts: Object.fromEntries(conflicts.conflicts),
9095
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
9096
+ };
9097
+
9098
+ const detectedConflicts = oldParentChange
9099
+ ? await conflicts.detectConflicts({
9100
+ baseFiles,
9101
+ leftFiles,
9102
+ rightFiles,
9103
+ drivers: {},
9104
+ // Not necessarily the checked-out working copy, so don't let
9105
+ // merge drivers write to disk on its behalf.
9106
+ workingCopyDir: /** @type {any} */ (null),
9107
+ baseChange: oldParentId,
9108
+ leftChange: changeId,
9109
+ rightChange: newParent,
9110
+ })
9111
+ : [];
9112
+
9113
+ for (const conflict of detectedConflicts) {
9114
+ await conflicts.addConflict(conflict);
9115
+ }
9116
+
7672
9117
  // Update parent
7673
9118
  change.parents = [newParent];
7674
9119
  await graph.updateChange(change);
@@ -7679,6 +9124,8 @@ async function createJJ(options) {
7679
9124
  user: await getUserOplogInfo(),
7680
9125
  description: `move change ${changeId.slice(0, 8)} to ${newParent.slice(0, 8)}`,
7681
9126
  parents: [],
9127
+ changeSnapshot: { [changeId]: changeSnapshotBefore },
9128
+ conflictsSnapshot,
7682
9129
  view: {
7683
9130
  bookmarks: {},
7684
9131
  remoteBookmarks: {},
@@ -7687,7 +9134,7 @@ async function createJJ(options) {
7687
9134
  },
7688
9135
  });
7689
9136
 
7690
- return change;
9137
+ return { ...change, conflicts: detectedConflicts };
7691
9138
  },
7692
9139
 
7693
9140
  /**
@@ -7859,7 +9306,7 @@ async function createJJ(options) {
7859
9306
 
7860
9307
  for (const filePath of trackedFiles) {
7861
9308
  try {
7862
- const fullPath = path.join(dir, filePath);
9309
+ const fullPath = join(dir, filePath);
7863
9310
  const stats = await fs.promises.stat(fullPath);
7864
9311
 
7865
9312
  // Skip files that are too large (silently - this is an optimization)
@@ -7872,7 +9319,7 @@ async function createJJ(options) {
7872
9319
  break;
7873
9320
  }
7874
9321
 
7875
- const content = await fs.promises.readFile(fullPath, 'utf-8');
9322
+ const content = await fs.promises.readFile(fullPath, 'utf8');
7876
9323
  fileSnapshot[filePath] = content;
7877
9324
  totalSnapshotSize += stats.size;
7878
9325
  } catch (error) {
@@ -8070,6 +9517,8 @@ async function createJJ(options) {
8070
9517
 
8071
9518
  // Snapshot filesystem BEFORE operation (for undo) unless preserveSnapshot is true
8072
9519
  let fileSnapshot;
9520
+ /** @type {Record<string, any>} */
9521
+ const changeSnapshot = {};
8073
9522
  if (!args.preserveSnapshot) {
8074
9523
  fileSnapshot = await snapshotFilesystem();
8075
9524
 
@@ -8078,6 +9527,10 @@ async function createJJ(options) {
8078
9527
  const currentChangeId = workingCopy.getCurrentChangeId();
8079
9528
  const currentChange = await graph.getChange(currentChangeId);
8080
9529
  if (currentChange && fileSnapshot) {
9530
+ // Snapshot BEFORE mutating so undo()/redo() can revert this
9531
+ // in-place update too, not just the pointer/new-change creation
9532
+ // below — see issue #29.
9533
+ changeSnapshot[currentChangeId] = structuredClone(currentChange);
8081
9534
  currentChange.fileSnapshot = fileSnapshot;
8082
9535
  await graph.updateChange(currentChange);
8083
9536
  }
@@ -8105,6 +9558,7 @@ async function createJJ(options) {
8105
9558
  const user = /** @type {any} */ (userConfig.getUser());
8106
9559
 
8107
9560
  // Initialize with parent's file snapshot
9561
+ /** @type {Record<string, string>} */
8108
9562
  let initialSnapshot = {};
8109
9563
  if (parents && parents.length > 0) {
8110
9564
  const parent = await graph.getChange(parents[0]);
@@ -8148,10 +9602,19 @@ async function createJJ(options) {
8148
9602
  // Handle insertBefore: rebase target to have new change as parent
8149
9603
  if (args.insertBefore) {
8150
9604
  const targetChange = await graph.getChange(args.insertBefore);
9605
+ changeSnapshot[args.insertBefore] = structuredClone(targetChange);
8151
9606
  targetChange.parents = [newChangeId];
8152
9607
  await graph.updateChange(targetChange);
8153
9608
  }
8154
9609
 
9610
+ // Sync the working directory to the new change's snapshot: write
9611
+ // everything it has AND remove anything it doesn't (issue #30 — this
9612
+ // used to never touch disk at all, so `new({ parents: [...] })`
9613
+ // targeting anything other than the current head silently left the
9614
+ // OLD working copy's files in place instead of checking out the new
9615
+ // parent's).
9616
+ await syncWorkingCopyFiles(initialSnapshot);
9617
+
8155
9618
  await workingCopy.setCurrentChange(newChangeId);
8156
9619
 
8157
9620
  // Record operation with filesystem snapshot
@@ -8164,6 +9627,13 @@ async function createJJ(options) {
8164
9627
  },
8165
9628
  description: `new change ${newChangeId.slice(0, 8)}`,
8166
9629
  parents: [],
9630
+ // See issue #29: lets undo()/redo()/operations.restore() reverse
9631
+ // the existing records this op updated in place (the old
9632
+ // working-copy change's refreshed fileSnapshot, and insertBefore's
9633
+ // target's reparented parents). The newly created change itself
9634
+ // (newChangeId) is intentionally NOT torn down on undo — see
9635
+ // computeGraphReversal's doc comment.
9636
+ changeSnapshot,
8167
9637
  view: {
8168
9638
  bookmarks: {},
8169
9639
  remoteBookmarks: {},
@@ -8544,6 +10014,13 @@ async function createJJ(options) {
8544
10014
 
8545
10015
  const previousChangeId = workingCopy.getCurrentChangeId();
8546
10016
 
10017
+ /** @type {Record<string, any>} */
10018
+ const changeSnapshot = {};
10019
+
10020
+ // Capture disk state right before we touch anything, for undo() —
10021
+ // see issue #29.
10022
+ const fileSnapshotBeforeSwitch = await snapshotFilesystem();
10023
+
8547
10024
  // CRITICAL FIX: Clean working directory BEFORE snapshotting to prevent cross-branch pollution
8548
10025
  // Remove files that don't belong to the current changeId before we snapshot it
8549
10026
  if (previousChangeId !== args.changeId) {
@@ -8557,7 +10034,7 @@ async function createJJ(options) {
8557
10034
  for (const filePath of workingDirFiles) {
8558
10035
  if (!previousSnapshot[filePath]) {
8559
10036
  try {
8560
- const fullPath = path.join(dir, filePath);
10037
+ const fullPath = join(dir, filePath);
8561
10038
  await fs.promises.unlink(fullPath);
8562
10039
  await workingCopy.untrackFile(filePath);
8563
10040
  } catch (error) {
@@ -8565,13 +10042,14 @@ async function createJJ(options) {
8565
10042
  }
8566
10043
  }
8567
10044
  }
8568
- }
8569
10045
 
8570
- // Now snapshot the CLEAN working directory (no pollution!)
8571
- const currentSnapshot = await snapshotFilesystem();
8572
- if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
8573
- previousChange.fileSnapshot = currentSnapshot;
8574
- await graph.updateChange(previousChange);
10046
+ // Now snapshot the CLEAN working directory (no pollution!)
10047
+ const currentSnapshot = await snapshotFilesystem();
10048
+ if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
10049
+ changeSnapshot[previousChangeId] = structuredClone(previousChange);
10050
+ previousChange.fileSnapshot = currentSnapshot;
10051
+ await graph.updateChange(previousChange);
10052
+ }
8575
10053
  }
8576
10054
  }
8577
10055
 
@@ -8584,38 +10062,17 @@ async function createJJ(options) {
8584
10062
  timestamp: new Date().toISOString(),
8585
10063
  });
8586
10064
 
8587
- // Restore files from the change's snapshot to working directory
8588
- if (change.fileSnapshot) {
8589
- for (const [filePath, content] of Object.entries(change.fileSnapshot)) {
8590
- try {
8591
- const fullPath = path.join(dir, filePath);
8592
-
8593
- // Ensure directory exists
8594
- const pathParts = filePath.split('/');
8595
- if (pathParts.length > 1) {
8596
- const dirPath = pathParts.slice(0, -1).join('/');
8597
- const fullDirPath = path.join(dir, dirPath);
8598
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8599
- }
8600
-
8601
- // Write file content
8602
- await fs.promises.writeFile(fullPath, content, 'utf8');
8603
-
8604
- // Update working copy tracking
8605
- const stats = await fs.promises.stat(fullPath);
8606
- await workingCopy.trackFile(filePath, {
8607
- mtime: stats.mtime,
8608
- size: stats.size,
8609
- mode: stats.mode,
8610
- });
8611
- } catch (error) {
8612
- throw new JJError(
8613
- 'FILE_RESTORE_FAILED',
8614
- `Failed to restore file ${filePath}: ${error.message}`,
8615
- { filePath, originalError: error.message }
8616
- );
8617
- }
8618
- }
10065
+ // Fully sync the working directory to the target change's snapshot:
10066
+ // write everything it has AND remove anything it doesn't (issue #30
10067
+ // — this used to only overlay the target's files on top of whatever
10068
+ // was already on disk, so a file present in the PREVIOUS change but
10069
+ // absent from the target leaked into the next snapshot).
10070
+ try {
10071
+ await syncWorkingCopyFiles(change.fileSnapshot || {});
10072
+ } catch (error) {
10073
+ throw new JJError('FILE_RESTORE_FAILED', `Failed to restore files: ${error.message}`, {
10074
+ originalError: error.message,
10075
+ });
8619
10076
  }
8620
10077
 
8621
10078
  // Set this change as the working copy
@@ -8631,11 +10088,13 @@ async function createJJ(options) {
8631
10088
  },
8632
10089
  description: `edit change ${args.changeId.slice(0, 8)}`,
8633
10090
  parents: [],
10091
+ changeSnapshot,
8634
10092
  view: {
8635
10093
  bookmarks: {},
8636
10094
  remoteBookmarks: {},
8637
10095
  heads: [args.changeId],
8638
10096
  workingCopy: args.changeId,
10097
+ fileSnapshot: fileSnapshotBeforeSwitch,
8639
10098
  },
8640
10099
  });
8641
10100
 
@@ -8666,106 +10125,118 @@ async function createJJ(options) {
8666
10125
  /**
8667
10126
  * Undo last operation
8668
10127
  *
10128
+ * Reverts the most recent operation — including graph-rewriting ones
10129
+ * (squash/rebase/abandon/new/edit), not just working-copy/file state
10130
+ * (see issue #29). Matches real jj's progressive-undo semantics
10131
+ * (`jj-undo(1)`): called again with nothing else in between, it steps
10132
+ * one further operation into the past rather than undoing itself.
10133
+ *
8669
10134
  * @returns {Promise<Object>} Information about the undo including the undone operation and restored state
8670
10135
  */
8671
10136
  async undo() {
8672
- // Get the operation we're about to undo to access its pre-state
8673
10137
  await oplog.load();
8674
10138
  const ops = await oplog.list();
8675
10139
  if (ops.length === 0) {
8676
10140
  throw new JJError('NOTHING_TO_UNDO', 'No operations to undo');
8677
10141
  }
8678
10142
 
8679
- // Get the current operation (the one being undone) to restore its pre-state
8680
- const currentOp = ops[ops.length - 1];
8681
-
8682
- const previousView = /** @type {any} */ (await oplog.undo());
8683
-
8684
- // Restore state from previous view
8685
- await workingCopy.setCurrentChange(previousView.workingCopy);
8686
-
8687
- // Restore ChangeGraph content mutated in place by the operation being
8688
- // undone (e.g. describe() editing change.description) — see issue #12.
8689
- // currentOp.changeSnapshot holds the affected change record(s) as they
8690
- // were BEFORE currentOp ran, captured the same way fileSnapshot is.
8691
- await graph.load();
8692
- if (currentOp && currentOp.changeSnapshot) {
8693
- for (const snapshot of Object.values(
8694
- /** @type {Record<string, any>} */ (currentOp.changeSnapshot)
8695
- )) {
8696
- if (snapshot && (await graph.getChange(snapshot.changeId))) {
8697
- await graph.updateChange(snapshot);
8698
- }
8699
- }
8700
- }
8701
-
8702
- // Restore filesystem from previous operation's snapshot (taken BEFORE that operation)
8703
- // This is how JJ snapshots the working copy before every command
8704
- if (previousView.fileSnapshot) {
8705
- for (const [filePath, content] of Object.entries(previousView.fileSnapshot)) {
8706
- try {
8707
- const fullPath = path.join(dir, filePath);
8708
-
8709
- // Ensure directory exists
8710
- const pathParts = filePath.split('/');
8711
- if (pathParts.length > 1) {
8712
- const dirPath = pathParts.slice(0, -1).join('/');
8713
- const fullDirPath = path.join(dir, dirPath);
8714
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8715
- }
8716
-
8717
- // Write file content
8718
- await fs.promises.writeFile(fullPath, content, 'utf8');
8719
- } catch (error) {
8720
- throw new JJError(
8721
- 'UNDO_FILE_RESTORE_FAILED',
8722
- `Failed to restore file ${filePath} during undo: ${error.message}`,
8723
- { filePath, originalError: error.message }
8724
- );
8725
- }
8726
- }
8727
- }
10143
+ const targetOp = resolveUndoTarget(ops);
10144
+ const landingOpId = targetOp.parents && targetOp.parents[0];
10145
+ const landingOp = landingOpId ? ops.find((op) => op.id === landingOpId) : null;
8728
10146
 
8729
- // Restore conflicts state from before the undone operation
8730
- // The conflictsSnapshot in an operation represents the state BEFORE that operation ran
8731
- await conflicts.load();
8732
- if (currentOp && currentOp.conflictsSnapshot) {
8733
- // Restore conflicts from the operation we're undoing (its pre-state)
8734
- conflicts.conflicts = new Map(Object.entries(currentOp.conflictsSnapshot.conflicts || {}));
8735
- conflicts.fileConflicts = new Map(
8736
- Object.entries(currentOp.conflictsSnapshot.fileConflicts || {})
8737
- );
8738
- await conflicts.save();
10147
+ /** @type {ReturnType<typeof computeGraphReversal>} */
10148
+ let reversal;
10149
+ /** @type {any} */
10150
+ let landingView;
10151
+ if (landingOp) {
10152
+ reversal = computeGraphReversal(ops, landingOp);
10153
+ landingView = landingOp.view || {};
8739
10154
  } else {
8740
- // No conflicts snapshot - clear conflicts
8741
- await conflicts.clear();
10155
+ // Undoing the very first (root) operation: there's nothing before
10156
+ // it to restore to, so land on its own recorded state rather than
10157
+ // throwing (mirrors the previous no-parent fallback).
10158
+ reversal = {
10159
+ changeSnapshotToApply: {},
10160
+ fileSnapshot: undefined,
10161
+ conflictsSnapshot: undefined,
10162
+ };
10163
+ landingView = targetOp.view || {};
10164
+ }
10165
+
10166
+ // Capture EXACTLY what's about to be overwritten, from ground truth,
10167
+ // before applying the reversal — this is what redo() re-applies
10168
+ // later. It can't just recompute "the state after targetOp" the way
10169
+ // undo() itself computes "the state after landingOp" (by scanning
10170
+ // for a LATER op that also touched the same changeId/files/
10171
+ // conflicts): that scan only works going backward, where "nothing
10172
+ // touched it since" means "it's already correct". Going forward,
10173
+ // "nothing touched it since targetOp" means the opposite — its
10174
+ // effect is still live and needs to be captured now, not rederived.
10175
+ await graph.load();
10176
+ /** @type {Record<string, any>} */
10177
+ const redoChangeSnapshot = {};
10178
+ for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
10179
+ const current = await graph.getChange(changeId);
10180
+ if (current) redoChangeSnapshot[changeId] = current;
10181
+ }
10182
+ const redoFileSnapshot =
10183
+ reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
10184
+ let redoConflictsSnapshot;
10185
+ if (reversal.conflictsSnapshot !== undefined) {
10186
+ await conflicts.load();
10187
+ redoConflictsSnapshot = {
10188
+ conflicts: Object.fromEntries(conflicts.conflicts),
10189
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
10190
+ };
8742
10191
  }
8743
10192
 
8744
- // Record undo operation. Tag it with the view it undid so that redo()
8745
- // can re-apply it (jj's progressive undo/redo, added in jj v0.33).
10193
+ if (landingView.workingCopy) {
10194
+ await workingCopy.setCurrentChange(landingView.workingCopy);
10195
+ }
10196
+ await applyGraphReversal(reversal);
10197
+
10198
+ // Record undo as its own operation (matching real jj) — tagged with
10199
+ // which operation it targeted so a following undo() can walk one
10200
+ // step further back (resolveUndoTarget) instead of toggling, and
10201
+ // carrying exactly what redo() needs to re-apply (captured above).
10202
+ //
10203
+ // Also record `changeSnapshot: redoChangeSnapshot` — the SAME
10204
+ // ground-truth-before-this-op values, but under the field name
10205
+ // computeGraphReversal() actually scans for. Without this, undo()
10206
+ // itself was the one mutating operation in the whole API that never
10207
+ // recorded its own changeSnapshot, so a LATER undo() targeting THIS
10208
+ // undo (to step back past it) had nothing to restore: it would
10209
+ // repoint the working copy and files, but leave whatever this
10210
+ // undo() overwrote (e.g. a change's description) exactly as this
10211
+ // undo() left it. See issue #37(a).
8746
10212
  await oplog.recordOperation({
8747
10213
  timestamp: new Date().toISOString(),
8748
10214
  user: await getUserOplogInfo(),
8749
10215
  description: 'undo operation',
8750
10216
  eventType: 'undo',
8751
- undoneOpId: currentOp.id,
8752
- undoneView: currentOp.view,
8753
- undoneConflictsSnapshot: currentOp.conflictsSnapshot || null,
10217
+ targetOpId: targetOp.id,
10218
+ undoneOpId: targetOp.id, // kept for API/type compatibility
10219
+ changeSnapshot: redoChangeSnapshot,
10220
+ redoChangeSnapshot,
10221
+ redoFileSnapshot,
10222
+ redoConflictsSnapshot,
8754
10223
  parents: [],
8755
- view: previousView,
10224
+ view: landingView,
8756
10225
  });
8757
10226
 
8758
- // Return information about what was undone
10227
+ const restoredFileCount =
10228
+ reversal.fileSnapshot !== undefined ? Object.keys(reversal.fileSnapshot).length : 0;
10229
+
8759
10230
  return {
8760
10231
  undoneOperation: {
8761
- description: currentOp.description,
8762
- timestamp: currentOp.timestamp,
8763
- user: currentOp.user,
10232
+ description: targetOp.description,
10233
+ timestamp: targetOp.timestamp,
10234
+ user: targetOp.user,
8764
10235
  },
8765
10236
  restoredState: {
8766
- workingCopy: previousView.workingCopy,
8767
- heads: previousView.heads,
8768
- fileCount: previousView.fileSnapshot ? Object.keys(previousView.fileSnapshot).length : 0,
10237
+ workingCopy: landingView.workingCopy,
10238
+ heads: landingView.heads,
10239
+ fileCount: restoredFileCount,
8769
10240
  },
8770
10241
  };
8771
10242
  },
@@ -8773,9 +10244,9 @@ async function createJJ(options) {
8773
10244
  /**
8774
10245
  * Redo an operation previously undone with undo() (v1.5)
8775
10246
  *
8776
- * Mirrors `jj redo` (jj v0.33): progressively re-applies the operations that
8777
- * `undo()` reverted, most-recent-undo first. Each redo consumes exactly one
8778
- * pending undo.
10247
+ * Mirrors `jj redo` (jj v0.33): re-applies the operation that the most
10248
+ * recent not-yet-redone undo() reverted — including its graph rewrites,
10249
+ * not just working-copy/file state (see issue #29).
8779
10250
  *
8780
10251
  * @returns {Promise<Object>} Information about the redone operation
8781
10252
  */
@@ -8806,42 +10277,52 @@ async function createJJ(options) {
8806
10277
  });
8807
10278
  }
8808
10279
 
8809
- const redoneView = undoOp.undoneView || {};
10280
+ const targetOpId = undoOp.targetOpId || undoOp.undoneOpId;
10281
+ const targetOp = targetOpId && ops.find((op) => op.id === targetOpId);
10282
+ const redoneView = (targetOp && targetOp.view) || {};
8810
10283
 
8811
- // Restore the working-copy pointer.
8812
10284
  if (redoneView.workingCopy) {
8813
10285
  await workingCopy.setCurrentChange(redoneView.workingCopy);
8814
10286
  }
8815
10287
 
8816
- // Restore the working-copy files captured in the undone view.
8817
- if (redoneView.fileSnapshot) {
8818
- for (const [filePath, content] of Object.entries(redoneView.fileSnapshot)) {
8819
- try {
8820
- const fullPath = path.join(dir, filePath);
8821
- const pathParts = filePath.split('/');
8822
- if (pathParts.length > 1) {
8823
- const fullDirPath = path.join(dir, pathParts.slice(0, -1).join('/'));
8824
- await fs.promises.mkdir(fullDirPath, { recursive: true });
8825
- }
8826
- await fs.promises.writeFile(fullPath, content, 'utf8');
8827
- } catch (error) {
8828
- throw new JJError(
8829
- 'REDO_FILE_RESTORE_FAILED',
8830
- `Failed to restore file ${filePath} during redo: ${error.message}`,
8831
- { filePath, originalError: error.message }
8832
- );
8833
- }
8834
- }
10288
+ // Re-apply exactly what this undo captured right before it reverted
10289
+ // things — NOT computeGraphReversal(ops, targetOp): that scans for a
10290
+ // LATER op touching the same changeId/files/conflicts, which is the
10291
+ // right question for undo() ("has anything since made my target
10292
+ // stale?") but the wrong one here — after an undo, NOTHING later
10293
+ // re-established targetOp's own effect, so there's nothing to find
10294
+ // by scanning forward. undo() captured it directly for exactly this
10295
+ // reason (see its own comment).
10296
+ await graph.load();
10297
+
10298
+ // Capture ground truth right before overwriting, same as undo()
10299
+ // does for its own changeSnapshot (issue #37a) — lets a later
10300
+ // undo() targeting THIS redo op restore what redo() is about to
10301
+ // clobber, instead of finding no changeSnapshot at all.
10302
+ /** @type {Record<string, any>} */
10303
+ const undoChangeSnapshot = {};
10304
+ for (const changeId of Object.keys(undoOp.redoChangeSnapshot || {})) {
10305
+ const current = await graph.getChange(changeId);
10306
+ if (current) undoChangeSnapshot[changeId] = current;
8835
10307
  }
8836
10308
 
8837
- // Restore conflicts to the state that existed after the redone operation.
10309
+ for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
10310
+ if (await graph.getChange(changeId)) {
10311
+ // Bypass the git-sync middleware — see the matching comment in
10312
+ // applyGraphReversal(). `snapshot` already carries the change's
10313
+ // correct commitId from before the undo it's reversing ran
10314
+ // (issue #37c applies to redo() too).
10315
+ await baseGraph.updateChange(snapshot);
10316
+ }
10317
+ }
10318
+ if (undoOp.redoFileSnapshot !== undefined) {
10319
+ await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
10320
+ }
8838
10321
  await conflicts.load();
8839
- if (undoOp.undoneConflictsSnapshot) {
8840
- conflicts.conflicts = new Map(
8841
- Object.entries(undoOp.undoneConflictsSnapshot.conflicts || {})
8842
- );
10322
+ if (undoOp.redoConflictsSnapshot !== undefined) {
10323
+ conflicts.conflicts = new Map(Object.entries(undoOp.redoConflictsSnapshot.conflicts || {}));
8843
10324
  conflicts.fileConflicts = new Map(
8844
- Object.entries(undoOp.undoneConflictsSnapshot.fileConflicts || {})
10325
+ Object.entries(undoOp.redoConflictsSnapshot.fileConflicts || {})
8845
10326
  );
8846
10327
  await conflicts.save();
8847
10328
  }
@@ -8852,19 +10333,23 @@ async function createJJ(options) {
8852
10333
  description: 'redo operation',
8853
10334
  eventType: 'redo',
8854
10335
  redoOf: undoOp.id,
10336
+ changeSnapshot: undoChangeSnapshot,
8855
10337
  parents: [],
8856
10338
  view: redoneView,
8857
10339
  });
8858
10340
 
10341
+ const restoredFileCount =
10342
+ undoOp.redoFileSnapshot !== undefined ? Object.keys(undoOp.redoFileSnapshot).length : 0;
10343
+
8859
10344
  return {
8860
10345
  redoneOperation: {
8861
10346
  description: undoOp.description,
8862
- undoneOpId: undoOp.undoneOpId,
10347
+ undoneOpId: targetOpId,
8863
10348
  },
8864
10349
  restoredState: {
8865
10350
  workingCopy: redoneView.workingCopy,
8866
10351
  heads: redoneView.heads,
8867
- fileCount: redoneView.fileSnapshot ? Object.keys(redoneView.fileSnapshot).length : 0,
10352
+ fileCount: restoredFileCount,
8868
10353
  },
8869
10354
  };
8870
10355
  },
@@ -9091,6 +10576,12 @@ async function createJJ(options) {
9091
10576
  /**
9092
10577
  * Restore repository to a specific operation (matches `jj operation restore`)
9093
10578
  *
10579
+ * Fully reverts everything recorded after the target operation —
10580
+ * including graph rewrites (squash/rebase/abandon/new/edit), working
10581
+ * copy files, and conflict state, not just bookmarks/the working-copy
10582
+ * pointer (see issue #29) — using the same reversal primitive as
10583
+ * undo()/redo().
10584
+ *
9094
10585
  * @param {Record<string, any>} args - Arguments
9095
10586
  * @returns {Promise<Object>} Restore result
9096
10587
  */
@@ -9130,12 +10621,35 @@ async function createJJ(options) {
9130
10621
  await workingCopy.setCurrentChange(targetOp.view.workingCopy);
9131
10622
  }
9132
10623
 
10624
+ // Revert the change graph, working-copy files, and conflict state
10625
+ // to exactly what they were right after targetOp ran.
10626
+ const reversal = computeGraphReversal(ops, targetOp);
10627
+
10628
+ // Capture ground truth for every changeId this restore is about to
10629
+ // overwrite, BEFORE applying the reversal — exactly like undo()
10630
+ // does for its own changeSnapshot. Without this, restore()'s own
10631
+ // oplog entry carried no changeSnapshot at all, so undoing the
10632
+ // restore itself had nothing to put back: a later undo() would
10633
+ // repoint the working copy and files, but leave whatever the
10634
+ // restore had just overwritten (e.g. a change's description)
10635
+ // exactly as the restore left it. See issue #37(a).
10636
+ await graph.load();
10637
+ /** @type {Record<string, any>} */
10638
+ const changeSnapshot = {};
10639
+ for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
10640
+ const current = await graph.getChange(changeId);
10641
+ if (current) changeSnapshot[changeId] = current;
10642
+ }
10643
+
10644
+ await applyGraphReversal(reversal);
10645
+
9133
10646
  // Record this restoration as a new operation
9134
10647
  await oplog.recordOperation({
9135
10648
  timestamp: new Date().toISOString(),
9136
10649
  user: await getUserOplogInfo(),
9137
10650
  description: `restore to operation ${args.operation}`,
9138
10651
  parents: [],
10652
+ changeSnapshot,
9139
10653
  view: targetOp.view,
9140
10654
  });
9141
10655
 
@@ -9421,10 +10935,53 @@ async function createJJ(options) {
9421
10935
  timestamp: new Date().toISOString(),
9422
10936
  });
9423
10937
 
10938
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
10939
+ // reverse the squash (un-abandon source, restore dest's description
10940
+ // and content) — see issue #29.
10941
+ /** @type {Record<string, any>} */
10942
+ const changeSnapshot = {
10943
+ [source]: structuredClone(sourceChange),
10944
+ [destChangeId]: structuredClone(destChange),
10945
+ };
10946
+
9424
10947
  // Mark source as abandoned
9425
10948
  sourceChange.abandoned = true;
9426
10949
  await graph.updateChange(sourceChange);
9427
10950
 
10951
+ // Merge source's file content into dest (issue #30: squash() used to
10952
+ // only combine descriptions, leaving dest's tree untouched). Compute
10953
+ // what source itself actually changed relative to ITS OWN parent
10954
+ // (its "diff") and fold just that onto dest, rather than overwriting
10955
+ // dest's whole snapshot with source's — a path source never touched
10956
+ // (inherited unchanged from that parent) must not clobber a
10957
+ // dest-specific version of the same path.
10958
+ const sourceParentId = sourceChange.parents && sourceChange.parents[0];
10959
+ const sourceParentChange = sourceParentId ? await graph.getChange(sourceParentId) : null;
10960
+ const sourceBaseSnapshot = (sourceParentChange && sourceParentChange.fileSnapshot) || {};
10961
+ const sourceSnapshot = sourceChange.fileSnapshot || {};
10962
+ const mergedSnapshot = { ...(destChange.fileSnapshot || {}) };
10963
+ const touchedPaths = new Set([
10964
+ ...Object.keys(sourceBaseSnapshot),
10965
+ ...Object.keys(sourceSnapshot),
10966
+ ]);
10967
+ for (const filePath of touchedPaths) {
10968
+ const baseValue = sourceBaseSnapshot[filePath];
10969
+ const sourceValue = sourceSnapshot[filePath];
10970
+ if (sourceValue === baseValue) {
10971
+ // source never touched this path (inherited as-is from its own
10972
+ // parent) — leave whatever dest already had for it alone.
10973
+ continue;
10974
+ }
10975
+ if (sourceValue === undefined) {
10976
+ // source deleted a file it inherited from its parent.
10977
+ delete mergedSnapshot[filePath];
10978
+ } else {
10979
+ // source added or modified this path.
10980
+ mergedSnapshot[filePath] = sourceValue;
10981
+ }
10982
+ }
10983
+ destChange.fileSnapshot = mergedSnapshot;
10984
+
9428
10985
  // Update description of dest to indicate squash (middleware will sync to Git)
9429
10986
  destChange.description += `\n\n(squashed from ${source.slice(0, 8)})`;
9430
10987
  await graph.updateChange(destChange);
@@ -9452,10 +11009,22 @@ async function createJJ(options) {
9452
11009
  },
9453
11010
  description: '(no description set)',
9454
11011
  timestamp: new Date().toISOString(),
9455
- fileSnapshot: {},
11012
+ // Start from the DESTINATION's tree (which now includes source's
11013
+ // folded-in changes, set above), not an empty object — matches
11014
+ // real `jj squash`, where the new working-copy change looks
11015
+ // identical to its new parent until you touch something. Issue
11016
+ // #39: this used to hard-code `{}`, so status()/snapshot() saw
11017
+ // every on-disk file as untracked/new until something re-wrote
11018
+ // it and triggered a fresh auto-snapshot.
11019
+ fileSnapshot: { ...destChange.fileSnapshot },
9456
11020
  };
9457
11021
 
9458
11022
  await graph.addChange(newChange); // Middleware will sync to Git
11023
+ // Sync the working directory to match (issue #30's fix for
11024
+ // new()/edit(); squash()'s own synthetic post-squash change never
11025
+ // did this) — a no-op when source's parent was already dest (the
11026
+ // common case), but necessary whenever source/dest aren't adjacent.
11027
+ await syncWorkingCopyFiles(newChange.fileSnapshot);
9459
11028
  await workingCopy.setCurrentChange(newChangeId);
9460
11029
  newWorkingCopyId = newChangeId;
9461
11030
  }
@@ -9466,6 +11035,7 @@ async function createJJ(options) {
9466
11035
  user: await getUserOplogInfo(),
9467
11036
  description: `squash ${source.slice(0, 8)} into ${destChangeId.slice(0, 8)}`,
9468
11037
  parents: [],
11038
+ changeSnapshot,
9469
11039
  view: {
9470
11040
  bookmarks: {},
9471
11041
  remoteBookmarks: {},
@@ -9536,6 +11106,12 @@ async function createJJ(options) {
9536
11106
  throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
9537
11107
  }
9538
11108
 
11109
+ // Captured BEFORE anything mutates — see issue #38: abandoning the
11110
+ // change @ currently points at must move @ off it (real jj checks
11111
+ // out a fresh empty change on the abandoned change's own parent(s));
11112
+ // abandoning any OTHER change must leave @ exactly where it was.
11113
+ const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
11114
+
9539
11115
  // Dispatch change:abandoning event (preventable)
9540
11116
  await dispatchEventAsync(jj, 'change:abandoning', {
9541
11117
  operation: 'abandon',
@@ -9544,15 +11120,29 @@ async function createJJ(options) {
9544
11120
  timestamp: new Date().toISOString(),
9545
11121
  });
9546
11122
 
11123
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
11124
+ // reverse the abandon (un-abandon this change, un-reparent children)
11125
+ // — see issue #29.
11126
+ /** @type {Record<string, any>} */
11127
+ const changeSnapshot = { [changeId]: structuredClone(change) };
11128
+
9547
11129
  change.abandoned = true;
9548
11130
  await graph.updateChange(change);
9549
11131
 
11132
+ // Re-parent children onto the abandoned change's own parent(s)
11133
+ // (issue #30: this used to leave children pointing at the now-
11134
+ // abandoned commit). Matches real `jj abandon`: the abandoned
11135
+ // change is elided from the graph and its children attach directly
11136
+ // to whatever it was attached to.
11137
+ await reparentChildrenOnto(changeId, change.parents, changeSnapshot);
11138
+
9550
11139
  // Record operation
9551
11140
  await oplog.recordOperation({
9552
11141
  timestamp: new Date().toISOString(),
9553
11142
  user: { name: user.name, email: user.email, hostname: 'localhost' },
9554
11143
  description: `abandon change ${changeId.slice(0, 8)}`,
9555
11144
  parents: [],
11145
+ changeSnapshot,
9556
11146
  view: {
9557
11147
  bookmarks: {},
9558
11148
  remoteBookmarks: {},
@@ -9574,9 +11164,220 @@ async function createJJ(options) {
9574
11164
  { cancelable: false }
9575
11165
  );
9576
11166
 
11167
+ // If @ was pointing at the change we just abandoned, it can't stay
11168
+ // there — real jj moves @ to a new empty change on the abandoned
11169
+ // change's own parent(s) (issue #38). Reuse new()'s own "create an
11170
+ // empty change with given parents" logic rather than duplicating it
11171
+ // — this is exactly the workaround callers had to reach for
11172
+ // themselves (`new({ parents: [parentOfAt] })` before abandoning).
11173
+ if (wasWorkingCopy) {
11174
+ const newWorkingCopyChange = await jj.new({ parents: change.parents || [] });
11175
+ return { ...change, newWorkingCopyChangeId: newWorkingCopyChange.changeId };
11176
+ }
11177
+
9577
11178
  return change;
9578
11179
  },
9579
11180
 
11181
+ /**
11182
+ * Converge divergent copies of a change (matches `jj converge`, jj
11183
+ * v0.45.0+ — issue #32, extended for N-way per the isomorphic-jj v0.45
11184
+ * catch-up follow-up) — resolves ANY number of divergent copies of one
11185
+ * changeId in a single call. Real jj's own CLI currently only
11186
+ * converges one change-id per invocation (an explicit TODO in its own
11187
+ * source as of v0.45.1: "consider adding logic to deal with more than
11188
+ * one divergent change-id in one invocation"), which is what fixed
11189
+ * this package's own changeId-at-a-time API design — but its
11190
+ * underlying engine (`TruncatedEvolutionGraph`/`find_divergent_changes`)
11191
+ * already resolves every divergent revision of THAT one change-id
11192
+ * together, not just a pair. This generalizes the same per-path rule
11193
+ * accordingly: two-way "does the other side's value differ from base"
11194
+ * becomes N-way "how many DISTINCT values, across every copy, differ
11195
+ * from base" — 0 means nobody touched it, 1 means exactly one copy (or
11196
+ * several copies agreeing on the same new value) changed it and that
11197
+ * value wins, 2+ is a genuine conflict between that many copies.
11198
+ *
11199
+ * A change is divergent when more than one visible commit shares its
11200
+ * change id (see the `divergent()` revset, and
11201
+ * ChangeGraph.addDivergentCopy() for how a divergent copy gets added
11202
+ * in the first place — nothing in this library creates one on its
11203
+ * own yet; this resolves one once something has, e.g. a future git
11204
+ * fetch/concurrent-operation integration).
11205
+ *
11206
+ * Non-interactive-mode convention, matching this package's other
11207
+ * ambiguous-outcome APIs: like merge(), a genuine per-path conflict is
11208
+ * returned as data (`{ resolved: false, conflicts }`), not thrown —
11209
+ * you get a result back either way and decide what to do with
11210
+ * unresolved conflicts. What DOES throw is anything that isn't even a
11211
+ * well-formed convergence attempt (no divergence to resolve at all) —
11212
+ * the same category as merge()'s own `MERGE_ERROR` throw for "no
11213
+ * common ancestor found".
11214
+ *
11215
+ * @param {Record<string, any>|string} args - `{ changeId }`, or a bare changeId string
11216
+ * @returns {Promise<Object>} `{ changeId, resolved, conflicts, commitId? }`
11217
+ */
11218
+ async converge(args) {
11219
+ const changeId = typeof args === 'string' ? args : args && (args.changeId || args.change);
11220
+ if (!changeId || typeof changeId !== 'string') {
11221
+ throw new JJError('INVALID_ARGUMENT', 'Missing changeId argument', {
11222
+ suggestion: 'Provide the divergent changeId to converge: { changeId: "..." }',
11223
+ });
11224
+ }
11225
+
11226
+ await graph.load();
11227
+ await userConfig.load();
11228
+ const siblings = graph.getDivergentSiblings(changeId);
11229
+
11230
+ if (siblings.length < 2) {
11231
+ throw new JJError(
11232
+ 'NOT_DIVERGENT',
11233
+ `Change ${changeId} has no divergent copies to converge`,
11234
+ {
11235
+ changeId,
11236
+ suggestion:
11237
+ 'converge() is only meaningful for a changeId the divergent() revset reports',
11238
+ }
11239
+ );
11240
+ }
11241
+
11242
+ // The primary copy is whichever one getChange() resolves (stored
11243
+ // under the plain changeId key); every other one is a divergent copy
11244
+ // added via addDivergentCopy(). No cap on how many — see the doc
11245
+ // comment above.
11246
+ const primary = /** @type {any} */ (await graph.getChange(changeId));
11247
+ const others = siblings.filter((/** @type {any} */ s) => s.commitId !== primary.commitId);
11248
+
11249
+ // Base for the N-way merge: what this change looked like before it
11250
+ // diverged. This package doesn't retain historical snapshots (only
11251
+ // current content), so — same pragmatic choice rebase() makes for
11252
+ // "old base" — use the shared parent's current content; divergent
11253
+ // copies realistically share the same parent, since they're all
11254
+ // rewrites of one prior position in history, not unrelated commits
11255
+ // that happen to collide. Applied uniformly to every copy, not
11256
+ // per-copy — the same simplification the original two-way version
11257
+ // made, just not newly introduced by going N-way.
11258
+ const baseParentId = primary.parents && primary.parents[0];
11259
+ const baseParentChange = baseParentId ? await graph.getChange(baseParentId) : null;
11260
+ const baseFiles = new Map(
11261
+ Object.entries((baseParentChange && baseParentChange.fileSnapshot) || {})
11262
+ );
11263
+
11264
+ // Every copy (primary + others), each as its own file map, paired
11265
+ // with its commitId for conflict reporting.
11266
+ const copies = [primary, ...others].map((/** @type {any} */ c) => ({
11267
+ commitId: c.commitId,
11268
+ files: new Map(Object.entries(c.fileSnapshot || {})),
11269
+ }));
11270
+
11271
+ await conflicts.load();
11272
+ const conflictsSnapshot = {
11273
+ conflicts: Object.fromEntries(conflicts.conflicts),
11274
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
11275
+ };
11276
+
11277
+ const allPaths = new Set([
11278
+ ...baseFiles.keys(),
11279
+ ...copies.flatMap((c) => [...c.files.keys()]),
11280
+ ]);
11281
+
11282
+ /** @type {Record<string, string>} */
11283
+ const mergedSnapshot = {};
11284
+ /** @type {any[]} */
11285
+ const detectedConflicts = [];
11286
+
11287
+ for (const filePath of allPaths) {
11288
+ const baseValue = baseFiles.get(filePath);
11289
+ // Every DISTINCT value across all copies that differs from base,
11290
+ // each paired with (one of) the commitId(s) that has it — mirrors
11291
+ // ConflictModel._detectPathConflict's two-way "no conflict" rules,
11292
+ // generalized: 0 distinct changed values means nobody touched it
11293
+ // (base wins, i.e. nothing to write), 1 means only one side (or
11294
+ // several copies agreeing) changed it (that value wins), 2+ is a
11295
+ // genuine conflict.
11296
+ const changed = new Map();
11297
+ for (const copy of copies) {
11298
+ const value = copy.files.get(filePath);
11299
+ if (value !== baseValue && !changed.has(value)) {
11300
+ changed.set(value, copy.commitId);
11301
+ }
11302
+ }
11303
+
11304
+ if (changed.size === 0) {
11305
+ if (baseValue !== undefined) mergedSnapshot[filePath] = baseValue;
11306
+ } else if (changed.size === 1) {
11307
+ const [[value]] = changed;
11308
+ if (value !== undefined) mergedSnapshot[filePath] = value;
11309
+ } else {
11310
+ const versions = [...changed.entries()].map(([content, commitId]) => ({
11311
+ commitId,
11312
+ content,
11313
+ }));
11314
+ detectedConflicts.push(conflicts.createNWayConflict(filePath, baseValue, versions));
11315
+ }
11316
+ }
11317
+
11318
+ if (detectedConflicts.length > 0) {
11319
+ // Matches real jj: non-interactive mode aborts automatic
11320
+ // resolution rather than guessing — but per this package's own
11321
+ // merge() convention, that's reported back as data, not thrown.
11322
+ for (const conflict of detectedConflicts) {
11323
+ await conflicts.addConflict(conflict);
11324
+ }
11325
+ await oplog.recordOperation({
11326
+ timestamp: new Date().toISOString(),
11327
+ user: await getUserOplogInfo(),
11328
+ description: `converge ${changeId.slice(0, 8)} (unresolved)`,
11329
+ parents: [],
11330
+ conflictsSnapshot,
11331
+ view: {
11332
+ bookmarks: {},
11333
+ remoteBookmarks: {},
11334
+ heads: [],
11335
+ workingCopy: workingCopy.getCurrentChangeId(),
11336
+ },
11337
+ });
11338
+ return { changeId, resolved: false, conflicts: detectedConflicts };
11339
+ }
11340
+
11341
+ const user = /** @type {any} */ (userConfig.getUser());
11342
+ const changeSnapshot = { [changeId]: structuredClone(primary) };
11343
+ const otherIds = others.map((/** @type {any} */ o) => o.commitId.slice(0, 8)).join(', ');
11344
+
11345
+ const convergedChange = {
11346
+ ...primary,
11347
+ commitId: randomHex(20),
11348
+ fileSnapshot: mergedSnapshot,
11349
+ description: primary.description + `\n\n(converged divergent copies: ${otherIds})`,
11350
+ timestamp: new Date().toISOString(),
11351
+ committer: {
11352
+ name: user.name,
11353
+ email: user.email,
11354
+ timestamp: new Date().toISOString(),
11355
+ },
11356
+ divergent: false,
11357
+ };
11358
+ await graph.updateChange(convergedChange);
11359
+ for (const other of others) {
11360
+ await graph.deleteDivergentCopy(changeId, other.commitId);
11361
+ }
11362
+
11363
+ await oplog.recordOperation({
11364
+ timestamp: new Date().toISOString(),
11365
+ user: await getUserOplogInfo(),
11366
+ description: `converge ${changeId.slice(0, 8)}`,
11367
+ parents: [],
11368
+ changeSnapshot,
11369
+ conflictsSnapshot,
11370
+ view: {
11371
+ bookmarks: {},
11372
+ remoteBookmarks: {},
11373
+ heads: [changeId],
11374
+ workingCopy: workingCopy.getCurrentChangeId(),
11375
+ },
11376
+ });
11377
+
11378
+ return { changeId, resolved: true, conflicts: [], commitId: convergedChange.commitId };
11379
+ },
11380
+
9580
11381
  /**
9581
11382
  * Backout a change - create a new change that reverses the effect of the specified change
9582
11383
  *
@@ -11038,7 +12839,7 @@ async function createJJ(options) {
11038
12839
  .split('/')
11039
12840
  .pop()
11040
12841
  .replace(/\.git$/, '');
11041
- const fullCloneDir = path.join(dir, cloneDir);
12842
+ const fullCloneDir = join(dir, cloneDir);
11042
12843
 
11043
12844
  // Clone using isomorphic-git
11044
12845
  await git.clone({
@@ -11416,8 +13217,14 @@ async function createJJ(options) {
11416
13217
  * @returns {Promise<Object>} Repository root information
11417
13218
  */
11418
13219
  async root() {
11419
- // Return the directory containing .git
11420
- const gitDir = path.join(dir, '.git');
13220
+ // `dir` is a real host filesystem path (unlike the repo-relative
13221
+ // git/jj paths `path` — posix-path.js — is for), so it needs the
13222
+ // host's own separator here, not always '/': on Windows Node's real
13223
+ // `fs` still resolves a forward-slash-joined path fine, but this
13224
+ // method hands the string back to the caller as data, and jj's own
13225
+ // `git root` returns a platform-native path there.
13226
+ const isWin32 = typeof process !== 'undefined' && process.platform === 'win32';
13227
+ const gitDir = isWin32 ? `${dir.replace(/[\\/]+$/, '')}\\.git` : join(dir, '.git');
11421
13228
 
11422
13229
  try {
11423
13230
  await fs.promises.access(gitDir);
@@ -11496,7 +13303,7 @@ async function createJJ(options) {
11496
13303
  const wcFiles = await workingCopy.listFiles();
11497
13304
  for (const file of wcFiles) {
11498
13305
  try {
11499
- const content = await fs.promises.readFile(path.join(dir, file), 'utf-8');
13306
+ const content = await fs.promises.readFile(join(dir, file), 'utf8');
11500
13307
  leftFiles.set(file, content);
11501
13308
  } catch (error) {
11502
13309
  // File might be deleted or binary - throw error for explicit handling
@@ -11639,7 +13446,7 @@ async function createJJ(options) {
11639
13446
  }
11640
13447
 
11641
13448
  // Write resolved content to file
11642
- await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf-8');
13449
+ await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
11643
13450
 
11644
13451
  // Mark conflict as resolved
11645
13452
  await conflicts.resolveConflict(args.conflictId, 'manual');
@@ -11682,7 +13489,7 @@ async function createJJ(options) {
11682
13489
  const resolvedContent = _resolveWithStrategy(conflict, args.strategy);
11683
13490
 
11684
13491
  // Write resolved content
11685
- await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf-8');
13492
+ await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
11686
13493
 
11687
13494
  // Mark as resolved
11688
13495
  await conflicts.resolveConflict(conflict.conflictId, args.strategy);
@@ -12004,7 +13811,7 @@ async function createJJ(options) {
12004
13811
  );
12005
13812
  }
12006
13813
 
12007
- const fullPath = path.join(dir, args.path);
13814
+ const fullPath = join(dir, args.path);
12008
13815
 
12009
13816
  // Convert string mode to number if needed
12010
13817
  const mode = typeof args.mode === 'string' ? parseInt(args.mode, 8) : args.mode;
@@ -12115,14 +13922,14 @@ async function createJJ(options) {
12115
13922
  if (change.fileSnapshot) {
12116
13923
  for (const [filePath, content] of Object.entries(change.fileSnapshot)) {
12117
13924
  try {
12118
- const fullPath = path.join(args.path, filePath);
13925
+ const fullPath = join(args.path, filePath);
12119
13926
 
12120
13927
  // Ensure directory exists
12121
13928
  const pathParts = filePath.split('/');
12122
13929
  if (pathParts.length > 1) {
12123
13930
  const dirPath = pathParts.slice(0, -1).join('/');
12124
- const fullDirPath = path.join(args.path, dirPath);
12125
- await fs.promises.mkdir(fullDirPath, { recursive: true });
13931
+ const fullDirPath = join(args.path, dirPath);
13932
+ await mkdirp(fs, fullDirPath);
12126
13933
  }
12127
13934
 
12128
13935
  // Write file content