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.
@@ -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,266 @@ 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
+ // Bypass the git-sync middleware (baseGraph, not the wrapped
286
+ // `graph`): undo/restore are pure reverts, and `snapshot` already
287
+ // carries the change's ORIGINAL commitId from before the reverted
288
+ // operation(s) ran. Going through graph.updateChange() would fire
289
+ // onUpdateChange -> syncChangeToGit(), which mints a brand-new
290
+ // commit (and thus a new commitId) from the CURRENT tree instead
291
+ // of restoring the one that was actually there — see issue #37(c):
292
+ // "undo/restore give reverted commits new commit IDs". Writing the
293
+ // snapshot verbatim via baseGraph puts the original commitId back.
294
+ await baseGraph.updateChange(snapshot);
295
+ }
296
+ }
297
+
298
+ if (reversal.fileSnapshot !== undefined) {
299
+ await syncWorkingCopyFiles(reversal.fileSnapshot);
300
+ }
301
+
302
+ await conflicts.load();
303
+ if (reversal.conflictsSnapshot !== undefined) {
304
+ conflicts.conflicts = new Map(Object.entries(reversal.conflictsSnapshot.conflicts || {}));
305
+ conflicts.fileConflicts = new Map(
306
+ Object.entries(reversal.conflictsSnapshot.fileConflicts || {})
307
+ );
308
+ await conflicts.save();
309
+ }
310
+ // Else: no operation in the reverted range touched conflicts — leave
311
+ // current conflict state alone. (Previously undo() unconditionally
312
+ // cleared ALL conflicts whenever the op being undone hadn't recorded
313
+ // its own conflictsSnapshot, which is nearly every non-merge() op —
314
+ // undoing an unrelated describe() would silently wipe conflicts from
315
+ // an earlier, unrelated merge(). Fixed as part of issue #29.)
316
+ };
317
+
318
+ /**
319
+ * Re-parent every child of `changeId` onto `newParents` instead — used
320
+ * when a change is being elided from the graph (abandon(), issue #30;
321
+ * converge(), issue #32) so its children don't end up pointing at
322
+ * something no longer meant to be there. Mutates `changeSnapshot` in
323
+ * place, adding each touched child's PRE-reparent record so the caller's
324
+ * own oplog entry lets undo()/redo()/operations.restore() reverse this
325
+ * too (the same mechanism the caller already uses for the elided
326
+ * change's own snapshot).
327
+ *
328
+ * @param {string} changeId - The change being elided
329
+ * @param {string[]} newParents - What its children should point at instead
330
+ * @param {Record<string, any>} changeSnapshot - Accumulator to add each touched child's pre-state to
331
+ * @returns {Promise<void>}
332
+ */
333
+ const reparentChildrenOnto = async (changeId, newParents, changeSnapshot) => {
334
+ const childIds = graph.getChildren(changeId);
335
+ for (const childId of childIds) {
336
+ const child = await graph.getChange(childId);
337
+ if (!child) continue;
338
+ changeSnapshot[childId] = structuredClone(child);
339
+ const reparented = child.parents.flatMap((/** @type {string} */ p) =>
340
+ p === changeId ? newParents : [p]
341
+ );
342
+ // De-dupe (e.g. the child could already have one of the new parents
343
+ // as another of its own parents) while preserving first-seen order.
344
+ child.parents = [...new Set(reparented)];
345
+ await graph.updateChange(child);
346
+ }
347
+ };
348
+
349
+ /**
350
+ * Determine which operation a fresh undo() call should revert, matching
351
+ * real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
352
+ * a normal operation, this will undo that last operation by restoring its
353
+ * parent. If jj undo is used repeatedly, it will restore increasingly
354
+ * older operations, going further back into the past.").
355
+ *
356
+ * The raw op-log parent chain can't answer this directly once undo/redo
357
+ * bookkeeping operations are mixed into the log: an 'undo' operation's
358
+ * own parent is the operation it just undid, so naively targeting
359
+ * `ops[ops.length - 1]` on a SECOND undo() would undo the undo (toggle
360
+ * back to the future) instead of stepping one further step into the past
361
+ * — this was issue #29's "undo() twice only undoes the undo" bug.
362
+ *
363
+ * @param {Array<any>} ops - Full chronological operation list
364
+ * @returns {any} The operation object undo() should revert
365
+ */
366
+ const resolveUndoTarget = (ops) => {
367
+ const head = ops[ops.length - 1];
368
+ const findOp = (/** @type {any} */ id) => ops.find((op) => op.id === id);
369
+
370
+ if (head.eventType === 'undo') {
371
+ // We're stepping further into the past: what should be undone now is
372
+ // the operation right BEFORE the one `head` already undid — not
373
+ // head's own parent, which IS the operation head undid (targeting it
374
+ // again would just toggle back to the present).
375
+ const previouslyUndone = head.targetOpId && findOp(head.targetOpId);
376
+ const stepBack = previouslyUndone && findOp(previouslyUndone.parents[0]);
377
+ return stepBack || previouslyUndone || head;
378
+ }
379
+
380
+ if (head.eventType === 'redo') {
381
+ // A redo re-established some earlier operation as current; a fresh
382
+ // undo right after it should behave exactly as if that operation
383
+ // were freshly current and had never been touched by undo/redo at
384
+ // all — i.e. undo it again (real jj: redo "moves in the direction of
385
+ // the future"; undoing right after simply resumes stepping back).
386
+ const redoneUndo = head.redoOf && findOp(head.redoOf);
387
+ const target = redoneUndo && redoneUndo.targetOpId && findOp(redoneUndo.targetOpId);
388
+ return target || head;
389
+ }
390
+
391
+ return head;
392
+ };
393
+
133
394
  /**
134
395
  * Automatic working-copy snapshot (v1.6).
135
396
  *
@@ -314,6 +575,25 @@ export async function createJJ(options) {
314
575
  }
315
576
  },
316
577
 
578
+ // Used by undo()/redo()/operations.restore() to reverse an
579
+ // addChange() when winding the graph back — see issue #29. No Git
580
+ // sync hook: a change created after the operation being reverted was
581
+ // never pushed anywhere to unwind, and the Git backend has no
582
+ // "forget this commit" operation (nor should it — Git objects are
583
+ // content-addressed and harmless to leave orphaned).
584
+ deleteChange: (/** @type {any} */ changeId) => baseGraph.deleteChange(changeId),
585
+
586
+ // See issue #32 / converge(): a divergent copy is another commit
587
+ // claiming an existing changeId; no Git sync hook, for the same
588
+ // reason addChange()'s primary copy already goes through
589
+ // onAddChange() when it's first created — the divergent copy needs
590
+ // no additional sync of its own.
591
+ addDivergentCopy: (/** @type {any} */ change) => baseGraph.addDivergentCopy(change),
592
+ getDivergentSiblings: (/** @type {any} */ changeId) =>
593
+ baseGraph.getDivergentSiblings(changeId),
594
+ deleteDivergentCopy: (/** @type {any} */ changeId, /** @type {any} */ commitId) =>
595
+ baseGraph.deleteDivergentCopy(changeId, commitId),
596
+
317
597
  // Delegate other operations
