Skip to content

Commit 88d725e

Browse files
henrikjeclaude
andcommitted
feat(undo): preserve operation records and add debug metadata
Stop deleting operation records after completion/undo/abort. Instead, finalizeOperationRecord() marks them with completedAt, outcome, and status: "completed". Records persist in .arbws/operation.json until the next operation overwrites them, preserving diagnostic information. Three additions: - Finalize instead of delete: new completedAt timestamp and outcome field ("completed", "aborted", "undone", "force-cleared") on the operation record. Only --force still deletes (corrupt records). - Capture git stderr on failure: truncated to 4000 chars in the per-repo errorOutput field for post-mortem debugging. - GIT_REFLOG_ACTION: set during execution loops so git reflog entries are tagged with arb-rebase, arb-undo, etc. - Include operation record in arb dump per-workspace output. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b26c0c2 commit 88d725e

19 files changed

Lines changed: 704 additions & 407 deletions

src/commands/branch-rename.ts

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type RepoOperationState,
88
arbAction,
99
assertNoInProgressOperation,
10-
deleteOperationRecord,
10+
finalizeOperationRecord,
1111
readInProgressOperation,
1212
readWorkspaceConfig,
1313
writeOperationRecord,
@@ -320,7 +320,7 @@ async function runRename(
320320
branch: newBranch,
321321
...(configBase && { base: configBase }),
322322
});
323-
if (existingRecord) deleteOperationRecord(wsDir);
323+
if (existingRecord) finalizeOperationRecord(wsDir, "completed");
324324
success(`Workspace branch set to '${newBranch}' (no repos to rename)`);
325325
info("Run 'arb attach' to attach repos on the new branch");
326326
return;
@@ -372,7 +372,7 @@ async function runRename(
372372
// If continuing and all repos are already renamed, finalize the operation
373373
if (existingRecord) {
374374
writeWorkspaceConfig(configFile, { branch: newBranch, ...(configBase && { base: configBase }) });
375-
deleteOperationRecord(wsDir);
375+
finalizeOperationRecord(wsDir, "completed");
376376
info("All repos already renamed");
377377
} else {
378378
info("Nothing to rename");
@@ -439,35 +439,42 @@ async function runRename(
439439
let renameOk = 0;
440440
const failures: string[] = [];
441441

442-
for (const a of willRename) {
443-
inlineStart(a.repo, "renaming");
444-
const result = await renameBranch(a.repoDir, oldBranch, newBranch);
445-
if (result.exitCode === 0) {
446-
// Clear stale tracking left by git branch -m.
447-
// Without this, @{upstream} resolves to origin/<oldBranch> and
448-
// arb push reports "up to date" instead of pushing the new name.
449-
await gitLocal(a.repoDir, "config", "--unset", `branch.${newBranch}.remote`);
450-
await gitLocal(a.repoDir, "config", "--unset", `branch.${newBranch}.merge`);
451-
452-
const postHeadResult = await gitLocal(a.repoDir, "rev-parse", "HEAD");
453-
const existing = record.repos[a.repo];
454-
if (existing) {
455-
record.repos[a.repo] = { ...existing, status: "completed", postHead: postHeadResult.stdout.trim() };
456-
}
457-
writeOperationRecord(wsDir, record);
442+
try {
443+
process.env.GIT_REFLOG_ACTION = "arb-branch-rename";
444+
for (const a of willRename) {
445+
inlineStart(a.repo, "renaming");
446+
const result = await renameBranch(a.repoDir, oldBranch, newBranch);
447+
if (result.exitCode === 0) {
448+
// Clear stale tracking left by git branch -m.
449+
// Without this, @{upstream} resolves to origin/<oldBranch> and
450+
// arb push reports "up to date" instead of pushing the new name.
451+
await gitLocal(a.repoDir, "config", "--unset", `branch.${newBranch}.remote`);
452+
await gitLocal(a.repoDir, "config", "--unset", `branch.${newBranch}.merge`);
453+
454+
const postHeadResult = await gitLocal(a.repoDir, "rev-parse", "HEAD");
455+
const existing = record.repos[a.repo];
456+
if (existing) {
457+
record.repos[a.repo] = { ...existing, status: "completed", postHead: postHeadResult.stdout.trim() };
458+
}
459+
writeOperationRecord(wsDir, record);
460+
461+
inlineResult(a.repo, `local branch renamed to ${newBranch}`);
462+
renameOk++;
463+
} else {
464+
const existing = record.repos[a.repo];
465+
if (existing) {
466+
const errorOutput = result.stderr.trim().slice(0, 4000) || undefined;
467+
record.repos[a.repo] = { ...existing, status: "conflicting", errorOutput };
468+
}
469+
writeOperationRecord(wsDir, record);
458470

459-
inlineResult(a.repo, `local branch renamed to ${newBranch}`);
460-
renameOk++;
461-
} else {
462-
const existing = record.repos[a.repo];
463-
if (existing) {
464-
record.repos[a.repo] = { ...existing, status: "conflicting" };
471+
inlineResult(a.repo, red("failed"));
472+
failures.push(a.repo);
465473
}
466-
writeOperationRecord(wsDir, record);
467-
468-
inlineResult(a.repo, red("failed"));
469-
failures.push(a.repo);
470474
}
475+
} finally {
476+
// biome-ignore lint/performance/noDelete: must truly unset env var, not coerce to string
477+
delete process.env.GIT_REFLOG_ACTION;
471478
}
472479

473480
if (failures.length > 0) {
@@ -489,6 +496,7 @@ async function runRename(
489496
// All local renames succeeded — apply deferred config and mark completed
490497
writeWorkspaceConfig(configFile, configAfter);
491498
record.status = "completed";
499+
record.completedAt = new Date().toISOString();
492500
writeOperationRecord(wsDir, record);
493501

494502
// Remote cleanup — only runs after all local renames succeed

src/commands/dump.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { readFileSync } from "node:fs";
22
import { basename, join } from "node:path";
33
import type { Command } from "commander";
4-
import { type CommandContext, arbAction, readWorkspaceConfig } from "../lib/core";
4+
import { type CommandContext, arbAction, readOperationRecord, readWorkspaceConfig } from "../lib/core";
55
import { getRemoteNames, getRemoteUrl } from "../lib/git";
66
import { AnalysisCache, computeFlags, gatherWorkspaceSummary } from "../lib/status";
77
import { listRepos, listWorkspaces, readGitdirFromWorktree, workspaceRepoDirs } from "../lib/workspace";
@@ -204,7 +204,14 @@ async function runDump(ctx: CommandContext): Promise<void> {
204204
dumpErrors.push(`workspace ${ws} config: ${errMsg(err)}`);
205205
}
206206

207-
return { branch, base, repos };
207+
let operation: object | null = null;
208+
try {
209+
operation = readOperationRecord(wsDir);
210+
} catch (err) {
211+
dumpErrors.push(`workspace ${ws} operation: ${errMsg(err)}`);
212+
}
213+
214+
return { branch, base, repos, operation };
208215
}),
209216
);
210217

src/commands/pull.ts

Lines changed: 101 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -267,118 +267,125 @@ export async function runPull(
267267
}
268268
writeOperationRecord(wsDir, record);
269269
};
270-
const markConflicting = (repo: string) => {
270+
const markConflicting = (repo: string, stderr?: string) => {
271271
const existing = record.repos[repo];
272272
if (existing) {
273-
record.repos[repo] = { ...existing, status: "conflicting" };
273+
const errorOutput = stderr?.trim().slice(0, 4000) || undefined;
274+
record.repos[repo] = { ...existing, status: "conflicting", errorOutput };
274275
}
275276
writeOperationRecord(wsDir, record);
276277
};
277278

278-
for (const a of willPull) {
279-
const strategy = a.pullStrategy ?? (a.pullMode === "rebase" ? "rebase-pull" : "merge-pull");
280-
inlineStart(a.repo, `pulling (${pullStrategyLabel(strategy)})`);
281-
const pullRemote = remotesMap.get(a.repo)?.share;
282-
if (!pullRemote) continue;
283-
284-
if (strategy === "rebase-pull") {
285-
const pullArgs = a.needsStash
286-
? ["pull", "--rebase", "--autostash", pullRemote, a.branch]
287-
: ["pull", "--rebase", pullRemote, a.branch];
288-
const pullResult = await gitNetwork(a.repoDir, pullTimeout, pullArgs);
289-
if (pullResult.exitCode === 0) {
290-
await markCompleted(a.repo);
291-
inlineResult(a.repo, `pulled ${plural(a.behind, "commit")} (${a.pullMode})`);
292-
pullOk++;
293-
} else {
294-
if (isConflictResult(pullResult.stdout, pullResult.stderr)) {
295-
markConflicting(a.repo);
296-
inlineResult(a.repo, yellow("conflict"));
297-
conflicted.push({ assessment: a, stdout: pullResult.stdout, stderr: pullResult.stderr });
279+
try {
280+
process.env.GIT_REFLOG_ACTION = "arb-pull";
281+
for (const a of willPull) {
282+
const strategy = a.pullStrategy ?? (a.pullMode === "rebase" ? "rebase-pull" : "merge-pull");
283+
inlineStart(a.repo, `pulling (${pullStrategyLabel(strategy)})`);
284+
const pullRemote = remotesMap.get(a.repo)?.share;
285+
if (!pullRemote) continue;
286+
287+
if (strategy === "rebase-pull") {
288+
const pullArgs = a.needsStash
289+
? ["pull", "--rebase", "--autostash", pullRemote, a.branch]
290+
: ["pull", "--rebase", pullRemote, a.branch];
291+
const pullResult = await gitNetwork(a.repoDir, pullTimeout, pullArgs);
292+
if (pullResult.exitCode === 0) {
293+
await markCompleted(a.repo);
294+
inlineResult(a.repo, `pulled ${plural(a.behind, "commit")} (${a.pullMode})`);
295+
pullOk++;
298296
} else {
299-
inlineResult(a.repo, yellow("failed"));
300-
failed.push({
301-
assessment: a,
302-
exitCode: pullResult.exitCode,
303-
stdout: pullResult.stdout,
304-
stderr: pullResult.stderr,
305-
action: "pull --rebase",
306-
});
307-
}
308-
}
309-
} else if (strategy === "safe-reset" || strategy === "forced-reset") {
310-
if (a.needsStash) {
311-
await gitLocal(a.repoDir, "stash", "push", "-m", "arb: autostash before pull");
312-
}
313-
const target = a.safeReset?.target ?? `${pullRemote}/${a.branch}`;
314-
const resetLabel = strategy === "forced-reset" ? "forced reset" : "safe reset";
315-
const resetResult = await gitLocal(a.repoDir, "reset", "--hard", target);
316-
if (resetResult.exitCode === 0) {
317-
let stashPopOk = true;
318-
if (a.needsStash) {
319-
const popResult = await gitLocal(a.repoDir, "stash", "pop");
320-
if (popResult.exitCode !== 0) {
321-
stashPopOk = false;
322-
stashPopFailed.push(a);
297+
if (isConflictResult(pullResult.stdout, pullResult.stderr)) {
298+
markConflicting(a.repo, pullResult.stderr);
299+
inlineResult(a.repo, yellow("conflict"));
300+
conflicted.push({ assessment: a, stdout: pullResult.stdout, stderr: pullResult.stderr });
301+
} else {
302+
inlineResult(a.repo, yellow("failed"));
303+
failed.push({
304+
assessment: a,
305+
exitCode: pullResult.exitCode,
306+
stdout: pullResult.stdout,
307+
stderr: pullResult.stderr,
308+
action: "pull --rebase",
309+
});
323310
}
324311
}
325-
await markCompleted(a.repo);
326-
let doneMsg = `${resetLabel} to ${target}`;
327-
if (!stashPopOk) {
328-
doneMsg += ` ${yellow("(stash pop failed)")}`;
329-
}
330-
inlineResult(a.repo, doneMsg);
331-
pullOk++;
332-
} else {
333-
markConflicting(a.repo);
334-
inlineResult(a.repo, yellow("failed"));
335-
failed.push({
336-
assessment: a,
337-
exitCode: resetResult.exitCode,
338-
stdout: resetResult.stdout,
339-
stderr: resetResult.stderr,
340-
action: `reset --hard ${target}`,
341-
});
342-
}
343-
} else {
344-
if (a.needsStash) {
345-
await gitLocal(a.repoDir, "stash", "push", "-m", "arb: autostash before pull");
346-
}
347-
const pullResult = await gitNetwork(a.repoDir, pullTimeout, ["pull", "--no-rebase", pullRemote, a.branch]);
348-
if (pullResult.exitCode === 0) {
349-
let stashPopOk = true;
312+
} else if (strategy === "safe-reset" || strategy === "forced-reset") {
350313
if (a.needsStash) {
351-
const popResult = await gitLocal(a.repoDir, "stash", "pop");
352-
if (popResult.exitCode !== 0) {
353-
stashPopOk = false;
354-
stashPopFailed.push(a);
355-
}
356-
}
357-
await markCompleted(a.repo);
358-
let doneMsg = `pulled ${plural(a.behind, "commit")} (${a.pullMode})`;
359-
if (!stashPopOk) {
360-
doneMsg += ` ${yellow("(stash pop failed)")}`;
314+
await gitLocal(a.repoDir, "stash", "push", "-m", "arb: autostash before pull");
361315
}
362-
inlineResult(a.repo, doneMsg);
363-
pullOk++;
364-
} else {
365-
if (isConflictResult(pullResult.stdout, pullResult.stderr)) {
366-
markConflicting(a.repo);
367-
inlineResult(a.repo, yellow("conflict"));
368-
conflicted.push({ assessment: a, stdout: pullResult.stdout, stderr: pullResult.stderr });
316+
const target = a.safeReset?.target ?? `${pullRemote}/${a.branch}`;
317+
const resetLabel = strategy === "forced-reset" ? "forced reset" : "safe reset";
318+
const resetResult = await gitLocal(a.repoDir, "reset", "--hard", target);
319+
if (resetResult.exitCode === 0) {
320+
let stashPopOk = true;
321+
if (a.needsStash) {
322+
const popResult = await gitLocal(a.repoDir, "stash", "pop");
323+
if (popResult.exitCode !== 0) {
324+
stashPopOk = false;
325+
stashPopFailed.push(a);
326+
}
327+
}
328+
await markCompleted(a.repo);
329+
let doneMsg = `${resetLabel} to ${target}`;
330+
if (!stashPopOk) {
331+
doneMsg += ` ${yellow("(stash pop failed)")}`;
332+
}
333+
inlineResult(a.repo, doneMsg);
334+
pullOk++;
369335
} else {
370-
markConflicting(a.repo);
336+
markConflicting(a.repo, resetResult.stderr);
371337
inlineResult(a.repo, yellow("failed"));
372338
failed.push({
373339
assessment: a,
374-
exitCode: pullResult.exitCode,
375-
stdout: pullResult.stdout,
376-
stderr: pullResult.stderr,
377-
action: "pull --no-rebase",
340+
exitCode: resetResult.exitCode,
341+
stdout: resetResult.stdout,
342+
stderr: resetResult.stderr,
343+
action: `reset --hard ${target}`,
378344
});
379345
}
346+
} else {
347+
if (a.needsStash) {
348+
await gitLocal(a.repoDir, "stash", "push", "-m", "arb: autostash before pull");
349+
}
350+
const pullResult = await gitNetwork(a.repoDir, pullTimeout, ["pull", "--no-rebase", pullRemote, a.branch]);
351+
if (pullResult.exitCode === 0) {
352+
let stashPopOk = true;
353+
if (a.needsStash) {
354+
const popResult = await gitLocal(a.repoDir, "stash", "pop");
355+
if (popResult.exitCode !== 0) {
356+
stashPopOk = false;
357+
stashPopFailed.push(a);
358+
}
359+
}
360+
await markCompleted(a.repo);
361+
let doneMsg = `pulled ${plural(a.behind, "commit")} (${a.pullMode})`;
362+
if (!stashPopOk) {
363+
doneMsg += ` ${yellow("(stash pop failed)")}`;
364+
}
365+
inlineResult(a.repo, doneMsg);
366+
pullOk++;
367+
} else {
368+
if (isConflictResult(pullResult.stdout, pullResult.stderr)) {
369+
markConflicting(a.repo, pullResult.stderr);
370+
inlineResult(a.repo, yellow("conflict"));
371+
conflicted.push({ assessment: a, stdout: pullResult.stdout, stderr: pullResult.stderr });
372+
} else {
373+
markConflicting(a.repo, pullResult.stderr);
374+
inlineResult(a.repo, yellow("failed"));
375+
failed.push({
376+
assessment: a,
377+
exitCode: pullResult.exitCode,
378+
stdout: pullResult.stdout,
379+
stderr: pullResult.stderr,
380+
action: "pull --no-rebase",
381+
});
382+
}
383+
}
380384
}
381385
}
386+
} finally {
387+
// biome-ignore lint/performance/noDelete: must truly unset env var, not coerce to string
388+
delete process.env.GIT_REFLOG_ACTION;
382389
}
383390

384391
// Consolidated conflict report
@@ -405,6 +412,7 @@ export async function runPull(
405412
// Finalize operation record
406413
if (conflicted.length === 0 && failed.length === 0) {
407414
record.status = "completed";
415+
record.completedAt = new Date().toISOString();
408416
writeOperationRecord(wsDir, record);
409417
} else {
410418
info("Use 'arb pull --continue' to resume or 'arb pull --abort' to cancel");

0 commit comments

Comments
 (0)