isomorphic-jj 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,8 +16,9 @@ import { BackgroundOps } from '../core/background-ops.js';
16
16
  import { UserConfig } from '../core/user-config.js';
17
17
  import { IsomorphicGitBackend } from '../backend/isomorphic-git-backend.js';
18
18
  import { JJError } from '../utils/errors.js';
19
- import { generateChangeId } from '../utils/id-generation.js';
20
- import path from 'path';
19
+ import { generateChangeId, randomHex } from '../utils/id-generation.js';
20
+ import * as path from '../utils/posix-path.js';
21
+ import { mkdirp } from '../utils/mkdirp.js';
21
22
 
22
23
  /**
23
24
  * Create and initialize a JJ repository instance
@@ -116,7 +117,7 @@ export async function createJJ(options) {
116
117
  break;
117
118
  }
118
119
 
119
- const content = await fs.promises.readFile(fullPath, 'utf-8');
120
+ const content = await fs.promises.readFile(fullPath, 'utf8');
120
121
  fileSnapshot[filePath] = content;
121
122
  totalSnapshotSize += stats.size;
122
123
  } catch (error) {
@@ -130,6 +131,257 @@ export async function createJJ(options) {
130
131
  return fileSnapshot;
131
132
  };
132
133
 
134
+ /**
135
+ * Fully sync the working directory to a target file snapshot: writes
136
+ * every file the target has (create/update), AND deletes every currently
137
+ * tracked file the target does NOT have.
138
+ *
139
+ * Both edit() and new() used to only overlay the target's files on top of
140
+ * whatever was already on disk, so a file that existed in the previously
141
+ * checked-out change but not in the target leaked into the next
142
+ * snapshot — see issue #30. undo()/redo() had the same gap restoring
143
+ * file state — see issue #29. This is the one place that does it
144
+ * correctly, used by all four.
145
+ *
146
+ * @param {Record<string, string>} targetSnapshot - path -> file content
147
+ * @returns {Promise<void>}
148
+ */
149
+ const syncWorkingCopyFiles = async (targetSnapshot) => {
150
+ const snapshot = targetSnapshot || {};
151
+ const currentFiles = await workingCopy.listFiles();
152
+
153
+ // Remove files that aren't part of the target.
154
+ for (const filePath of currentFiles) {
155
+ if (!(filePath in snapshot)) {
156
+ try {
157
+ const fullPath = path.join(dir, filePath);
158
+ await fs.promises.unlink(fullPath);
159
+ } catch (error) {
160
+ // Already gone / inaccessible — fine, we just want it absent.
161
+ }
162
+ await workingCopy.untrackFile(filePath);
163
+ }
164
+ }
165
+
166
+ // Write/update every file the target has.
167
+ for (const [filePath, content] of Object.entries(snapshot)) {
168
+ const fullPath = path.join(dir, filePath);
169
+ const pathParts = filePath.split('/');
170
+ if (pathParts.length > 1) {
171
+ const fullDirPath = path.join(dir, pathParts.slice(0, -1).join('/'));
172
+ await mkdirp(fs, fullDirPath);
173
+ }
174
+ await fs.promises.writeFile(fullPath, content, 'utf8');
175
+
176
+ const stats = await fs.promises.stat(fullPath);
177
+ await workingCopy.trackFile(filePath, {
178
+ mtime: stats.mtime,
179
+ size: stats.size,
180
+ mode: stats.mode,
181
+ });
182
+ }
183
+ };
184
+
185
+ /**
186
+ * Compute the ChangeGraph/file/conflicts state that existed right after
187
+ * `landingOp` finished — i.e. what undo()/redo()/operations.restore()
188
+ * need to re-establish when winding the repository forward or back to
189
+ * that point (see issue #29).
190
+ *
191
+ * Every operation that mutates EXISTING change records in place records
192
+ * (additively — most operations touch none of this and that's fine,
193
+ * there's nothing to reverse for them):
194
+ * - changeSnapshot: { [changeId]: fullRecordBeforeThisOpRan }
195
+ * - view.fileSnapshot: on-disk file state right BEFORE this op ran
196
+ * - conflictsSnapshot: ConflictModel state right BEFORE this op ran
197
+ *
198
+ * Deliberately NOT reverted: changes an operation newly *created* (via
199
+ * graph.addChange() — new(), squash()'s synthetic empty working-copy
200
+ * change, ...). Undoing their creation only un-refs them (restores the
201
+ * working-copy pointer/heads that made them reachable) — it does not
202
+ * delete the change record — matching real jj, where an "unreachable"
203
+ * change stays resolvable by its change id (hidden, not gone) until
204
+ * explicitly gc'd, and this package's own history-editing tests rely on
205
+ * exactly that: rebasing onto a change created by a since-undone new()
206
+ * must still work.
207
+ *
208
+ * "The state right after landingOp" is therefore exactly what the FIRST
209
+ * operation after landingOp recorded as ITS "before" state, merged across
210
+ * every operation between landingOp and the current head in case
211
+ * different operations touched different changeIds (a single op's own
212
+ * snapshot only covers what IT touched). Concretely, per field:
213
+ * - changeSnapshot: first (earliest / closest-to-landingOp) entry per
214
+ * changeId wins — later entries in the range reflect that changeId's
215
+ * state at a LATER point than we're restoring to.
216
+ * - fileSnapshot / conflictsSnapshot: the first op in the range that
217
+ * recorded one, since whole-state snapshots aren't per-changeId and
218
+ * an op that didn't record one simply didn't change that state.
219
+ *
220
+ * This same helper backs undo() (landingOp = the parent of whatever's
221
+ * being undone), redo() (landingOp = the operation a prior undo()
222
+ * reverted), and operations.restore() (landingOp = the target operation
223
+ * itself) — one reversal primitive, not three.
224
+ *
225
+ * @param {Array<any>} ops - Full chronological operation list (oplog.list())
226
+ * @param {any} landingOp - The operation whose resulting state to recompute
227
+ * @returns {{
228
+ * changeSnapshotToApply: Record<string, any>,
229
+ * fileSnapshot: Record<string, string>|undefined,
230
+ * conflictsSnapshot: any,
231
+ * }}
232
+ */
233
+ const computeGraphReversal = (/** @type {any[]} */ ops, /** @type {any} */ landingOp) => {
234
+ const landingIndex = ops.findIndex((op) => op.id === landingOp.id);
235
+
236
+ /** @type {Record<string, any>} */
237
+ const changeSnapshotToApply = {};
238
+ /** @type {Record<string, string>|undefined} */
239
+ let fileSnapshot;
240
+ /** @type {any} */
241
+ let conflictsSnapshot;
242
+
243
+ for (let i = landingIndex + 1; i < ops.length; i++) {
244
+ const op = ops[i];
245
+
246
+ if (op.changeSnapshot) {
247
+ for (const [changeId, snapshot] of Object.entries(op.changeSnapshot)) {
248
+ if (!(changeId in changeSnapshotToApply)) {
249
+ changeSnapshotToApply[changeId] = snapshot;
250
+ }
251
+ }
252
+ }
253
+
254
+ // `!= null` (not `!== undefined`) on purpose: some operations record
255
+ // an explicit `null` for "I ran, and I deliberately took no snapshot"
256
+ // (e.g. describe() of a non-working-copy revision) — that's exactly
257
+ // as uninformative as never having set the field, so keep scanning
258
+ // rather than "finding" a null snapshot.
259
+ if (fileSnapshot === undefined && op.view && op.view.fileSnapshot != null) {
260
+ fileSnapshot = op.view.fileSnapshot;
261
+ }
262
+
263
+ if (conflictsSnapshot === undefined && op.conflictsSnapshot != null) {
264
+ conflictsSnapshot = op.conflictsSnapshot;
265
+ }
266
+ }
267
+
268
+ return { changeSnapshotToApply, fileSnapshot, conflictsSnapshot };
269
+ };
270
+
271
+ /**
272
+ * Apply a reversal computed by computeGraphReversal() to the live graph,
273
+ * working-copy files, and conflict state. Does NOT touch the working-copy
274
+ * pointer or bookmarks — callers set those from the landing op's own
275
+ * `view` directly (pointers, unlike file/graph/conflict content, are
276
+ * already correct as recorded, see computeGraphReversal's doc comment).
277
+ *
278
+ * @param {ReturnType<typeof computeGraphReversal>} reversal
279
+ * @returns {Promise<void>}
280
+ */
281
+ const applyGraphReversal = async (reversal) => {
282
+ await graph.load();
283
+ for (const [changeId, snapshot] of Object.entries(reversal.changeSnapshotToApply)) {
284
+ if (await graph.getChange(changeId)) {
285
+ await graph.updateChange(snapshot);
286
+ }
287
+ }
288
+
289
+ if (reversal.fileSnapshot !== undefined) {
290
+ await syncWorkingCopyFiles(reversal.fileSnapshot);
291
+ }
292
+
293
+ await conflicts.load();
294
+ if (reversal.conflictsSnapshot !== undefined) {
295
+ conflicts.conflicts = new Map(Object.entries(reversal.conflictsSnapshot.conflicts || {}));
296
+ conflicts.fileConflicts = new Map(
297
+ Object.entries(reversal.conflictsSnapshot.fileConflicts || {})
298
+ );
299
+ await conflicts.save();
300
+ }
301
+ // Else: no operation in the reverted range touched conflicts — leave
302
+ // current conflict state alone. (Previously undo() unconditionally
303
+ // cleared ALL conflicts whenever the op being undone hadn't recorded
304
+ // its own conflictsSnapshot, which is nearly every non-merge() op —
305
+ // undoing an unrelated describe() would silently wipe conflicts from
306
+ // an earlier, unrelated merge(). Fixed as part of issue #29.)
307
+ };
308
+
309
+ /**
310
+ * Re-parent every child of `changeId` onto `newParents` instead — used
311
+ * when a change is being elided from the graph (abandon(), issue #30;
312
+ * converge(), issue #32) so its children don't end up pointing at
313
+ * something no longer meant to be there. Mutates `changeSnapshot` in
314
+ * place, adding each touched child's PRE-reparent record so the caller's
315
+ * own oplog entry lets undo()/redo()/operations.restore() reverse this
316
+ * too (the same mechanism the caller already uses for the elided
317
+ * change's own snapshot).
318
+ *
319
+ * @param {string} changeId - The change being elided
320
+ * @param {string[]} newParents - What its children should point at instead
321
+ * @param {Record<string, any>} changeSnapshot - Accumulator to add each touched child's pre-state to
322
+ * @returns {Promise<void>}
323
+ */
324
+ const reparentChildrenOnto = async (changeId, newParents, changeSnapshot) => {
325
+ const childIds = graph.getChildren(changeId);
326
+ for (const childId of childIds) {
327
+ const child = await graph.getChange(childId);
328
+ if (!child) continue;
329
+ changeSnapshot[childId] = structuredClone(child);
330
+ const reparented = child.parents.flatMap((/** @type {string} */ p) =>
331
+ p === changeId ? newParents : [p]
332
+ );
333
+ // De-dupe (e.g. the child could already have one of the new parents
334
+ // as another of its own parents) while preserving first-seen order.
335
+ child.parents = [...new Set(reparented)];
336
+ await graph.updateChange(child);
337
+ }
338
+ };
339
+
340
+ /**
341
+ * Determine which operation a fresh undo() call should revert, matching
342
+ * real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
343
+ * a normal operation, this will undo that last operation by restoring its
344
+ * parent. If jj undo is used repeatedly, it will restore increasingly
345
+ * older operations, going further back into the past.").
346
+ *
347
+ * The raw op-log parent chain can't answer this directly once undo/redo
348
+ * bookkeeping operations are mixed into the log: an 'undo' operation's
349
+ * own parent is the operation it just undid, so naively targeting
350
+ * `ops[ops.length - 1]` on a SECOND undo() would undo the undo (toggle
351
+ * back to the future) instead of stepping one further step into the past
352
+ * — this was issue #29's "undo() twice only undoes the undo" bug.
353
+ *
354
+ * @param {Array<any>} ops - Full chronological operation list
355
+ * @returns {any} The operation object undo() should revert
356
+ */
357
+ const resolveUndoTarget = (ops) => {
358
+ const head = ops[ops.length - 1];
359
+ const findOp = (/** @type {any} */ id) => ops.find((op) => op.id === id);
360
+
361
+ if (head.eventType === 'undo') {
362
+ // We're stepping further into the past: what should be undone now is
363
+ // the operation right BEFORE the one `head` already undid — not
364
+ // head's own parent, which IS the operation head undid (targeting it
365
+ // again would just toggle back to the present).
366
+ const previouslyUndone = head.targetOpId && findOp(head.targetOpId);
367
+ const stepBack = previouslyUndone && findOp(previouslyUndone.parents[0]);
368
+ return stepBack || previouslyUndone || head;
369
+ }
370
+
371
+ if (head.eventType === 'redo') {
372
+ // A redo re-established some earlier operation as current; a fresh
373
+ // undo right after it should behave exactly as if that operation
374
+ // were freshly current and had never been touched by undo/redo at
375
+ // all — i.e. undo it again (real jj: redo "moves in the direction of
376
+ // the future"; undoing right after simply resumes stepping back).
377
+ const redoneUndo = head.redoOf && findOp(head.redoOf);
378
+ const target = redoneUndo && redoneUndo.targetOpId && findOp(redoneUndo.targetOpId);
379
+ return target || head;
380
+ }
381
+
382
+ return head;
383
+ };
384
+
133
385
  /**
134
386
  * Automatic working-copy snapshot (v1.6).
135
387
  *
@@ -314,6 +566,25 @@ export async function createJJ(options) {
314
566
  }
315
567
  },
316
568
 
569
+ // Used by undo()/redo()/operations.restore() to reverse an
570
+ // addChange() when winding the graph back — see issue #29. No Git
571
+ // sync hook: a change created after the operation being reverted was
572
+ // never pushed anywhere to unwind, and the Git backend has no
573
+ // "forget this commit" operation (nor should it — Git objects are
574
+ // content-addressed and harmless to leave orphaned).
575
+ deleteChange: (/** @type {any} */ changeId) => baseGraph.deleteChange(changeId),
576
+
577
+ // See issue #32 / converge(): a divergent copy is another commit
578
+ // claiming an existing changeId; no Git sync hook, for the same
579
+ // reason addChange()'s primary copy already goes through
580
+ // onAddChange() when it's first created — the divergent copy needs
581
+ // no additional sync of its own.
582
+ addDivergentCopy: (/** @type {any} */ change) => baseGraph.addDivergentCopy(change),
583
+ getDivergentSiblings: (/** @type {any} */ changeId) =>
584
+ baseGraph.getDivergentSiblings(changeId),
585
+ deleteDivergentCopy: (/** @type {any} */ changeId, /** @type {any} */ commitId) =>
586
+ baseGraph.deleteDivergentCopy(changeId, commitId),
587
+
317
588
  // Delegate other operations