318
598
  init: () => baseGraph.init(),
319
599
  };
@@ -471,6 +751,20 @@ export async function createJJ(options) {
471
751
  /**
472
752
  * Write a file to the working copy
473
753
  *
754
+ * Note (issue #30): this does NOT update the working-copy change's
755
+ * `commitId`/`fileSnapshot` — those stay as they were until the next
756
+ * command that snapshots the working copy (describe(), new(), edit(),
757
+ * status(), or any other call that goes through
758
+ * autoSnapshotWorkingCopy()/snapshot()). This is intentional, not a
759
+ * bug: jj has no staging area, but it also doesn't recompute a git
760
+ * tree/commit hash on every single file write (that would mean
761
+ * hashing on every keystroke of an external editor) — real jj only
762
+ * takes a fresh working-copy snapshot when an actual `jj` command
763
+ * runs. write() is the equivalent of editing a file with your OS's own
764
+ * tools outside of jj entirely; describe()/new()/edit()/status()/etc.
765
+ * are the equivalent of running a jj command, which is what triggers
766
+ * the (auto) snapshot in both real jj and here.
767
+ *
474
768
  * @param {Record<string, any>} args - Arguments
475
769
  * @returns {Promise<Object>} File information including path, size, and mode
476
770
  */
@@ -494,7 +788,7 @@ export async function createJJ(options) {
494
788
  if (pathParts.length > 1) {
495
789
  const dirPath = pathParts.slice(0, -1).join('/');
496
790
  const fullDirPath = `${dir}/${dirPath}`;
497
- await fs.promises.mkdir(fullDirPath, { recursive: true });
791
+ await mkdirp(fs, fullDirPath);
498
792
  }
499
793
 
500
794
  // Write the file
@@ -552,7 +846,7 @@ export async function createJJ(options) {
552
846
  const fullPath = path.join(dir, args.path);
553
847
  try {
554
848
  if (encoding === 'utf-8' || encoding === 'utf8') {
555
- return await fs.promises.readFile(fullPath, 'utf-8');
849
+ return await fs.promises.readFile(fullPath, 'utf8');
556
850
  } else {
557
851
  return await fs.promises.readFile(fullPath);
558
852
  }
@@ -774,7 +1068,7 @@ export async function createJJ(options) {
774
1068
  if (pathParts.length > 1) {
775
1069
  const dirPath = pathParts.slice(0, -1).join('/');
776
1070
  const fullDirPath = path.join(dir, dirPath);
777
- await fs.promises.mkdir(fullDirPath, { recursive: true });
1071
+ await mkdirp(fs, fullDirPath);
778
1072
  }
779
1073
 
780
1074
  // Create writable stream
@@ -887,7 +1181,7 @@ export async function createJJ(options) {
887
1181
  // Ensure destination directory exists
888
1182
  const toDir = toPath.substring(0, toPath.lastIndexOf('/'));
889
1183
  try {
890
- await fs.promises.mkdir(toDir, { recursive: true });
1184
+ await mkdirp(fs, toDir);
891
1185
  } catch (error) {
892
1186
  throw new JJError(
893
1187
  'FILE_SYSTEM_ERROR',
@@ -1005,6 +1299,51 @@ export async function createJJ(options) {
1005
1299
  // TODO: Prevent creating cycles in the change graph
1006
1300
  // For now, we'll allow it but it should be validated
1007
1301
 
1302
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
1303
+ // reverse the reparenting — see issue #29.
1304
+ const changeSnapshotBefore = structuredClone(change);
1305
+
1306
+ // Three-way merge against the OLD base before reparenting (issue #31):
1307
+ // real jj's rebase re-derives the rebased change's content as
1308
+ // (old base, old commit, new base) — if a sibling changed the same
1309
+ // lines differently, that's a genuine conflict, not something
1310
+ // reparenting-and-copying-verbatim can silently paper over. This
1311
+ // reuses the exact same detectConflicts() machinery merge() already
1312
+ // uses (see merge(), a few hundred lines down) rather than
1313
+ // duplicating three-way-merge logic a third time in this codebase.
1314
+ const oldParentId = change.parents[0];
1315
+ const oldParentChange = oldParentId ? await graph.getChange(oldParentId) : null;
1316
+ const baseFiles = new Map(
1317
+ Object.entries((oldParentChange && oldParentChange.fileSnapshot) || {})
1318
+ );
1319
+ const leftFiles = new Map(Object.entries(change.fileSnapshot || {}));
1320
+ const rightFiles = new Map(Object.entries(newParentChange.fileSnapshot || {}));
1321
+
1322
+ await conflicts.load();
1323
+ const conflictsSnapshot = {
1324
+ conflicts: Object.fromEntries(conflicts.conflicts),
1325
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
1326
+ };
1327
+
1328
+ const detectedConflicts = oldParentChange
1329
+ ? await conflicts.detectConflicts({
1330
+ baseFiles,
1331
+ leftFiles,
1332
+ rightFiles,
1333
+ drivers: {},
1334
+ // Not necessarily the checked-out working copy, so don't let
1335
+ // merge drivers write to disk on its behalf.
1336
+ workingCopyDir: /** @type {any} */ (null),
1337
+ baseChange: oldParentId,
1338
+ leftChange: changeId,
1339
+ rightChange: newParent,
1340
+ })
1341
+ : [];
1342
+
1343
+ for (const conflict of detectedConflicts) {
1344
+ await conflicts.addConflict(conflict);
1345
+ }
1346
+
1008
1347
  // Update parent
1009
1348
  change.parents = [newParent];
1010
1349
  await graph.updateChange(change);
@@ -1015,6 +1354,8 @@ export async function createJJ(options) {
1015
1354
  user: await getUserOplogInfo(),
1016
1355
  description: `move change ${changeId.slice(0, 8)} to ${newParent.slice(0, 8)}`,
1017
1356
  parents: [],
1357
+ changeSnapshot: { [changeId]: changeSnapshotBefore },
1358
+ conflictsSnapshot,
1018
1359
  view: {
1019
1360
  bookmarks: {},
1020
1361
  remoteBookmarks: {},
@@ -1023,7 +1364,7 @@ export async function createJJ(options) {
1023
1364
  },
1024
1365
  });
1025
1366
 
1026
- return change;
1367
+ return { ...change, conflicts: detectedConflicts };
1027
1368
  },
1028
1369
 
1029
1370
  /**
@@ -1208,7 +1549,7 @@ export async function createJJ(options) {
1208
1549
  break;
1209
1550
  }
1210
1551
 
1211
- const content = await fs.promises.readFile(fullPath, 'utf-8');
1552
+ const content = await fs.promises.readFile(fullPath, 'utf8');
1212
1553
  fileSnapshot[filePath] = content;
1213
1554
  totalSnapshotSize += stats.size;
1214
1555
  } catch (error) {
@@ -1406,6 +1747,8 @@ export async function createJJ(options) {
1406
1747
 
1407
1748
  // Snapshot filesystem BEFORE operation (for undo) unless preserveSnapshot is true
1408
1749
  let fileSnapshot;
1750
+ /** @type {Record<string, any>} */
1751
+ const changeSnapshot = {};
1409
1752
  if (!args.preserveSnapshot) {
1410
1753
  fileSnapshot = await snapshotFilesystem();
1411
1754
 
@@ -1414,6 +1757,10 @@ export async function createJJ(options) {
1414
1757
  const currentChangeId = workingCopy.getCurrentChangeId();
1415
1758
  const currentChange = await graph.getChange(currentChangeId);
1416
1759
  if (currentChange && fileSnapshot) {
1760
+ // Snapshot BEFORE mutating so undo()/redo() can revert this
1761
+ // in-place update too, not just the pointer/new-change creation
1762
+ // below — see issue #29.
1763
+ changeSnapshot[currentChangeId] = structuredClone(currentChange);
1417
1764
  currentChange.fileSnapshot = fileSnapshot;
1418
1765
  await graph.updateChange(currentChange);
1419
1766
  }
@@ -1441,6 +1788,7 @@ export async function createJJ(options) {
1441
1788
  const user = /** @type {any} */ (userConfig.getUser());
1442
1789
 
1443
1790
  // Initialize with parent's file snapshot
1791
+ /** @type {Record<string, string>} */
1444
1792
  let initialSnapshot = {};
1445
1793
  if (parents && parents.length > 0) {
1446
1794
  const parent = await graph.getChange(parents[0]);
@@ -1484,10 +1832,19 @@ export async function createJJ(options) {
1484
1832
  // Handle insertBefore: rebase target to have new change as parent
1485
1833
  if (args.insertBefore) {
1486
1834
  const targetChange = await graph.getChange(args.insertBefore);
1835
+ changeSnapshot[args.insertBefore] = structuredClone(targetChange);
1487
1836
  targetChange.parents = [newChangeId];
1488
1837
  await graph.updateChange(targetChange);
1489
1838
  }
1490
1839
 
1840
+ // Sync the working directory to the new change's snapshot: write
1841
+ // everything it has AND remove anything it doesn't (issue #30 — this
1842
+ // used to never touch disk at all, so `new({ parents: [...] })`
1843
+ // targeting anything other than the current head silently left the
1844
+ // OLD working copy's files in place instead of checking out the new
1845
+ // parent's).
1846
+ await syncWorkingCopyFiles(initialSnapshot);
1847
+
1491
1848
  await workingCopy.setCurrentChange(newChangeId);
1492
1849
 
1493
1850
  // Record operation with filesystem snapshot
@@ -1500,6 +1857,13 @@ export async function createJJ(options) {
1500
1857
  },
1501
1858
  description: `new change ${newChangeId.slice(0, 8)}`,
1502
1859
  parents: [],
1860
+ // See issue #29: lets undo()/redo()/operations.restore() reverse
1861
+ // the existing records this op updated in place (the old
1862
+ // working-copy change's refreshed fileSnapshot, and insertBefore's
1863
+ // target's reparented parents). The newly created change itself
1864
+ // (newChangeId) is intentionally NOT torn down on undo — see
1865
+ // computeGraphReversal's doc comment.
1866
+ changeSnapshot,
1503
1867
  view: {
1504
1868
  bookmarks: {},
1505
1869
  remoteBookmarks: {},
@@ -1880,6 +2244,13 @@ export async function createJJ(options) {
1880
2244
 
1881
2245
  const previousChangeId = workingCopy.getCurrentChangeId();
1882
2246
 
2247
+ /** @type {Record<string, any>} */
2248
+ const changeSnapshot = {};
2249
+
2250
+ // Capture disk state right before we touch anything, for undo() —
2251
+ // see issue #29.
2252
+ const fileSnapshotBeforeSwitch = await snapshotFilesystem();
2253
+
1883
2254
  // CRITICAL FIX: Clean working directory BEFORE snapshotting to prevent cross-branch pollution
1884
2255
  // Remove files that don't belong to the current changeId before we snapshot it
1885
2256
  if (previousChangeId !== args.changeId) {
@@ -1901,13 +2272,14 @@ export async function createJJ(options) {
1901
2272
  }
1902
2273
  }
1903
2274
  }
1904
- }
1905
2275
 
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);
2276
+ // Now snapshot the CLEAN working directory (no pollution!)
2277
+ const currentSnapshot = await snapshotFilesystem();
2278
+ if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
2279
+ changeSnapshot[previousChangeId] = structuredClone(previousChange);
2280
+ previousChange.fileSnapshot = currentSnapshot;
2281
+ await graph.updateChange(previousChange);
2282
+ }
1911
2283
  }
1912
2284
  }
1913
2285
 
@@ -1920,38 +2292,17 @@ export async function createJJ(options) {
1920
2292
  timestamp: new Date().toISOString(),
1921
2293
  });
1922
2294
 
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
- }
2295
+ // Fully sync the working directory to the target change's snapshot:
2296
+ // write everything it has AND remove anything it doesn't (issue #30
2297
+ // — this used to only overlay the target's files on top of whatever
2298
+ // was already on disk, so a file present in the PREVIOUS change but
2299
+ // absent from the target leaked into the next snapshot).
2300
+ try {
2301
+ await syncWorkingCopyFiles(change.fileSnapshot || {});
2302
+ } catch (error) {
2303
+ throw new JJError('FILE_RESTORE_FAILED', `Failed to restore files: ${error.message}`, {
2304
+ originalError: error.message,
2305
+ });
1955
2306
  }
1956
2307
 
1957
2308
  // Set this change as the working copy
@@ -1967,11 +2318,13 @@ export async function createJJ(options) {
1967
2318
  },
1968
2319
  description: `edit change ${args.changeId.slice(0, 8)}`,
1969
2320
  parents: [],
2321
+ changeSnapshot,
1970
2322
  view: {
1971
2323
  bookmarks: {},
1972
2324
  remoteBookmarks: {},
1973
2325
  heads: [args.changeId],
1974
2326
  workingCopy: args.changeId,
2327
+ fileSnapshot: fileSnapshotBeforeSwitch,
1975
2328
  },
1976
2329
  });
1977
2330
 
@@ -2002,106 +2355,118 @@ export async function createJJ(options) {
2002
2355
  /**
2003
2356
  * Undo last operation
2004
2357
  *
2358
+ * Reverts the most recent operation — including graph-rewriting ones
2359
+ * (squash/rebase/abandon/new/edit), not just working-copy/file state
2360
+ * (see issue #29). Matches real jj's progressive-undo semantics
2361
+ * (`jj-undo(1)`): called again with nothing else in between, it steps
2362
+ * one further operation into the past rather than undoing itself.
2363
+ *
2005
2364
  * @returns {Promise<Object>} Information about the undo including the undone operation and restored state
2006
2365
  */
2007
2366
  async undo() {
2008
- // Get the operation we're about to undo to access its pre-state
2009
2367
  await oplog.load();
2010
2368
  const ops = await oplog.list();
2011
2369
  if (ops.length === 0) {
2012
2370
  throw new JJError('NOTHING_TO_UNDO', 'No operations to undo');
2013
2371
  }
2014
2372
 
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());
2019
-
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.
2027
- 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
- }
2063
- }
2373
+ const targetOp = resolveUndoTarget(ops);
2374
+ const landingOpId = targetOp.parents && targetOp.parents[0];
2375
+ const landingOp = landingOpId ? ops.find((op) => op.id === landingOpId) : null;
2064
2376
 
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();
2377
+ /** @type {ReturnType<typeof computeGraphReversal>} */
2378
+ let reversal;
2379
+ /** @type {any} */
2380
+ let landingView;
2381
+ if (landingOp) {
2382
+ reversal = computeGraphReversal(ops, landingOp);
2383
+ landingView = landingOp.view || {};
2075
2384
  } else {
2076
- // No conflicts snapshot - clear conflicts
2077
- await conflicts.clear();
2385
+ // Undoing the very first (root) operation: there's nothing before
2386
+ // it to restore to, so land on its own recorded state rather than
2387
+ // throwing (mirrors the previous no-parent fallback).
2388
+ reversal = {
2389
+ changeSnapshotToApply: {},
2390
+ fileSnapshot: undefined,
2391
+ conflictsSnapshot: undefined,
2392
+ };
2393
+ landingView = targetOp.view || {};
2394
+ }
2395
+
2396
+ // Capture EXACTLY what's about to be overwritten, from ground truth,
2397
+ // before applying the reversal — this is what redo() re-applies
2398
+ // later. It can't just recompute "the state after targetOp" the way
2399
+ // undo() itself computes "the state after landingOp" (by scanning
2400
+ // for a LATER op that also touched the same changeId/files/
2401
+ // conflicts): that scan only works going backward, where "nothing
2402
+ // touched it since" means "it's already correct". Going forward,
2403
+ // "nothing touched it since targetOp" means the opposite — its
2404
+ // effect is still live and needs to be captured now, not rederived.
2405
+ await graph.load();
2406
+ /** @type {Record<string, any>} */
2407
+ const redoChangeSnapshot = {};
2408
+ for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
2409
+ const current = await graph.getChange(changeId);
2410
+ if (current) redoChangeSnapshot[changeId] = current;
2411
+ }
2412
+ const redoFileSnapshot =
2413
+ reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
2414
+ let redoConflictsSnapshot;
2415
+ if (reversal.conflictsSnapshot !== undefined) {
2416
+ await conflicts.load();
2417
+ redoConflictsSnapshot = {
2418
+ conflicts: Object.fromEntries(conflicts.conflicts),
2419
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
2420
+ };
2078
2421
  }
2079
2422
 
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).
2423
+ if (landingView.workingCopy) {
2424
+ await workingCopy.setCurrentChange(landingView.workingCopy);
2425
+ }
2426
+ await applyGraphReversal(reversal);
2427
+
2428
+ // Record undo as its own operation (matching real jj) — tagged with
2429
+ // which operation it targeted so a following undo() can walk one
2430
+ // step further back (resolveUndoTarget) instead of toggling, and
2431
+ // carrying exactly what redo() needs to re-apply (captured above).
2432
+ //
2433
+ // Also record `changeSnapshot: redoChangeSnapshot` — the SAME
2434
+ // ground-truth-before-this-op values, but under the field name
2435
+ // computeGraphReversal() actually scans for. Without this, undo()
2436
+ // itself was the one mutating operation in the whole API that never
2437
+ // recorded its own changeSnapshot, so a LATER undo() targeting THIS
2438
+ // undo (to step back past it) had nothing to restore: it would
2439
+ // repoint the working copy and files, but leave whatever this
2440
+ // undo() overwrote (e.g. a change's description) exactly as this
2441
+ // undo() left it. See issue #37(a).
2082
2442
  await oplog.recordOperation({
2083
2443
  timestamp: new Date().toISOString(),
2084
2444
  user: await getUserOplogInfo(),
2085
2445
  description: 'undo operation',
2086
2446
  eventType: 'undo',
2087
- undoneOpId: currentOp.id,
2088
- undoneView: currentOp.view,
2089
- undoneConflictsSnapshot: currentOp.conflictsSnapshot || null,
2447
+ targetOpId: targetOp.id,
2448
+ undoneOpId: targetOp.id, // kept for API/type compatibility
2449
+ changeSnapshot: redoChangeSnapshot,
2450
+ redoChangeSnapshot,
2451
+ redoFileSnapshot,
2452
+ redoConflictsSnapshot,
2090
2453
  parents: [],
2091
- view: previousView,
2454
+ view: landingView,
2092
2455
  });
2093
2456
 
2094
- // Return information about what was undone
2457
+ const restoredFileCount =
2458
+ reversal.fileSnapshot !== undefined ? Object.keys(reversal.fileSnapshot).length : 0;
2459
+
2095
2460
  return {
2096
2461
  undoneOperation: {
2097
- description: currentOp.description,
2098
- timestamp: currentOp.timestamp,
2099
- user: currentOp.user,
2462
+ description: targetOp.description,
2463
+ timestamp: targetOp.timestamp,
2464
+ user: targetOp.user,
2100
2465
  },
2101
2466
  restoredState: {
2102
- workingCopy: previousView.workingCopy,
2103
- heads: previousView.heads,
2104
- fileCount: previousView.fileSnapshot ? Object.keys(previousView.fileSnapshot).length : 0,
2467
+ workingCopy: landingView.workingCopy,
2468
+ heads: landingView.heads,
2469
+ fileCount: restoredFileCount,
2105
2470
  },
2106
2471
  };
2107
2472
  },
@@ -2109,9 +2474,9 @@ export async function createJJ(options) {
2109
2474
  /**
2110
2475
  * Redo an operation previously undone with undo() (v1.5)
2111
2476
  *
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.
2477
+ * Mirrors `jj redo` (jj v0.33): re-applies the operation that the most
2478
+ * recent not-yet-redone undo() reverted — including its graph rewrites,
2479
+ * not just working-copy/file state (see issue #29).
2115
2480
  *
2116
2481
  * @returns {Promise<Object>} Information about the redone operation
2117
2482
  */
@@ -2142,42 +2507,52 @@ export async function createJJ(options) {
2142
2507
  });
2143
2508
  }
2144
2509
 
2145
- const redoneView = undoOp.undoneView || {};
2510
+ const targetOpId = undoOp.targetOpId || undoOp.undoneOpId;
2511
+ const targetOp = targetOpId && ops.find((op) => op.id === targetOpId);
2512
+ const redoneView = (targetOp && targetOp.view) || {};
2146
2513
 
2147
- // Restore the working-copy pointer.
2148
2514
  if (redoneView.workingCopy) {
2149
2515
  await workingCopy.setCurrentChange(redoneView.workingCopy);
2150
2516
  }
2151
2517
 
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
- }
2170
- }
2518
+ // Re-apply exactly what this undo captured right before it reverted
2519
+ // things — NOT computeGraphReversal(ops, targetOp): that scans for a
2520
+ // LATER op touching the same changeId/files/conflicts, which is the
2521
+ // right question for undo() ("has anything since made my target
2522
+ // stale?") but the wrong one here — after an undo, NOTHING later
2523
+ // re-established targetOp's own effect, so there's nothing to find
2524
+ // by scanning forward. undo() captured it directly for exactly this
2525
+ // reason (see its own comment).
2526
+ await graph.load();
2527
+
2528
+ // Capture ground truth right before overwriting, same as undo()
2529
+ // does for its own changeSnapshot (issue #37a) — lets a later
2530
+ // undo() targeting THIS redo op restore what redo() is about to
2531
+ // clobber, instead of finding no changeSnapshot at all.
2532
+ /** @type {Record<string, any>} */
2533
+ const undoChangeSnapshot = {};
2534
+ for (const changeId of Object.keys(undoOp.redoChangeSnapshot || {})) {
2535
+ const current = await graph.getChange(changeId);
2536
+ if (current) undoChangeSnapshot[changeId] = current;
2171
2537
  }