318
589
  init: () => baseGraph.init(),
319
590
  };
@@ -471,6 +742,20 @@ export async function createJJ(options) {
471
742
  /**
472
743
  * Write a file to the working copy
473
744
  *
745
+ * Note (issue #30): this does NOT update the working-copy change's
746
+ * `commitId`/`fileSnapshot` — those stay as they were until the next
747
+ * command that snapshots the working copy (describe(), new(), edit(),
748
+ * status(), or any other call that goes through
749
+ * autoSnapshotWorkingCopy()/snapshot()). This is intentional, not a
750
+ * bug: jj has no staging area, but it also doesn't recompute a git
751
+ * tree/commit hash on every single file write (that would mean
752
+ * hashing on every keystroke of an external editor) — real jj only
753
+ * takes a fresh working-copy snapshot when an actual `jj` command
754
+ * runs. write() is the equivalent of editing a file with your OS's own
755
+ * tools outside of jj entirely; describe()/new()/edit()/status()/etc.
756
+ * are the equivalent of running a jj command, which is what triggers
757
+ * the (auto) snapshot in both real jj and here.
758
+ *
474
759
  * @param {Record<string, any>} args - Arguments
475
760
  * @returns {Promise<Object>} File information including path, size, and mode
476
761
  */
@@ -494,7 +779,7 @@ export async function createJJ(options) {
494
779
  if (pathParts.length > 1) {
495
780
  const dirPath = pathParts.slice(0, -1).join('/');
496
781
  const fullDirPath = `${dir}/${dirPath}`;
497
- await fs.promises.mkdir(fullDirPath, { recursive: true });
782
+ await mkdirp(fs, fullDirPath);
498
783
  }
499
784
 
500
785
  // Write the file
@@ -552,7 +837,7 @@ export async function createJJ(options) {
552
837
  const fullPath = path.join(dir, args.path);
553
838
  try {
554
839
  if (encoding === 'utf-8' || encoding === 'utf8') {
555
- return await fs.promises.readFile(fullPath, 'utf-8');
840
+ return await fs.promises.readFile(fullPath, 'utf8');
556
841
  } else {
557
842
  return await fs.promises.readFile(fullPath);
558
843
  }
@@ -774,7 +1059,7 @@ export async function createJJ(options) {
774
1059
  if (pathParts.length > 1) {
775
1060
  const dirPath = pathParts.slice(0, -1).join('/');
776
1061
  const fullDirPath = path.join(dir, dirPath);
777
- await fs.promises.mkdir(fullDirPath, { recursive: true });
1062
+ await mkdirp(fs, fullDirPath);
778
1063
  }
779
1064
 
780
1065
  // Create writable stream
@@ -887,7 +1172,7 @@ export async function createJJ(options) {
887
1172
  // Ensure destination directory exists
888
1173
  const toDir = toPath.substring(0, toPath.lastIndexOf('/'));
889
1174
  try {
890
- await fs.promises.mkdir(toDir, { recursive: true });
1175
+ await mkdirp(fs, toDir);
891
1176
  } catch (error) {
892
1177
  throw new JJError(
893
1178
  'FILE_SYSTEM_ERROR',
@@ -1005,6 +1290,51 @@ export async function createJJ(options) {
1005
1290
  // TODO: Prevent creating cycles in the change graph
1006
1291
  // For now, we'll allow it but it should be validated
1007
1292
 
1293
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
1294
+ // reverse the reparenting — see issue #29.
1295
+ const changeSnapshotBefore = structuredClone(change);
1296
+
1297
+ // Three-way merge against the OLD base before reparenting (issue #31):
1298
+ // real jj's rebase re-derives the rebased change's content as
1299
+ // (old base, old commit, new base) — if a sibling changed the same
1300
+ // lines differently, that's a genuine conflict, not something
1301
+ // reparenting-and-copying-verbatim can silently paper over. This
1302
+ // reuses the exact same detectConflicts() machinery merge() already
1303
+ // uses (see merge(), a few hundred lines down) rather than
1304
+ // duplicating three-way-merge logic a third time in this codebase.
1305
+ const oldParentId = change.parents[0];
1306
+ const oldParentChange = oldParentId ? await graph.getChange(oldParentId) : null;
1307
+ const baseFiles = new Map(
1308
+ Object.entries((oldParentChange && oldParentChange.fileSnapshot) || {})
1309
+ );
1310
+ const leftFiles = new Map(Object.entries(change.fileSnapshot || {}));
1311
+ const rightFiles = new Map(Object.entries(newParentChange.fileSnapshot || {}));
1312
+
1313
+ await conflicts.load();
1314
+ const conflictsSnapshot = {
1315
+ conflicts: Object.fromEntries(conflicts.conflicts),
1316
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
1317
+ };
1318
+
1319
+ const detectedConflicts = oldParentChange
1320
+ ? await conflicts.detectConflicts({
1321
+ baseFiles,
1322
+ leftFiles,
1323
+ rightFiles,
1324
+ drivers: {},
1325
+ // Not necessarily the checked-out working copy, so don't let
1326
+ // merge drivers write to disk on its behalf.
1327
+ workingCopyDir: /** @type {any} */ (null),
1328
+ baseChange: oldParentId,
1329
+ leftChange: changeId,
1330
+ rightChange: newParent,
1331
+ })
1332
+ : [];
1333
+
1334
+ for (const conflict of detectedConflicts) {
1335
+ await conflicts.addConflict(conflict);
1336
+ }
1337
+
1008
1338
  // Update parent
1009
1339
  change.parents = [newParent];
1010
1340
  await graph.updateChange(change);
@@ -1015,6 +1345,8 @@ export async function createJJ(options) {
1015
1345
  user: await getUserOplogInfo(),
1016
1346
  description: `move change ${changeId.slice(0, 8)} to ${newParent.slice(0, 8)}`,
1017
1347
  parents: [],
1348
+ changeSnapshot: { [changeId]: changeSnapshotBefore },
1349
+ conflictsSnapshot,
1018
1350
  view: {
1019
1351
  bookmarks: {},
1020
1352
  remoteBookmarks: {},
@@ -1023,7 +1355,7 @@ export async function createJJ(options) {
1023
1355
  },
1024
1356
  });
1025
1357
 
1026
- return change;
1358
+ return { ...change, conflicts: detectedConflicts };
1027
1359
  },
1028
1360
 
1029
1361
  /**
@@ -1208,7 +1540,7 @@ export async function createJJ(options) {
1208
1540
  break;
1209
1541
  }
1210
1542
 
1211
- const content = await fs.promises.readFile(fullPath, 'utf-8');
1543
+ const content = await fs.promises.readFile(fullPath, 'utf8');
1212
1544
  fileSnapshot[filePath] = content;
1213
1545
  totalSnapshotSize += stats.size;
1214
1546
  } catch (error) {
@@ -1406,6 +1738,8 @@ export async function createJJ(options) {
1406
1738
 
1407
1739
  // Snapshot filesystem BEFORE operation (for undo) unless preserveSnapshot is true
1408
1740
  let fileSnapshot;
1741
+ /** @type {Record<string, any>} */
1742
+ const changeSnapshot = {};
1409
1743
  if (!args.preserveSnapshot) {
1410
1744
  fileSnapshot = await snapshotFilesystem();
1411
1745
 
@@ -1414,6 +1748,10 @@ export async function createJJ(options) {
1414
1748
  const currentChangeId = workingCopy.getCurrentChangeId();
1415
1749
  const currentChange = await graph.getChange(currentChangeId);
1416
1750
  if (currentChange && fileSnapshot) {
1751
+ // Snapshot BEFORE mutating so undo()/redo() can revert this
1752
+ // in-place update too, not just the pointer/new-change creation
1753
+ // below — see issue #29.
1754
+ changeSnapshot[currentChangeId] = structuredClone(currentChange);
1417
1755
  currentChange.fileSnapshot = fileSnapshot;
1418
1756
  await graph.updateChange(currentChange);
1419
1757
  }
@@ -1441,6 +1779,7 @@ export async function createJJ(options) {
1441
1779
  const user = /** @type {any} */ (userConfig.getUser());
1442
1780
 
1443
1781
  // Initialize with parent's file snapshot
1782
+ /** @type {Record<string, string>} */
1444
1783
  let initialSnapshot = {};
1445
1784
  if (parents && parents.length > 0) {
1446
1785
  const parent = await graph.getChange(parents[0]);
@@ -1484,10 +1823,19 @@ export async function createJJ(options) {
1484
1823
  // Handle insertBefore: rebase target to have new change as parent
1485
1824
  if (args.insertBefore) {
1486
1825
  const targetChange = await graph.getChange(args.insertBefore);
1826
+ changeSnapshot[args.insertBefore] = structuredClone(targetChange);
1487
1827
  targetChange.parents = [newChangeId];
1488
1828
  await graph.updateChange(targetChange);
1489
1829
  }
1490
1830
 
1831
+ // Sync the working directory to the new change's snapshot: write
1832
+ // everything it has AND remove anything it doesn't (issue #30 — this
1833
+ // used to never touch disk at all, so `new({ parents: [...] })`
1834
+ // targeting anything other than the current head silently left the
1835
+ // OLD working copy's files in place instead of checking out the new
1836
+ // parent's).
1837
+ await syncWorkingCopyFiles(initialSnapshot);
1838
+
1491
1839
  await workingCopy.setCurrentChange(newChangeId);
1492
1840
 
1493
1841
  // Record operation with filesystem snapshot
@@ -1500,6 +1848,13 @@ export async function createJJ(options) {
1500
1848
  },
1501
1849
  description: `new change ${newChangeId.slice(0, 8)}`,
1502
1850
  parents: [],
1851
+ // See issue #29: lets undo()/redo()/operations.restore() reverse
1852
+ // the existing records this op updated in place (the old
1853
+ // working-copy change's refreshed fileSnapshot, and insertBefore's
1854
+ // target's reparented parents). The newly created change itself
1855
+ // (newChangeId) is intentionally NOT torn down on undo — see
1856
+ // computeGraphReversal's doc comment.
1857
+ changeSnapshot,
1503
1858
  view: {
1504
1859
  bookmarks: {},
1505
1860
  remoteBookmarks: {},
@@ -1880,6 +2235,13 @@ export async function createJJ(options) {
1880
2235
 
1881
2236
  const previousChangeId = workingCopy.getCurrentChangeId();
1882
2237
 
2238
+ /** @type {Record<string, any>} */
2239
+ const changeSnapshot = {};
2240
+
2241
+ // Capture disk state right before we touch anything, for undo() —
2242
+ // see issue #29.
2243
+ const fileSnapshotBeforeSwitch = await snapshotFilesystem();
2244
+
1883
2245
  // CRITICAL FIX: Clean working directory BEFORE snapshotting to prevent cross-branch pollution
1884
2246
  // Remove files that don't belong to the current changeId before we snapshot it
1885
2247
  if (previousChangeId !== args.changeId) {
@@ -1901,13 +2263,14 @@ export async function createJJ(options) {
1901
2263
  }
1902
2264
  }
1903
2265
  }
1904
- }
1905
2266
 
1906
- // Now snapshot the CLEAN working directory (no pollution!)
1907
- const currentSnapshot = await snapshotFilesystem();
1908
- if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
1909
- previousChange.fileSnapshot = currentSnapshot;
1910
- await graph.updateChange(previousChange);
2267
+ // Now snapshot the CLEAN working directory (no pollution!)
2268
+ const currentSnapshot = await snapshotFilesystem();
2269
+ if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
2270
+ changeSnapshot[previousChangeId] = structuredClone(previousChange);
2271
+ previousChange.fileSnapshot = currentSnapshot;
2272
+ await graph.updateChange(previousChange);
2273
+ }
1911
2274
  }
1912
2275
  }
1913
2276
 
@@ -1920,38 +2283,17 @@ export async function createJJ(options) {
1920
2283
  timestamp: new Date().toISOString(),
1921
2284
  });
1922
2285
 
1923
- // Restore files from the change's snapshot to working directory
1924
- if (change.fileSnapshot) {
1925
- for (const [filePath, content] of Object.entries(change.fileSnapshot)) {
1926
- try {
1927
- const fullPath = path.join(dir, filePath);
1928
-
1929
- // Ensure directory exists
1930
- const pathParts = filePath.split('/');
1931
- if (pathParts.length > 1) {
1932
- const dirPath = pathParts.slice(0, -1).join('/');
1933
- const fullDirPath = path.join(dir, dirPath);
1934
- await fs.promises.mkdir(fullDirPath, { recursive: true });
1935
- }
1936
-
1937
- // Write file content
1938
- await fs.promises.writeFile(fullPath, content, 'utf8');
1939
-
1940
- // Update working copy tracking
1941
- const stats = await fs.promises.stat(fullPath);
1942
- await workingCopy.trackFile(filePath, {
1943
- mtime: stats.mtime,
1944
- size: stats.size,
1945
- mode: stats.mode,
1946
- });
1947
- } catch (error) {
1948
- throw new JJError(
1949
- 'FILE_RESTORE_FAILED',
1950
- `Failed to restore file ${filePath}: ${error.message}`,
1951
- { filePath, originalError: error.message }
1952
- );
1953
- }
1954
- }
2286
+ // Fully sync the working directory to the target change's snapshot:
2287
+ // write everything it has AND remove anything it doesn't (issue #30
2288
+ // — this used to only overlay the target's files on top of whatever
2289
+ // was already on disk, so a file present in the PREVIOUS change but
2290
+ // absent from the target leaked into the next snapshot).
2291
+ try {
2292
+ await syncWorkingCopyFiles(change.fileSnapshot || {});
2293
+ } catch (error) {
2294
+ throw new JJError('FILE_RESTORE_FAILED', `Failed to restore files: ${error.message}`, {
2295
+ originalError: error.message,
2296
+ });
1955
2297
  }
1956
2298
 
1957
2299
  // Set this change as the working copy
@@ -1967,11 +2309,13 @@ export async function createJJ(options) {
1967
2309
  },
1968
2310
  description: `edit change ${args.changeId.slice(0, 8)}`,
1969
2311
  parents: [],
2312
+ changeSnapshot,
1970
2313
  view: {
1971
2314
  bookmarks: {},
1972
2315
  remoteBookmarks: {},
1973
2316
  heads: [args.changeId],
1974
2317
  workingCopy: args.changeId,
2318
+ fileSnapshot: fileSnapshotBeforeSwitch,
1975
2319
  },
1976
2320
  });
1977
2321
 
@@ -2002,106 +2346,107 @@ export async function createJJ(options) {
2002
2346
  /**
2003
2347
  * Undo last operation
2004
2348
  *
2349
+ * Reverts the most recent operation — including graph-rewriting ones
2350
+ * (squash/rebase/abandon/new/edit), not just working-copy/file state
2351
+ * (see issue #29). Matches real jj's progressive-undo semantics
2352
+ * (`jj-undo(1)`): called again with nothing else in between, it steps
2353
+ * one further operation into the past rather than undoing itself.
2354
+ *
2005
2355
  * @returns {Promise<Object>} Information about the undo including the undone operation and restored state
2006
2356
  */
2007
2357
  async undo() {
2008
- // Get the operation we're about to undo to access its pre-state
2009
2358
  await oplog.load();
2010
2359
  const ops = await oplog.list();
2011
2360
  if (ops.length === 0) {
2012
2361
  throw new JJError('NOTHING_TO_UNDO', 'No operations to undo');
2013
2362
  }
2014
2363
 
2015
- // Get the current operation (the one being undone) to restore its pre-state
2016
- const currentOp = ops[ops.length - 1];
2017
-
2018
- const previousView = /** @type {any} */ (await oplog.undo());
2364
+ const targetOp = resolveUndoTarget(ops);
2365
+ const landingOpId = targetOp.parents && targetOp.parents[0];
2366
+ const landingOp = landingOpId ? ops.find((op) => op.id === landingOpId) : null;
2019
2367
 
2020
- // Restore state from previous view
2021
- await workingCopy.setCurrentChange(previousView.workingCopy);
2022
-
2023
- // Restore ChangeGraph content mutated in place by the operation being
2024
- // undone (e.g. describe() editing change.description) — see issue #12.
2025
- // currentOp.changeSnapshot holds the affected change record(s) as they
2026
- // were BEFORE currentOp ran, captured the same way fileSnapshot is.
2368
+ /** @type {ReturnType<typeof computeGraphReversal>} */
2369
+ let reversal;
2370
+ /** @type {any} */
2371
+ let landingView;
2372
+ if (landingOp) {
2373
+ reversal = computeGraphReversal(ops, landingOp);
2374
+ landingView = landingOp.view || {};
2375
+ } else {
2376
+ // Undoing the very first (root) operation: there's nothing before
2377
+ // it to restore to, so land on its own recorded state rather than
2378
+ // throwing (mirrors the previous no-parent fallback).
2379
+ reversal = {
2380
+ changeSnapshotToApply: {},
2381
+ fileSnapshot: undefined,
2382
+ conflictsSnapshot: undefined,
2383
+ };
2384
+ landingView = targetOp.view || {};
2385
+ }
2386
+
2387
+ // Capture EXACTLY what's about to be overwritten, from ground truth,
2388
+ // before applying the reversal — this is what redo() re-applies
2389
+ // later. It can't just recompute "the state after targetOp" the way
2390
+ // undo() itself computes "the state after landingOp" (by scanning
2391
+ // for a LATER op that also touched the same changeId/files/
2392
+ // conflicts): that scan only works going backward, where "nothing
2393
+ // touched it since" means "it's already correct". Going forward,
2394
+ // "nothing touched it since targetOp" means the opposite — its
2395
+ // effect is still live and needs to be captured now, not rederived.
2027
2396
  await graph.load();
2028
- if (currentOp && currentOp.changeSnapshot) {
2029
- for (const snapshot of Object.values(
2030
- /** @type {Record<string, any>} */ (currentOp.changeSnapshot)
2031
- )) {
2032
- if (snapshot && (await graph.getChange(snapshot.changeId))) {
2033
- await graph.updateChange(snapshot);
2034
- }
2035
- }
2036
- }
2037
-
2038
- // Restore filesystem from previous operation's snapshot (taken BEFORE that operation)
2039
- // This is how JJ snapshots the working copy before every command
2040
- if (previousView.fileSnapshot) {
2041
- for (const [filePath, content] of Object.entries(previousView.fileSnapshot)) {
2042
- try {
2043
- const fullPath = path.join(dir, filePath);
2044
-
2045
- // Ensure directory exists
2046
- const pathParts = filePath.split('/');
2047
- if (pathParts.length > 1) {
2048
- const dirPath = pathParts.slice(0, -1).join('/');
2049
- const fullDirPath = path.join(dir, dirPath);
2050
- await fs.promises.mkdir(fullDirPath, { recursive: true });
2051
- }
2052
-
2053
- // Write file content
2054
- await fs.promises.writeFile(fullPath, content, 'utf8');
2055
- } catch (error) {
2056
- throw new JJError(
2057
- 'UNDO_FILE_RESTORE_FAILED',
2058
- `Failed to restore file ${filePath} during undo: ${error.message}`,
2059
- { filePath, originalError: error.message }
2060
- );
2061
- }
2062
- }
2397
+ /** @type {Record<string, any>} */
2398
+ const redoChangeSnapshot = {};
2399
+ for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
2400
+ const current = await graph.getChange(changeId);
2401
+ if (current) redoChangeSnapshot[changeId] = current;
2402
+ }
2403
+ const redoFileSnapshot =
2404
+ reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
2405
+ let redoConflictsSnapshot;
2406
+ if (reversal.conflictsSnapshot !== undefined) {
2407
+ await conflicts.load();
2408
+ redoConflictsSnapshot = {
2409
+ conflicts: Object.fromEntries(conflicts.conflicts),
2410
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
2411
+ };
2063
2412
  }
2064
2413
 
2065
- // Restore conflicts state from before the undone operation
2066
- // The conflictsSnapshot in an operation represents the state BEFORE that operation ran
2067
- await conflicts.load();
2068
- if (currentOp && currentOp.conflictsSnapshot) {
2069
- // Restore conflicts from the operation we're undoing (its pre-state)
2070
- conflicts.conflicts = new Map(Object.entries(currentOp.conflictsSnapshot.conflicts || {}));
2071
- conflicts.fileConflicts = new Map(
2072
- Object.entries(currentOp.conflictsSnapshot.fileConflicts || {})
2073
- );
2074
- await conflicts.save();
2075
- } else {
2076
- // No conflicts snapshot - clear conflicts
2077
- await conflicts.clear();
2414
+ if (landingView.workingCopy) {
2415
+ await workingCopy.setCurrentChange(landingView.workingCopy);
2078
2416
  }
2417
+ await applyGraphReversal(reversal);
2079
2418
 
2080
- // Record undo operation. Tag it with the view it undid so that redo()
2081
- // can re-apply it (jj's progressive undo/redo, added in jj v0.33).
2419
+ // Record undo as its own operation (matching real jj) — tagged with
2420
+ // which operation it targeted so a following undo() can walk one
2421
+ // step further back (resolveUndoTarget) instead of toggling, and
2422
+ // carrying exactly what redo() needs to re-apply (captured above).
2082
2423
  await oplog.recordOperation({
2083
2424
  timestamp: new Date().toISOString(),
2084
2425
  user: await getUserOplogInfo(),
2085
2426
  description: 'undo operation',
2086
2427
  eventType: 'undo',
2087
- undoneOpId: currentOp.id,
2088
- undoneView: currentOp.view,
2089
- undoneConflictsSnapshot: currentOp.conflictsSnapshot || null,
2428
+ targetOpId: targetOp.id,
2429
+ undoneOpId: targetOp.id, // kept for API/type compatibility
2430
+ redoChangeSnapshot,
2431
+ redoFileSnapshot,
2432
+ redoConflictsSnapshot,
2090
2433
  parents: [],
2091
- view: previousView,
2434
+ view: landingView,
2092
2435
  });
2093
2436
 
2094
- // Return information about what was undone
2437
+ const restoredFileCount =
2438
+ reversal.fileSnapshot !== undefined ? Object.keys(reversal.fileSnapshot).length : 0;
2439
+
2095
2440
  return {
2096
2441
  undoneOperation: {
2097
- description: currentOp.description,
2098
- timestamp: currentOp.timestamp,
2099
- user: currentOp.user,
2442
+ description: targetOp.description,
2443
+ timestamp: targetOp.timestamp,
2444
+ user: targetOp.user,
2100
2445
  },
2101
2446
  restoredState: {
2102
- workingCopy: previousView.workingCopy,
2103
- heads: previousView.heads,
2104
- fileCount: previousView.fileSnapshot ? Object.keys(previousView.fileSnapshot).length : 0,
2447
+ workingCopy: landingView.workingCopy,
2448
+ heads: landingView.heads,
2449
+ fileCount: restoredFileCount,
2105
2450
  },
2106
2451
  };
2107
2452
  },
@@ -2109,9 +2454,9 @@ export async function createJJ(options) {
2109
2454
  /**
2110
2455
  * Redo an operation previously undone with undo() (v1.5)
2111
2456
  *
2112
- * Mirrors `jj redo` (jj v0.33): progressively re-applies the operations that
2113
- * `undo()` reverted, most-recent-undo first. Each redo consumes exactly one
2114
- * pending undo.
2457
+ * Mirrors `jj redo` (jj v0.33): re-applies the operation that the most
2458
+ * recent not-yet-redone undo() reverted — including its graph rewrites,
2459
+ * not just working-copy/file state (see issue #29).
2115
2460
  *
2116
2461
  * @returns {Promise<Object>} Information about the redone operation
2117
2462
  */
@@ -2142,42 +2487,36 @@ export async function createJJ(options) {
2142
2487
  });
2143
2488
  }
2144
2489
 
2145
- const redoneView = undoOp.undoneView || {};
2490
+ const targetOpId = undoOp.targetOpId || undoOp.undoneOpId;
2491
+ const targetOp = targetOpId && ops.find((op) => op.id === targetOpId);
2492
+ const redoneView = (targetOp && targetOp.view) || {};
2146
2493
 
2147
- // Restore the working-copy pointer.
2148
2494
  if (redoneView.workingCopy) {
2149
2495
  await workingCopy.setCurrentChange(redoneView.workingCopy);
2150
2496
  }
2151
2497
 
2152
- // Restore the working-copy files captured in the undone view.
2153
- if (redoneView.fileSnapshot) {
2154
- for (const [filePath, content] of Object.entries(redoneView.fileSnapshot)) {
2155
- try {
2156
- const fullPath = path.join(dir, filePath);
2157
- const pathParts = filePath.split('/');
2158
- if (pathParts.length > 1) {
2159
- const fullDirPath = path.join(dir, pathParts.slice(0, -1).join('/'));
2160
- await fs.promises.mkdir(fullDirPath, { recursive: true });
2161
- }
2162
- await fs.promises.writeFile(fullPath, content, 'utf8');
2163
- } catch (error) {
2164
- throw new JJError(
2165
- 'REDO_FILE_RESTORE_FAILED',
2166
- `Failed to restore file ${filePath} during redo: ${error.message}`,
2167
- { filePath, originalError: error.message }
2168
- );
2169
- }
2498
+ // Re-apply exactly what this undo captured right before it reverted
2499
+ // things — NOT computeGraphReversal(ops, targetOp): that scans for a
2500
+ // LATER op touching the same changeId/files/conflicts, which is the
2501
+ // right question for undo() ("has anything since made my target
2502
+ // stale?") but the wrong one here — after an undo, NOTHING later
2503
+ // re-established targetOp's own effect, so there's nothing to find
2504
+ // by scanning forward. undo() captured it directly for exactly this
2505
+ // reason (see its own comment).
2506
+ await graph.load();
2507
+ for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
2508
+ if (await graph.getChange(changeId)) {
2509
+ await graph.updateChange(snapshot);
2170
2510
  }
2171
2511
  }
2172
-
2173
- // Restore conflicts to the state that existed after the redone operation.
2512
+ if (undoOp.redoFileSnapshot !== undefined) {
2513
+ await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
2514
+ }
2174
2515
  await conflicts.load();
2175
- if (undoOp.undoneConflictsSnapshot) {
2176
- conflicts.conflicts = new Map(
2177
- Object.entries(undoOp.undoneConflictsSnapshot.conflicts || {})
2178
- );
2516
+ if (undoOp.redoConflictsSnapshot !== undefined) {
2517
+ conflicts.conflicts = new Map(Object.entries(undoOp.redoConflictsSnapshot.conflicts || {}));
2179
2518
  conflicts.fileConflicts = new Map(
2180
- Object.entries(undoOp.undoneConflictsSnapshot.fileConflicts || {})
2519
+ Object.entries(undoOp.redoConflictsSnapshot.fileConflicts || {})
2181
2520
  );
2182
2521
  await conflicts.save();
2183
2522
  }
@@ -2192,15 +2531,18 @@ export async function createJJ(options) {
2192
2531
  view: redoneView,
2193
2532
  });
2194
2533
 
2534
+ const restoredFileCount =
2535
+ undoOp.redoFileSnapshot !== undefined ? Object.keys(undoOp.redoFileSnapshot).length : 0;
2536
+
2195
2537
  return {
2196
2538
  redoneOperation: {
2197
2539
  description: undoOp.description,
2198
- undoneOpId: undoOp.undoneOpId,
2540
+ undoneOpId: targetOpId,
2199
2541
  },
2200
2542
  restoredState: {
2201
2543
  workingCopy: redoneView.workingCopy,
2202
2544
  heads: redoneView.heads,
2203
- fileCount: redoneView.fileSnapshot ? Object.keys(redoneView.fileSnapshot).length : 0,
2545
+ fileCount: restoredFileCount,
2204
2546
  },
2205
2547
  };
2206
2548
  },
@@ -2427,6 +2769,12 @@ export async function createJJ(options) {
2427
2769
  /**
2428
2770
  * Restore repository to a specific operation (matches `jj operation restore`)
2429
2771
  *
2772
+ * Fully reverts everything recorded after the target operation —
2773
+ * including graph rewrites (squash/rebase/abandon/new/edit), working
2774
+ * copy files, and conflict state, not just bookmarks/the working-copy
2775
+ * pointer (see issue #29) — using the same reversal primitive as
2776
+ * undo()/redo().
2777
+ *
2430
2778
  * @param {Record<string, any>} args - Arguments
2431
2779
  * @returns {Promise<Object>} Restore result
2432
2780
  */
@@ -2466,6 +2814,11 @@ export async function createJJ(options) {
2466
2814
  await workingCopy.setCurrentChange(targetOp.view.workingCopy);
2467
2815
  }
2468
2816
 
2817
+ // Revert the change graph, working-copy files, and conflict state
2818
+ // to exactly what they were right after targetOp ran.
2819
+ const reversal = computeGraphReversal(ops, targetOp);
2820
+ await applyGraphReversal(reversal);
2821
+
2469
2822
  // Record this restoration as a new operation
2470
2823
  await oplog.recordOperation({
2471
2824
  timestamp: new Date().toISOString(),
@@ -2757,10 +3110,53 @@ export async function createJJ(options) {
2757
3110
  timestamp: new Date().toISOString(),
2758
3111
  });
2759
3112
 
3113
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
3114
+ // reverse the squash (un-abandon source, restore dest's description
3115
+ // and content) — see issue #29.
3116
+ /** @type {Record<string, any>} */
3117
+ const changeSnapshot = {
3118
+ [source]: structuredClone(sourceChange),
3119
+ [destChangeId]: structuredClone(destChange),
3120
+ };
3121
+
2760
3122
  // Mark source as abandoned
2761
3123
  sourceChange.abandoned = true;
2762
3124
  await graph.updateChange(sourceChange);
2763
3125
 
3126
+ // Merge source's file content into dest (issue #30: squash() used to
3127
+ // only combine descriptions, leaving dest's tree untouched). Compute
3128
+ // what source itself actually changed relative to ITS OWN parent
3129
+ // (its "diff") and fold just that onto dest, rather than overwriting
3130
+ // dest's whole snapshot with source's — a path source never touched
3131
+ // (inherited unchanged from that parent) must not clobber a
3132
+ // dest-specific version of the same path.
3133
+ const sourceParentId = sourceChange.parents && sourceChange.parents[0];
3134
+ const sourceParentChange = sourceParentId ? await graph.getChange(sourceParentId) : null;
3135
+ const sourceBaseSnapshot = (sourceParentChange && sourceParentChange.fileSnapshot) || {};
3136
+ const sourceSnapshot = sourceChange.fileSnapshot || {};
3137
+ const mergedSnapshot = { ...(destChange.fileSnapshot || {}) };
3138
+ const touchedPaths = new Set([
3139
+ ...Object.keys(sourceBaseSnapshot),
3140
+ ...Object.keys(sourceSnapshot),
3141
+ ]);
3142
+ for (const filePath of touchedPaths) {
3143
+ const baseValue = sourceBaseSnapshot[filePath];
3144
+ const sourceValue = sourceSnapshot[filePath];
3145
+ if (sourceValue === baseValue) {
3146
+ // source never touched this path (inherited as-is from its own
3147
+ // parent) — leave whatever dest already had for it alone.
3148
+ continue;
3149
+ }
3150
+ if (sourceValue === undefined) {
3151
+ // source deleted a file it inherited from its parent.
3152
+ delete mergedSnapshot[filePath];
3153
+ } else {
3154
+ // source added or modified this path.
3155
+ mergedSnapshot[filePath] = sourceValue;
3156
+ }
3157
+ }
3158
+ destChange.fileSnapshot = mergedSnapshot;
3159
+
2764
3160
  // Update description of dest to indicate squash (middleware will sync to Git)
2765
3161
  destChange.description += `\n\n(squashed from ${source.slice(0, 8)})`;
2766
3162
  await graph.updateChange(destChange);
@@ -2802,6 +3198,7 @@ export async function createJJ(options) {
2802
3198
  user: await getUserOplogInfo(),
2803
3199
  description: `squash ${source.slice(0, 8)} into ${destChangeId.slice(0, 8)}`,
2804
3200
  parents: [],
3201
+ changeSnapshot,
2805
3202
  view: {
2806
3203
  bookmarks: {},
2807
3204
  remoteBookmarks: {},
@@ -2880,15 +3277,29 @@ export async function createJJ(options) {
2880
3277
  timestamp: new Date().toISOString(),
2881
3278
  });
2882
3279
 
3280
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
3281
+ // reverse the abandon (un-abandon this change, un-reparent children)
3282
+ // — see issue #29.
3283
+ /** @type {Record<string, any>} */
3284
+ const changeSnapshot = { [changeId]: structuredClone(change) };
3285
+
2883
3286
  change.abandoned = true;
2884
3287
  await graph.updateChange(change);
2885
3288
 
3289
+ // Re-parent children onto the abandoned change's own parent(s)
3290
+ // (issue #30: this used to leave children pointing at the now-
3291
+ // abandoned commit). Matches real `jj abandon`: the abandoned
3292
+ // change is elided from the graph and its children attach directly
3293
+ // to whatever it was attached to.
3294
+ await reparentChildrenOnto(changeId, change.parents, changeSnapshot);
3295
+
2886
3296
  // Record operation
2887
3297
  await oplog.recordOperation({
2888
3298
  timestamp: new Date().toISOString(),
2889
3299
  user: { name: user.name, email: user.email, hostname: 'localhost' },
2890
3300
  description: `abandon change ${changeId.slice(0, 8)}`,
2891
3301
  parents: [],
3302
+ changeSnapshot,
2892
3303
  view: {
2893
3304
  bookmarks: {},
2894
3305
  remoteBookmarks: {},
@@ -2913,6 +3324,206 @@ export async function createJJ(options) {
2913
3324
  return change;
2914
3325
  },
2915
3326
 
3327
+ /**
3328
+ * Converge divergent copies of a change (matches `jj converge`, jj
3329
+ * v0.45.0+ — issue #32, extended for N-way per the isomorphic-jj v0.45
3330
+ * catch-up follow-up) — resolves ANY number of divergent copies of one
3331
+ * changeId in a single call. Real jj's own CLI currently only
3332
+ * converges one change-id per invocation (an explicit TODO in its own
3333
+ * source as of v0.45.1: "consider adding logic to deal with more than
3334
+ * one divergent change-id in one invocation"), which is what fixed
3335
+ * this package's own changeId-at-a-time API design — but its
3336
+ * underlying engine (`TruncatedEvolutionGraph`/`find_divergent_changes`)
3337
+ * already resolves every divergent revision of THAT one change-id
3338
+ * together, not just a pair. This generalizes the same per-path rule
3339
+ * accordingly: two-way "does the other side's value differ from base"
3340
+ * becomes N-way "how many DISTINCT values, across every copy, differ
3341
+ * from base" — 0 means nobody touched it, 1 means exactly one copy (or
3342
+ * several copies agreeing on the same new value) changed it and that
3343
+ * value wins, 2+ is a genuine conflict between that many copies.
3344
+ *
3345
+ * A change is divergent when more than one visible commit shares its
3346
+ * change id (see the `divergent()` revset, and
3347
+ * ChangeGraph.addDivergentCopy() for how a divergent copy gets added
3348
+ * in the first place — nothing in this library creates one on its
3349
+ * own yet; this resolves one once something has, e.g. a future git
3350
+ * fetch/concurrent-operation integration).
3351
+ *
3352
+ * Non-interactive-mode convention, matching this package's other
3353
+ * ambiguous-outcome APIs: like merge(), a genuine per-path conflict is
3354
+ * returned as data (`{ resolved: false, conflicts }`), not thrown —
3355
+ * you get a result back either way and decide what to do with
3356
+ * unresolved conflicts. What DOES throw is anything that isn't even a
3357
+ * well-formed convergence attempt (no divergence to resolve at all) —
3358
+ * the same category as merge()'s own `MERGE_ERROR` throw for "no
3359
+ * common ancestor found".
3360
+ *
3361
+ * @param {Record<string, any>|string} args - `{ changeId }`, or a bare changeId string
3362
+ * @returns {Promise<Object>} `{ changeId, resolved, conflicts, commitId? }`
3363
+ */
3364
+ async converge(args) {
3365
+ const changeId = typeof args === 'string' ? args : args && (args.changeId || args.change);
3366
+ if (!changeId || typeof changeId !== 'string') {
3367
+ throw new JJError('INVALID_ARGUMENT', 'Missing changeId argument', {
3368
+ suggestion: 'Provide the divergent changeId to converge: { changeId: "..." }',
3369
+ });
3370
+ }
3371
+
3372
+ await graph.load();
3373
+ await userConfig.load();
3374
+ const siblings = graph.getDivergentSiblings(changeId);
3375
+
3376
+ if (siblings.length < 2) {
3377
+ throw new JJError(
3378
+ 'NOT_DIVERGENT',
3379
+ `Change ${changeId} has no divergent copies to converge`,
3380
+ {
3381
+ changeId,
3382
+ suggestion:
3383
+ 'converge() is only meaningful for a changeId the divergent() revset reports',
3384
+ }
3385
+ );
3386
+ }
3387
+
3388
+ // The primary copy is whichever one getChange() resolves (stored
3389
+ // under the plain changeId key); every other one is a divergent copy
3390
+ // added via addDivergentCopy(). No cap on how many — see the doc
3391
+ // comment above.
3392
+ const primary = /** @type {any} */ (await graph.getChange(changeId));
3393
+ const others = siblings.filter((/** @type {any} */ s) => s.commitId !== primary.commitId);
3394
+
3395
+ // Base for the N-way merge: what this change looked like before it
3396
+ // diverged. This package doesn't retain historical snapshots (only
3397
+ // current content), so — same pragmatic choice rebase() makes for
3398
+ // "old base" — use the shared parent's current content; divergent
3399
+ // copies realistically share the same parent, since they're all
3400
+ // rewrites of one prior position in history, not unrelated commits
3401
+ // that happen to collide. Applied uniformly to every copy, not
3402
+ // per-copy — the same simplification the original two-way version
3403
+ // made, just not newly introduced by going N-way.
3404
+ const baseParentId = primary.parents && primary.parents[0];
3405
+ const baseParentChange = baseParentId ? await graph.getChange(baseParentId) : null;
3406
+ const baseFiles = new Map(
3407
+ Object.entries((baseParentChange && baseParentChange.fileSnapshot) || {})
3408
+ );
3409
+
3410
+ // Every copy (primary + others), each as its own file map, paired
3411
+ // with its commitId for conflict reporting.
3412
+ const copies = [primary, ...others].map((/** @type {any} */ c) => ({
3413
+ commitId: c.commitId,
3414
+ files: new Map(Object.entries(c.fileSnapshot || {})),
3415
+ }));
3416
+
3417
+ await conflicts.load();
3418
+ const conflictsSnapshot = {
3419
+ conflicts: Object.fromEntries(conflicts.conflicts),
3420
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
3421
+ };
3422
+
3423
+ const allPaths = new Set([
3424
+ ...baseFiles.keys(),
3425
+ ...copies.flatMap((c) => [...c.files.keys()]),
3426
+ ]);
3427
+
3428
+ /** @type {Record<string, string>} */
3429
+ const mergedSnapshot = {};
3430
+ /** @type {any[]} */
3431
+ const detectedConflicts = [];
3432
+
3433
+ for (const filePath of allPaths) {
3434
+ const baseValue = baseFiles.get(filePath);
3435
+ // Every DISTINCT value across all copies that differs from base,
3436
+ // each paired with (one of) the commitId(s) that has it — mirrors
3437
+ // ConflictModel._detectPathConflict's two-way "no conflict" rules,
3438
+ // generalized: 0 distinct changed values means nobody touched it
3439
+ // (base wins, i.e. nothing to write), 1 means only one side (or
3440
+ // several copies agreeing) changed it (that value wins), 2+ is a
3441
+ // genuine conflict.
3442
+ const changed = new Map();
3443
+ for (const copy of copies) {
3444
+ const value = copy.files.get(filePath);
3445
+ if (value !== baseValue && !changed.has(value)) {
3446
+ changed.set(value, copy.commitId);
3447
+ }
3448
+ }
3449
+
3450
+ if (changed.size === 0) {
3451
+ if (baseValue !== undefined) mergedSnapshot[filePath] = baseValue;
3452
+ } else if (changed.size === 1) {
3453
+ const [[value]] = changed;
3454
+ if (value !== undefined) mergedSnapshot[filePath] = value;
3455
+ } else {
3456
+ const versions = [...changed.entries()].map(([content, commitId]) => ({
3457
+ commitId,
3458
+ content,
3459
+ }));
3460
+ detectedConflicts.push(conflicts.createNWayConflict(filePath, baseValue, versions));
3461
+ }
3462
+ }
3463
+
3464
+ if (detectedConflicts.length > 0) {
3465
+ // Matches real jj: non-interactive mode aborts automatic
3466
+ // resolution rather than guessing — but per this package's own
3467
+ // merge() convention, that's reported back as data, not thrown.
3468
+ for (const conflict of detectedConflicts) {
3469
+ await conflicts.addConflict(conflict);
3470
+ }
3471
+ await oplog.recordOperation({
3472
+ timestamp: new Date().toISOString(),
3473
+ user: await getUserOplogInfo(),
3474
+ description: `converge ${changeId.slice(0, 8)} (unresolved)`,
3475
+ parents: [],
3476
+ conflictsSnapshot,
3477
+ view: {
3478
+ bookmarks: {},
3479
+ remoteBookmarks: {},
3480
+ heads: [],
3481
+ workingCopy: workingCopy.getCurrentChangeId(),
3482
+ },
3483
+ });
3484
+ return { changeId, resolved: false, conflicts: detectedConflicts };
3485
+ }
3486
+
3487
+ const user = /** @type {any} */ (userConfig.getUser());
3488
+ const changeSnapshot = { [changeId]: structuredClone(primary) };
3489
+ const otherIds = others.map((/** @type {any} */ o) => o.commitId.slice(0, 8)).join(', ');
3490
+
3491
+ const convergedChange = {
3492
+ ...primary,
3493
+ commitId: randomHex(20),
3494
+ fileSnapshot: mergedSnapshot,
3495
+ description: primary.description + `\n\n(converged divergent copies: ${otherIds})`,
3496
+ timestamp: new Date().toISOString(),
3497
+ committer: {
3498
+ name: user.name,
3499
+ email: user.email,
3500
+ timestamp: new Date().toISOString(),
3501
+ },
3502
+ divergent: false,
3503
+ };
3504
+ await graph.updateChange(convergedChange);
3505
+ for (const other of others) {
3506
+ await graph.deleteDivergentCopy(changeId, other.commitId);
3507
+ }
3508
+
3509
+ await oplog.recordOperation({
3510
+ timestamp: new Date().toISOString(),
3511
+ user: await getUserOplogInfo(),
3512
+ description: `converge ${changeId.slice(0, 8)}`,
3513
+ parents: [],
3514
+ changeSnapshot,
3515
+ conflictsSnapshot,
3516
+ view: {
3517
+ bookmarks: {},
3518
+ remoteBookmarks: {},
3519
+ heads: [changeId],
3520
+ workingCopy: workingCopy.getCurrentChangeId(),
3521
+ },
3522
+ });
3523
+
3524
+ return { changeId, resolved: true, conflicts: [], commitId: convergedChange.commitId };
3525
+ },
3526
+
2916
3527
  /**
2917
3528
  * Backout a change - create a new change that reverses the effect of the specified change
2918
3529
  *
@@ -4752,8 +5363,14 @@ export async function createJJ(options) {
4752
5363
  * @returns {Promise<Object>} Repository root information
4753
5364
  */
4754
5365
  async root() {
4755
- // Return the directory containing .git
4756
- const gitDir = path.join(dir, '.git');
5366
+ // `dir` is a real host filesystem path (unlike the repo-relative
5367
+ // git/jj paths `path` — posix-path.js — is for), so it needs the
5368
+ // host's own separator here, not always '/': on Windows Node's real
5369
+ // `fs` still resolves a forward-slash-joined path fine, but this
5370
+ // method hands the string back to the caller as data, and jj's own
5371
+ // `git root` returns a platform-native path there.
5372
+ const isWin32 = typeof process !== 'undefined' && process.platform === 'win32';
5373
+ const gitDir = isWin32 ? `${dir.replace(/[\\/]+$/, '')}\\.git` : path.join(dir, '.git');
4757
5374
 
4758
5375
  try {
4759
5376
  await fs.promises.access(gitDir);
@@ -4832,7 +5449,7 @@ export async function createJJ(options) {
4832
5449
  const wcFiles = await workingCopy.listFiles();
4833
5450
  for (const file of wcFiles) {
4834
5451
  try {
4835
- const content = await fs.promises.readFile(path.join(dir, file), 'utf-8');
5452
+ const content = await fs.promises.readFile(path.join(dir, file), 'utf8');
4836
5453
  leftFiles.set(file, content);
4837
5454
  } catch (error) {
4838
5455
  // File might be deleted or binary - throw error for explicit handling
@@ -4975,7 +5592,7 @@ export async function createJJ(options) {
4975
5592
  }
4976
5593
 
4977
5594
  // Write resolved content to file
4978
- await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf-8');
5595
+ await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf8');
4979
5596
 
4980
5597
  // Mark conflict as resolved
4981
5598
  await conflicts.resolveConflict(args.conflictId, 'manual');
@@ -5018,7 +5635,7 @@ export async function createJJ(options) {
5018
5635
  const resolvedContent = _resolveWithStrategy(conflict, args.strategy);
5019
5636
 
5020
5637
  // Write resolved content
5021
- await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf-8');
5638
+ await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf8');
5022
5639
 
5023
5640
  // Mark as resolved
5024
5641
  await conflicts.resolveConflict(conflict.conflictId, args.strategy);
@@ -5458,7 +6075,7 @@ export async function createJJ(options) {
5458
6075
  if (pathParts.length > 1) {
5459
6076
  const dirPath = pathParts.slice(0, -1).join('/');
5460
6077
  const fullDirPath = path.join(args.path, dirPath);
5461
- await fs.promises.mkdir(fullDirPath, { recursive: true });
6078
+ await mkdirp(fs, fullDirPath);
5462
6079
  }
5463
6080
 
5464
6081
  // Write file content