2172
2538
 
2173
- // Restore conflicts to the state that existed after the redone operation.
2539
+ for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
2540
+ if (await graph.getChange(changeId)) {
2541
+ // Bypass the git-sync middleware — see the matching comment in
2542
+ // applyGraphReversal(). `snapshot` already carries the change's
2543
+ // correct commitId from before the undo it's reversing ran
2544
+ // (issue #37c applies to redo() too).
2545
+ await baseGraph.updateChange(snapshot);
2546
+ }
2547
+ }
2548
+ if (undoOp.redoFileSnapshot !== undefined) {
2549
+ await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
2550
+ }
2174
2551
  await conflicts.load();
2175
- if (undoOp.undoneConflictsSnapshot) {
2176
- conflicts.conflicts = new Map(
2177
- Object.entries(undoOp.undoneConflictsSnapshot.conflicts || {})
2178
- );
2552
+ if (undoOp.redoConflictsSnapshot !== undefined) {
2553
+ conflicts.conflicts = new Map(Object.entries(undoOp.redoConflictsSnapshot.conflicts || {}));
2179
2554
  conflicts.fileConflicts = new Map(
2180
- Object.entries(undoOp.undoneConflictsSnapshot.fileConflicts || {})
2555
+ Object.entries(undoOp.redoConflictsSnapshot.fileConflicts || {})
2181
2556
  );
2182
2557
  await conflicts.save();
2183
2558
  }
@@ -2188,19 +2563,23 @@ export async function createJJ(options) {
2188
2563
  description: 'redo operation',
2189
2564
  eventType: 'redo',
2190
2565
  redoOf: undoOp.id,
2566
+ changeSnapshot: undoChangeSnapshot,
2191
2567
  parents: [],
2192
2568
  view: redoneView,
2193
2569
  });
2194
2570
 
2571
+ const restoredFileCount =
2572
+ undoOp.redoFileSnapshot !== undefined ? Object.keys(undoOp.redoFileSnapshot).length : 0;
2573
+
2195
2574
  return {
2196
2575
  redoneOperation: {
2197
2576
  description: undoOp.description,
2198
- undoneOpId: undoOp.undoneOpId,
2577
+ undoneOpId: targetOpId,
2199
2578
  },
2200
2579
  restoredState: {
2201
2580
  workingCopy: redoneView.workingCopy,
2202
2581
  heads: redoneView.heads,
2203
- fileCount: redoneView.fileSnapshot ? Object.keys(redoneView.fileSnapshot).length : 0,
2582
+ fileCount: restoredFileCount,
2204
2583
  },
2205
2584
  };
2206
2585
  },
@@ -2427,6 +2806,12 @@ export async function createJJ(options) {
2427
2806
  /**
2428
2807
  * Restore repository to a specific operation (matches `jj operation restore`)
2429
2808
  *
2809
+ * Fully reverts everything recorded after the target operation —
2810
+ * including graph rewrites (squash/rebase/abandon/new/edit), working
2811
+ * copy files, and conflict state, not just bookmarks/the working-copy
2812
+ * pointer (see issue #29) — using the same reversal primitive as
2813
+ * undo()/redo().
2814
+ *
2430
2815
  * @param {Record<string, any>} args - Arguments
2431
2816
  * @returns {Promise<Object>} Restore result
2432
2817
  */
@@ -2466,12 +2851,35 @@ export async function createJJ(options) {
2466
2851
  await workingCopy.setCurrentChange(targetOp.view.workingCopy);
2467
2852
  }
2468
2853
 
2854
+ // Revert the change graph, working-copy files, and conflict state
2855
+ // to exactly what they were right after targetOp ran.
2856
+ const reversal = computeGraphReversal(ops, targetOp);
2857
+
2858
+ // Capture ground truth for every changeId this restore is about to
2859
+ // overwrite, BEFORE applying the reversal — exactly like undo()
2860
+ // does for its own changeSnapshot. Without this, restore()'s own
2861
+ // oplog entry carried no changeSnapshot at all, so undoing the
2862
+ // restore itself had nothing to put back: a later undo() would
2863
+ // repoint the working copy and files, but leave whatever the
2864
+ // restore had just overwritten (e.g. a change's description)
2865
+ // exactly as the restore left it. See issue #37(a).
2866
+ await graph.load();
2867
+ /** @type {Record<string, any>} */
2868
+ const changeSnapshot = {};
2869
+ for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
2870
+ const current = await graph.getChange(changeId);
2871
+ if (current) changeSnapshot[changeId] = current;
2872
+ }
2873
+
2874
+ await applyGraphReversal(reversal);
2875
+
2469
2876
  // Record this restoration as a new operation
2470
2877
  await oplog.recordOperation({
2471
2878
  timestamp: new Date().toISOString(),
2472
2879
  user: await getUserOplogInfo(),
2473
2880
  description: `restore to operation ${args.operation}`,
2474
2881
  parents: [],
2882
+ changeSnapshot,
2475
2883
  view: targetOp.view,
2476
2884
  });
2477
2885
 
@@ -2757,10 +3165,53 @@ export async function createJJ(options) {
2757
3165
  timestamp: new Date().toISOString(),
2758
3166
  });
2759
3167
 
3168
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
3169
+ // reverse the squash (un-abandon source, restore dest's description
3170
+ // and content) — see issue #29.
3171
+ /** @type {Record<string, any>} */
3172
+ const changeSnapshot = {
3173
+ [source]: structuredClone(sourceChange),
3174
+ [destChangeId]: structuredClone(destChange),
3175
+ };
3176
+
2760
3177
  // Mark source as abandoned
2761
3178
  sourceChange.abandoned = true;
2762
3179
  await graph.updateChange(sourceChange);
2763
3180
 
3181
+ // Merge source's file content into dest (issue #30: squash() used to
3182
+ // only combine descriptions, leaving dest's tree untouched). Compute
3183
+ // what source itself actually changed relative to ITS OWN parent
3184
+ // (its "diff") and fold just that onto dest, rather than overwriting
3185
+ // dest's whole snapshot with source's — a path source never touched
3186
+ // (inherited unchanged from that parent) must not clobber a
3187
+ // dest-specific version of the same path.
3188
+ const sourceParentId = sourceChange.parents && sourceChange.parents[0];
3189
+ const sourceParentChange = sourceParentId ? await graph.getChange(sourceParentId) : null;
3190
+ const sourceBaseSnapshot = (sourceParentChange && sourceParentChange.fileSnapshot) || {};
3191
+ const sourceSnapshot = sourceChange.fileSnapshot || {};
3192
+ const mergedSnapshot = { ...(destChange.fileSnapshot || {}) };
3193
+ const touchedPaths = new Set([
3194
+ ...Object.keys(sourceBaseSnapshot),
3195
+ ...Object.keys(sourceSnapshot),
3196
+ ]);
3197
+ for (const filePath of touchedPaths) {
3198
+ const baseValue = sourceBaseSnapshot[filePath];
3199
+ const sourceValue = sourceSnapshot[filePath];
3200
+ if (sourceValue === baseValue) {
3201
+ // source never touched this path (inherited as-is from its own
3202
+ // parent) — leave whatever dest already had for it alone.
3203
+ continue;
3204
+ }
3205
+ if (sourceValue === undefined) {
3206
+ // source deleted a file it inherited from its parent.
3207
+ delete mergedSnapshot[filePath];
3208
+ } else {
3209
+ // source added or modified this path.
3210
+ mergedSnapshot[filePath] = sourceValue;
3211
+ }
3212
+ }
3213
+ destChange.fileSnapshot = mergedSnapshot;
3214
+
2764
3215
  // Update description of dest to indicate squash (middleware will sync to Git)
2765
3216
  destChange.description += `\n\n(squashed from ${source.slice(0, 8)})`;
2766
3217
  await graph.updateChange(destChange);
@@ -2788,10 +3239,22 @@ export async function createJJ(options) {
2788
3239
  },
2789
3240
  description: '(no description set)',
2790
3241
  timestamp: new Date().toISOString(),
2791
- fileSnapshot: {},
3242
+ // Start from the DESTINATION's tree (which now includes source's
3243
+ // folded-in changes, set above), not an empty object — matches
3244
+ // real `jj squash`, where the new working-copy change looks
3245
+ // identical to its new parent until you touch something. Issue
3246
+ // #39: this used to hard-code `{}`, so status()/snapshot() saw
3247
+ // every on-disk file as untracked/new until something re-wrote
3248
+ // it and triggered a fresh auto-snapshot.
3249
+ fileSnapshot: { ...destChange.fileSnapshot },
2792
3250
  };
2793
3251
 
2794
3252
  await graph.addChange(newChange); // Middleware will sync to Git
3253
+ // Sync the working directory to match (issue #30's fix for
3254
+ // new()/edit(); squash()'s own synthetic post-squash change never
3255
+ // did this) — a no-op when source's parent was already dest (the
3256
+ // common case), but necessary whenever source/dest aren't adjacent.
3257
+ await syncWorkingCopyFiles(newChange.fileSnapshot);
2795
3258
  await workingCopy.setCurrentChange(newChangeId);
2796
3259
  newWorkingCopyId = newChangeId;
2797
3260
  }
@@ -2802,6 +3265,7 @@ export async function createJJ(options) {
2802
3265
  user: await getUserOplogInfo(),
2803
3266
  description: `squash ${source.slice(0, 8)} into ${destChangeId.slice(0, 8)}`,
2804
3267
  parents: [],
3268
+ changeSnapshot,
2805
3269
  view: {
2806
3270
  bookmarks: {},
2807
3271
  remoteBookmarks: {},
@@ -2872,6 +3336,12 @@ export async function createJJ(options) {
2872
3336
  throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
2873
3337
  }
2874
3338
 
3339
+ // Captured BEFORE anything mutates — see issue #38: abandoning the
3340
+ // change @ currently points at must move @ off it (real jj checks
3341
+ // out a fresh empty change on the abandoned change's own parent(s));
3342
+ // abandoning any OTHER change must leave @ exactly where it was.
3343
+ const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
3344
+
2875
3345
  // Dispatch change:abandoning event (preventable)
2876
3346
  await dispatchEventAsync(jj, 'change:abandoning', {
2877
3347
  operation: 'abandon',
@@ -2880,15 +3350,29 @@ export async function createJJ(options) {
2880
3350
  timestamp: new Date().toISOString(),
2881
3351
  });
2882
3352
 
3353
+ // Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
3354
+ // reverse the abandon (un-abandon this change, un-reparent children)
3355
+ // — see issue #29.
3356
+ /** @type {Record<string, any>} */
3357
+ const changeSnapshot = { [changeId]: structuredClone(change) };
3358
+
2883
3359
  change.abandoned = true;
2884
3360
  await graph.updateChange(change);
2885
3361
 
3362
+ // Re-parent children onto the abandoned change's own parent(s)
3363
+ // (issue #30: this used to leave children pointing at the now-
3364
+ // abandoned commit). Matches real `jj abandon`: the abandoned
3365
+ // change is elided from the graph and its children attach directly
3366
+ // to whatever it was attached to.
3367
+ await reparentChildrenOnto(changeId, change.parents, changeSnapshot);
3368
+
2886
3369
  // Record operation
2887
3370
  await oplog.recordOperation({
2888
3371
  timestamp: new Date().toISOString(),
2889
3372
  user: { name: user.name, email: user.email, hostname: 'localhost' },
2890
3373
  description: `abandon change ${changeId.slice(0, 8)}`,
2891
3374
  parents: [],
3375
+ changeSnapshot,
2892
3376
  view: {
2893
3377
  bookmarks: {},
2894
3378
  remoteBookmarks: {},
@@ -2910,9 +3394,220 @@ export async function createJJ(options) {
2910
3394
  { cancelable: false }
2911
3395
  );
2912
3396
 
3397
+ // If @ was pointing at the change we just abandoned, it can't stay
3398
+ // there — real jj moves @ to a new empty change on the abandoned
3399
+ // change's own parent(s) (issue #38). Reuse new()'s own "create an
3400
+ // empty change with given parents" logic rather than duplicating it
3401
+ // — this is exactly the workaround callers had to reach for
3402
+ // themselves (`new({ parents: [parentOfAt] })` before abandoning).
3403
+ if (wasWorkingCopy) {
3404
+ const newWorkingCopyChange = await jj.new({ parents: change.parents || [] });
3405
+ return { ...change, newWorkingCopyChangeId: newWorkingCopyChange.changeId };
3406
+ }
3407
+
2913
3408
  return change;
2914
3409
  },
2915
3410
 
3411
+ /**
3412
+ * Converge divergent copies of a change (matches `jj converge`, jj
3413
+ * v0.45.0+ — issue #32, extended for N-way per the isomorphic-jj v0.45
3414
+ * catch-up follow-up) — resolves ANY number of divergent copies of one
3415
+ * changeId in a single call. Real jj's own CLI currently only
3416
+ * converges one change-id per invocation (an explicit TODO in its own
3417
+ * source as of v0.45.1: "consider adding logic to deal with more than
3418
+ * one divergent change-id in one invocation"), which is what fixed
3419
+ * this package's own changeId-at-a-time API design — but its
3420
+ * underlying engine (`TruncatedEvolutionGraph`/`find_divergent_changes`)
3421
+ * already resolves every divergent revision of THAT one change-id
3422
+ * together, not just a pair. This generalizes the same per-path rule
3423
+ * accordingly: two-way "does the other side's value differ from base"
3424
+ * becomes N-way "how many DISTINCT values, across every copy, differ
3425
+ * from base" — 0 means nobody touched it, 1 means exactly one copy (or
3426
+ * several copies agreeing on the same new value) changed it and that
3427
+ * value wins, 2+ is a genuine conflict between that many copies.
3428
+ *
3429
+ * A change is divergent when more than one visible commit shares its
3430
+ * change id (see the `divergent()` revset, and
3431
+ * ChangeGraph.addDivergentCopy() for how a divergent copy gets added
3432
+ * in the first place — nothing in this library creates one on its
3433
+ * own yet; this resolves one once something has, e.g. a future git
3434
+ * fetch/concurrent-operation integration).
3435
+ *
3436
+ * Non-interactive-mode convention, matching this package's other
3437
+ * ambiguous-outcome APIs: like merge(), a genuine per-path conflict is
3438
+ * returned as data (`{ resolved: false, conflicts }`), not thrown —
3439
+ * you get a result back either way and decide what to do with
3440
+ * unresolved conflicts. What DOES throw is anything that isn't even a
3441
+ * well-formed convergence attempt (no divergence to resolve at all) —
3442
+ * the same category as merge()'s own `MERGE_ERROR` throw for "no
3443
+ * common ancestor found".
3444
+ *
3445
+ * @param {Record<string, any>|string} args - `{ changeId }`, or a bare changeId string
3446
+ * @returns {Promise<Object>} `{ changeId, resolved, conflicts, commitId? }`
3447
+ */
3448
+ async converge(args) {
3449
+ const changeId = typeof args === 'string' ? args : args && (args.changeId || args.change);
3450
+ if (!changeId || typeof changeId !== 'string') {
3451
+ throw new JJError('INVALID_ARGUMENT', 'Missing changeId argument', {
3452
+ suggestion: 'Provide the divergent changeId to converge: { changeId: "..." }',
3453
+ });
3454
+ }
3455
+
3456
+ await graph.load();
3457
+ await userConfig.load();
3458
+ const siblings = graph.getDivergentSiblings(changeId);
3459
+
3460
+ if (siblings.length < 2) {
3461
+ throw new JJError(
3462
+ 'NOT_DIVERGENT',
3463
+ `Change ${changeId} has no divergent copies to converge`,
3464
+ {
3465
+ changeId,
3466
+ suggestion:
3467
+ 'converge() is only meaningful for a changeId the divergent() revset reports',
3468
+ }
3469
+ );
3470
+ }
3471
+
3472
+ // The primary copy is whichever one getChange() resolves (stored
3473
+ // under the plain changeId key); every other one is a divergent copy
3474
+ // added via addDivergentCopy(). No cap on how many — see the doc
3475
+ // comment above.
3476
+ const primary = /** @type {any} */ (await graph.getChange(changeId));
3477
+ const others = siblings.filter((/** @type {any} */ s) => s.commitId !== primary.commitId);
3478
+
3479
+ // Base for the N-way merge: what this change looked like before it
3480
+ // diverged. This package doesn't retain historical snapshots (only
3481
+ // current content), so — same pragmatic choice rebase() makes for
3482
+ // "old base" — use the shared parent's current content; divergent
3483
+ // copies realistically share the same parent, since they're all
3484
+ // rewrites of one prior position in history, not unrelated commits
3485
+ // that happen to collide. Applied uniformly to every copy, not
3486
+ // per-copy — the same simplification the original two-way version
3487
+ // made, just not newly introduced by going N-way.
3488
+ const baseParentId = primary.parents && primary.parents[0];
3489
+ const baseParentChange = baseParentId ? await graph.getChange(baseParentId) : null;
3490
+ const baseFiles = new Map(
3491
+ Object.entries((baseParentChange && baseParentChange.fileSnapshot) || {})
3492
+ );
3493
+
3494
+ // Every copy (primary + others), each as its own file map, paired
3495
+ // with its commitId for conflict reporting.
3496
+ const copies = [primary, ...others].map((/** @type {any} */ c) => ({
3497
+ commitId: c.commitId,
3498
+ files: new Map(Object.entries(c.fileSnapshot || {})),
3499
+ }));
3500
+
3501
+ await conflicts.load();
3502
+ const conflictsSnapshot = {
3503
+ conflicts: Object.fromEntries(conflicts.conflicts),
3504
+ fileConflicts: Object.fromEntries(conflicts.fileConflicts),
3505
+ };
3506
+
3507
+ const allPaths = new Set([
3508
+ ...baseFiles.keys(),
3509
+ ...copies.flatMap((c) => [...c.files.keys()]),
3510
+ ]);
3511
+
3512
+ /** @type {Record<string, string>} */
3513
+ const mergedSnapshot = {};
3514
+ /** @type {any[]} */
3515
+ const detectedConflicts = [];
3516
+
3517
+ for (const filePath of allPaths) {
3518
+ const baseValue = baseFiles.get(filePath);
3519
+ // Every DISTINCT value across all copies that differs from base,
3520
+ // each paired with (one of) the commitId(s) that has it — mirrors
3521
+ // ConflictModel._detectPathConflict's two-way "no conflict" rules,
3522
+ // generalized: 0 distinct changed values means nobody touched it
3523
+ // (base wins, i.e. nothing to write), 1 means only one side (or
3524
+ // several copies agreeing) changed it (that value wins), 2+ is a
3525
+ // genuine conflict.
3526
+ const changed = new Map();
3527
+ for (const copy of copies) {
3528
+ const value = copy.files.get(filePath);
3529
+ if (value !== baseValue && !changed.has(value)) {
3530
+ changed.set(value, copy.commitId);
3531
+ }
3532
+ }
3533
+
3534
+ if (changed.size === 0) {
3535
+ if (baseValue !== undefined) mergedSnapshot[filePath] = baseValue;
3536
+ } else if (changed.size === 1) {
3537
+ const [[value]] = changed;
3538
+ if (value !== undefined) mergedSnapshot[filePath] = value;
3539
+ } else {
3540
+ const versions = [...changed.entries()].map(([content, commitId]) => ({
3541
+ commitId,
3542
+ content,
3543
+ }));
3544
+ detectedConflicts.push(conflicts.createNWayConflict(filePath, baseValue, versions));
3545
+ }
3546
+ }
3547
+
3548
+ if (detectedConflicts.length > 0) {
3549
+ // Matches real jj: non-interactive mode aborts automatic
3550
+ // resolution rather than guessing — but per this package's own
3551
+ // merge() convention, that's reported back as data, not thrown.
3552
+ for (const conflict of detectedConflicts) {
3553
+ await conflicts.addConflict(conflict);
3554
+ }
3555
+ await oplog.recordOperation({
3556
+ timestamp: new Date().toISOString(),
3557
+ user: await getUserOplogInfo(),
3558
+ description: `converge ${changeId.slice(0, 8)} (unresolved)`,
3559
+ parents: [],
3560
+ conflictsSnapshot,
3561
+ view: {
3562
+ bookmarks: {},
3563
+ remoteBookmarks: {},
3564
+ heads: [],
3565
+ workingCopy: workingCopy.getCurrentChangeId(),
3566
+ },
3567
+ });
3568
+ return { changeId, resolved: false, conflicts: detectedConflicts };
3569
+ }
3570
+
3571
+ const user = /** @type {any} */ (userConfig.getUser());
3572
+ const changeSnapshot = { [changeId]: structuredClone(primary) };
3573
+ const otherIds = others.map((/** @type {any} */ o) => o.commitId.slice(0, 8)).join(', ');
3574
+
3575
+ const convergedChange = {
3576
+ ...primary,
3577
+ commitId: randomHex(20),
3578
+ fileSnapshot: mergedSnapshot,
3579
+ description: primary.description + `\n\n(converged divergent copies: ${otherIds})`,
3580
+ timestamp: new Date().toISOString(),
3581
+ committer: {
3582
+ name: user.name,
3583
+ email: user.email,
3584
+ timestamp: new Date().toISOString(),
3585
+ },
3586
+ divergent: false,
3587
+ };
3588
+ await graph.updateChange(convergedChange);
3589
+ for (const other of others) {
3590
+ await graph.deleteDivergentCopy(changeId, other.commitId);
3591
+ }
3592
+
3593
+ await oplog.recordOperation({
3594
+ timestamp: new Date().toISOString(),
3595
+ user: await getUserOplogInfo(),
3596
+ description: `converge ${changeId.slice(0, 8)}`,
3597
+ parents: [],
3598
+ changeSnapshot,
3599
+ conflictsSnapshot,
3600
+ view: {
3601
+ bookmarks: {},
3602
+ remoteBookmarks: {},
3603
+ heads: [changeId],
3604
+ workingCopy: workingCopy.getCurrentChangeId(),
3605
+ },
3606
+ });
3607
+
3608
+ return { changeId, resolved: true, conflicts: [], commitId: convergedChange.commitId };
3609
+ },
3610
+
2916
3611
  /**
2917
3612
  * Backout a change - create a new change that reverses the effect of the specified change
2918
3613
  *
@@ -4752,8 +5447,14 @@ export async function createJJ(options) {
4752
5447
  * @returns {Promise<Object>} Repository root information
4753
5448
  */
4754
5449
  async root() {
4755
- // Return the directory containing .git
4756
- const gitDir = path.join(dir, '.git');
5450
+ // `dir` is a real host filesystem path (unlike the repo-relative
5451
+ // git/jj paths `path` — posix-path.js — is for), so it needs the
5452
+ // host's own separator here, not always '/': on Windows Node's real
5453
+ // `fs` still resolves a forward-slash-joined path fine, but this
5454
+ // method hands the string back to the caller as data, and jj's own
5455
+ // `git root` returns a platform-native path there.
5456
+ const isWin32 = typeof process !== 'undefined' && process.platform === 'win32';
5457
+ const gitDir = isWin32 ? `${dir.replace(/[\\/]+$/, '')}\\.git` : path.join(dir, '.git');
4757
5458
 
4758
5459
  try {
4759
5460
  await fs.promises.access(gitDir);
@@ -4832,7 +5533,7 @@ export async function createJJ(options) {
4832
5533
  const wcFiles = await workingCopy.listFiles();
4833
5534
  for (const file of wcFiles) {
4834
5535
  try {
4835
- const content = await fs.promises.readFile(path.join(dir, file), 'utf-8');
5536
+ const content = await fs.promises.readFile(path.join(dir, file), 'utf8');
4836
5537
  leftFiles.set(file, content);
4837
5538
  } catch (error) {
4838
5539
  // File might be deleted or binary - throw error for explicit handling
@@ -4975,7 +5676,7 @@ export async function createJJ(options) {
4975
5676
  }
4976
5677
 
4977
5678
  // Write resolved content to file
4978
- await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf-8');
5679
+ await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf8');
4979
5680
 
4980
5681
  // Mark conflict as resolved
4981
5682
  await conflicts.resolveConflict(args.conflictId, 'manual');
@@ -5018,7 +5719,7 @@ export async function createJJ(options) {
5018
5719
  const resolvedContent = _resolveWithStrategy(conflict, args.strategy);
5019
5720
 
5020
5721
  // Write resolved content
5021
- await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf-8');
5722
+ await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf8');
5022
5723
 
5023
5724
  // Mark as resolved
5024
5725
  await conflicts.resolveConflict(conflict.conflictId, args.strategy);
@@ -5458,7 +6159,7 @@ export async function createJJ(options) {
5458
6159
  if (pathParts.length > 1) {
5459
6160
  const dirPath = pathParts.slice(0, -1).join('/');
5460
6161
  const fullDirPath = path.join(args.path, dirPath);
5461
- await fs.promises.mkdir(fullDirPath, { recursive: true });
6162
+ await mkdirp(fs, fullDirPath);
5462
6163
  }
5463
6164
 
5464
6165
  // Write file content