Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ Workspace config (`.arbws/config.json`) and project config (`.arb/config.json`)

Commands that can fail partway through sequential multi-repo execution write an operation record to `.arbws/operation.json`, enabling `--continue` (resume after conflicts), `--abort` (cancel and restore), and `arb undo` (reverse a completed operation). Running a bare command during an in-progress operation is blocked with guidance — the user must explicitly choose `--continue` or `--abort`.

`arb undo [repos...]` supports selective per-repo undo. When repos are named, only those repos are undone and marked with `status: "undone"` in the operation record. The record is kept until all repos are resolved, at which point config is restored and the record is finalized. Workspace-level operations (config restore, directory rename for `rename` commands) are deferred to the final undo. The finalization check is outcome-based — naming every actionable repo explicitly produces the same result as a bare `arb undo`.

The shared infrastructure lives in `core/operation.ts` (schema, I/O, gate, reconciliation), `sync/continue-flow.ts` (shared continue orchestration), and `sync/undo/` (assessment, planning, execution for both `--abort` and `arb undo`). Adding a new command type requires a thin handler and an undo switch case. See `decisions/0095-operation-record-and-recovery-model.md` for the design rationale, option analysis, and the relationship between `--abort` and `arb undo`.

### Git worktree directory layout
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,13 @@ arb rebase --continue # completes — but the result looks wrong
arb undo # rolls back all repos to their pre-rebase state, ready to try again
```

You can also undo selectively — keep the rebase in most repos but roll back the ones that went wrong:

```bash
arb undo payments # undo only payments, leave the rest
arb undo # later: undo any remaining repos
```

This works for any tracked operation: rebase, merge, retarget, pull, reset, rename. Undo aborts in-progress git operations, resets HEADs, and rolls back config — even uncommitted changes. It detects if you have done other changes, so it never silently discards work. It is as close to a magic wand as you can get!

### Commit matching
Expand Down
50 changes: 50 additions & 0 deletions decisions/0097-selective-per-repo-undo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Selective Per-Repo Undo

Date: 2026-03-26

## Context

`arb undo` reverses the last workspace operation across all repos atomically — read the operation record, show a plan, execute, finalize. There was no way to keep some repos' changes while rolling back others. A common scenario: the user rebases five repos, the rebase is correct in four but wrong in one. The only option was to undo everything and re-rebase.

The feature needed to support incremental undo: `arb undo repo-a`, then later `arb undo repo-b`, then `arb undo` for the rest. This raised three design questions: (1) how to track partially-undone state, (2) when to perform workspace-level finalization (config restore, directory rename, record finalization), and (3) how to scope the drift safety check.

## Options

### A: Add "undone" status to `RepoOperationState`
Extend the per-repo status enum (`completed | conflicting | skipped | pending`) with `"undone"`. After selective undo, mark repos as "undone" in the existing operation record. Finalize only when all repos are resolved.
- **Pros:** Minimal schema change (one enum value). Single file remains the source of truth. Existing assess/continue/abort flows need only trivial additions. Human-readable record shows exactly which repos are undone.
- **Cons:** Makes the operation record mutable after completion — though `finalizeOperationRecord` already does this.

### B: Separate undo tracking file (`.arbws/undo-state.json`)
Keep the operation record untouched. Add a companion file that tracks which repos have been undone.
- **Pros:** Operation record stays immutable during undo.
- **Cons:** Contradicts the single-file pattern. Creates a consistency hazard (one file deleted but not the other). All existing infrastructure (`assertNoInProgressOperation`, continue, abort) works with one file — splitting introduces coordination complexity.

### C: Reuse existing status values (mark undone repos as "skipped")
When a repo is undone, set its status back to "skipped". No schema change.
- **Pros:** No schema change needed.
- **Cons:** "Skipped" means "this repo was never part of the operation." The assess functions produce `action: "skip"` for skipped repos and filter them from the plan table — an undone repo would become invisible. Semantic confusion and lost audit trail.

## Decision

Option A — add `"undone"` to the `RepoOperationState` status enum. Workspace-level finalization (config restore, directory rename, record finalization) is deferred until all repos are resolved. The finalization check is outcome-based, not invocation-based: `arb undo repo-a repo-b repo-c` naming every actionable repo produces the same finalization as a bare `arb undo`.

## Reasoning

**Single-file state.** GUIDELINES §Filesystem as database: "state is inspectable, debuggable, and impossible to corrupt through arb bugs alone." Splitting state across two files (Option B) creates a consistency hazard and doubles schema maintenance. The operation record already tracks per-repo lifecycle — extending it is the natural path.

**Semantic clarity over convenience.** Option C would silently absorb undone repos into the "skipped" category, hiding them from the plan table and losing the distinction between "never participated" and "was reversed." Undone repos should be visible when the user runs `arb undo --dry-run` to inspect partial state.

**Outcome-based finalization.** The alternative — checking whether the user passed `[repos...]` — would mean `arb undo repo-a repo-b` (all repos) behaves differently from `arb undo` (no args), even though the outcome is identical. Outcome-based finalization is simpler to reason about and avoids a class of "did I finalize correctly?" edge cases.

**Scoped drift check.** Full undo keeps the existing behavior: any drifted repo blocks the entire undo. Selective undo checks only selected repos — consistent with how `arb rebase repo-a` only assesses repo-a. An unselected drifted repo is irrelevant to the user's intent and should not block progress.

**Deferred workspace-level operations.** Config restore and directory rename (for `arb rename` undo) are workspace-level: restoring the config branch name while some repos still use the new name creates an inconsistent state. Deferring these to final undo keeps the workspace coherent at every intermediate step.

## Consequences

- The `RepoOperationState.status` enum gains a fifth value. `classifyContinueRepo` and `assertNoInProgressOperation` treat "undone" as resolved, so `--continue` skips undone repos and the gate auto-completes when all remaining repos are undone/completed/skipped.
- After selective undo, the operation record persists with mixed statuses (some "completed", some "undone"). A subsequent `arb undo` or `arb undo <remaining-repos>` picks up where the last one left off.
- The `--abort` flag on sync commands does not support selective repos — abort means "cancel the entire operation." This is enforced at the command level (Commander `.conflicts()`), not in the undo flow.
- The hint `Use 'arb undo' to undo the remaining N repos` after partial undo is critical for discoverability — without it, users may not realize the record is still live.
- `arb undo` now performs a second assessment pass after execution to determine whether all repos are resolved. This adds per-repo git operations but is necessary for correctness — the first assessment ran before execution, and the post-execution state may differ.
11 changes: 10 additions & 1 deletion shell/arb.bash
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,15 @@ __arb_complete_reset() {
COMPREPLY=($(compgen -W "$(__arb_workspace_repo_names "$base_dir")" -- "$cur"))
}

__arb_complete_undo() {
local base_dir="$1" cur="$2"
if [[ "$cur" == -* ]]; then
COMPREPLY=($(compgen -W "-y --yes --dry-run -v --verbose -f --force" -- "$cur"))
return
fi
COMPREPLY=($(compgen -W "$(__arb_workspace_repo_names "$base_dir")" -- "$cur"))
}

__arb_complete_exec() {
local base_dir="$1" cur="$2"
local prev="${COMP_WORDS[COMP_CWORD-1]}"
Expand Down Expand Up @@ -701,7 +710,7 @@ _arb() {
retarget) __arb_complete_retarget "$base_dir" "$cur" ;;
merge) __arb_complete_merge "$base_dir" "$cur" ;;
reset) __arb_complete_reset "$base_dir" "$cur" ;;
undo) COMPREPLY=($(compgen -W "-y --yes -n --dry-run -v --verbose -f --force" -- "$cur")) ;;
undo) __arb_complete_undo "$base_dir" "$cur" ;;
log) __arb_complete_log "$base_dir" "$cur" ;;
exec) __arb_complete_exec "$base_dir" "$cur" ;;
open) __arb_complete_open "$base_dir" "$cur" ;;
Expand Down
5 changes: 3 additions & 2 deletions shell/arb.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -545,9 +545,10 @@ _arb() {
undo)
_arguments \
'(-y --yes)'{-y,--yes}'[Skip confirmation prompt]' \
'(-n --dry-run)'{-n,--dry-run}'[Show what would happen without executing]' \
'--dry-run[Show what would happen without executing]' \
'(-v --verbose)'{-v,--verbose}'[Show commits being rolled back]' \
'(-f --force)'{-f,--force}'[Delete corrupted operation record without undo]'
'(-f --force)'{-f,--force}'[Delete corrupted operation record without undo]' \
'*:repo:($ws_repo_names)'
;;
log)
_arguments \
Expand Down
82 changes: 61 additions & 21 deletions src/commands/undo.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,80 @@
import type { Command } from "commander";
import { arbAction, deleteOperationRecord } from "../lib/core";
import { ArbError, type OperationRecord, arbAction, deleteOperationRecord, readOperationRecord } from "../lib/core";
import { runUndoFlow } from "../lib/sync";
import { info } from "../lib/terminal";
import { error, info, readNamesFromStdin } from "../lib/terminal";
import { requireWorkspace } from "../lib/workspace";

// ── Command registration ──

export function registerUndoCommand(program: Command): void {
program
.command("undo")
.command("undo [repos...]")
.option("-y, --yes", "Skip confirmation prompt")
.option("-n, --dry-run", "Show what would happen without executing")
.option("--dry-run", "Show what would happen without executing")
.option("-v, --verbose", "Show commits being rolled back in the plan")
.option("-f, --force", "Delete a corrupted operation record without attempting to undo")
.summary("Undo the last workspace operation")
.description(
"Reverses the most recent workspace operation (branch rename, retarget, rebase, merge, or pull). Reads the operation record from .arbws/operation.json, shows what will be undone, and asks for confirmation.\n\nFor branch renames: reverses the git branch -m and restores the workspace config.\nFor sync operations (rebase, merge, retarget): resets repos to their pre-operation HEAD and aborts any in-progress git operations.\n\nIf any repo has drifted (HEAD moved since the operation), undo is refused with an explanation. Use --yes to skip the confirmation prompt. Use --verbose to show the individual commits that will be rolled back for each repo.\n\nUse --force to delete a corrupted operation record without attempting to undo. This is an escape hatch when the record is unreadable.",
"Reverses the most recent workspace operation (branch rename, retarget, rebase, merge, or pull). Reads the operation record from .arbws/operation.json, shows what will be undone, and asks for confirmation.\n\nWhen called with [repos...], only the named repos are undone. The operation record tracks the partially-undone state, and you can undo additional repos later with another 'arb undo [repos...]'. A bare 'arb undo' without repo arguments undoes all remaining repos.\n\nFor branch renames: reverses the git branch -m and restores the workspace config.\nFor sync operations (rebase, merge, retarget): resets repos to their pre-operation HEAD and aborts any in-progress git operations.\n\nIf any selected repo has drifted (HEAD moved since the operation), undo is refused with an explanation. Use --yes to skip the confirmation prompt. Use --verbose to show the individual commits that will be rolled back for each repo.\n\nUse --force to delete a corrupted operation record without attempting to undo. This is an escape hatch when the record is unreadable.",
)
.action(
arbAction(async (ctx, options: { yes?: boolean; dryRun?: boolean; verbose?: boolean; force?: boolean }) => {
const { wsDir } = requireWorkspace(ctx);
arbAction(
async (
ctx,
repoArgs: string[],
options: { yes?: boolean; dryRun?: boolean; verbose?: boolean; force?: boolean },
) => {
const { wsDir } = requireWorkspace(ctx);

// --force: delete corrupted record without reading it
if (options.force) {
deleteOperationRecord(wsDir);
info("Operation record cleared");
return;
}
// Resolve repo names from args or stdin
let repos = repoArgs;
if (repos.length === 0) {
const stdinNames = await readNamesFromStdin();
if (stdinNames.length > 0) repos = stdinNames;
}

await runUndoFlow({
wsDir,
arbRootDir: ctx.arbRootDir,
reposDir: ctx.reposDir,
options,
verb: "undo",
});
}),
// --force: delete corrupted record without reading it
if (options.force) {
if (repos.length > 0) {
const msg = "--force deletes the entire operation record — it cannot be combined with [repos...]";
error(msg);
throw new ArbError(msg);
}
deleteOperationRecord(wsDir);
info("Operation record cleared");
return;
}

// Validate repo names against the operation record and pass it through
// to avoid a redundant read inside runUndoFlow.
let validatedRecord: OperationRecord | undefined;
if (repos.length > 0) {
const record = readOperationRecord(wsDir);
if (!record) {
const msg = "Nothing to undo";
error(msg);
throw new ArbError(msg);
}
const recordRepos = new Set(Object.keys(record.repos));
const unknown = repos.filter((r) => !recordRepos.has(r));
if (unknown.length > 0) {
const msg = `Unknown repo${unknown.length > 1 ? "s" : ""} in operation record: ${unknown.join(", ")}`;
error(msg);
throw new ArbError(msg);
}
validatedRecord = record;
}

await runUndoFlow({
wsDir,
arbRootDir: ctx.arbRootDir,
reposDir: ctx.reposDir,
options,
verb: "undo",
repos: repos.length > 0 ? repos : undefined,
record: validatedRecord,
});
},
),
);
}
47 changes: 47 additions & 0 deletions src/lib/core/operation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,3 +431,50 @@ describe("new schema fields", () => {
expect(updated?.completedAt).toBeDefined();
}));
});

// ── undone status ────────────────────────────────────────────────

describe("undone status", () => {
test("undone status is valid in RepoOperationState", () =>
withTestDir(async (wsDir) => {
const record: OperationRecord = {
command: "rebase",
startedAt: "2026-01-01T00:00:00.000Z",
status: "completed",
repos: {
"repo-a": { preHead: "abc1234", status: "undone" },
"repo-b": { preHead: "def5678", status: "completed", postHead: "ghi9012" },
},
};
writeOperationRecord(wsDir, record);
const result = readOperationRecord(wsDir);
expect(result).not.toBeNull();
expect(result?.repos["repo-a"]?.status).toBe("undone");
expect(result?.repos["repo-b"]?.status).toBe("completed");
}));

test("classifyContinueRepo returns skip for undone status", async () => {
const { classifyContinueRepo } = await import("./operation");
const state = { preHead: "abc1234", status: "undone" as const };
const result = await classifyContinueRepo("/nonexistent", state);
expect(result.action).toBe("skip");
});

test("assertNoInProgressOperation treats undone repos as resolved", () =>
withTestDir(async (wsDir) => {
const record: OperationRecord = {
command: "rebase",
startedAt: "2026-01-01T00:00:00.000Z",
status: "in-progress",
repos: {
"repo-a": { preHead: "abc1234", status: "undone" },
"repo-b": { preHead: "def5678", status: "completed", postHead: "ghi9012" },
},
};
writeOperationRecord(wsDir, record);
// Should auto-complete since undone + completed = all resolved
await assertNoInProgressOperation(wsDir);
const updated = readOperationRecord(wsDir);
expect(updated?.status).toBe("completed");
}));
});
3 changes: 2 additions & 1 deletion src/lib/core/operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const RepoOperationStateSchema = z.object({
preHead: z.string(),
postHead: z.string().optional(),
stashSha: z.string().nullable().optional(),
status: z.enum(["completed", "conflicting", "skipped", "pending"]),
status: z.enum(["completed", "conflicting", "skipped", "pending", "undone"]),
tracking: TrackingSchema,
errorOutput: z.string().optional(),
});
Expand Down Expand Up @@ -252,6 +252,7 @@ export async function classifyContinueRepo(
): Promise<ContinueClassification> {
if (state.status === "completed") return { action: "already-done" };
if (state.status === "skipped") return { action: "skip" };
if (state.status === "undone") return { action: "skip" };
if (state.status === "pending") return { action: "needs-execute" };

// status === "conflicting"
Expand Down
14 changes: 14 additions & 0 deletions src/lib/sync/undo/assess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export async function assessBranchRenameUndo(
continue;
}

if (state.status === "undone") {
assessments.push({ repo: repoName, repoDir, action: "already-undone" });
continue;
}

if (state.status === "conflicting") {
assessments.push({ repo: repoName, repoDir, action: "no-action", detail: "rename did not complete" });
continue;
Expand Down Expand Up @@ -118,6 +123,10 @@ export async function assessRenameUndo(
assessments.push({ repo: repoName, repoDir, action: "skip" });
continue;
}
if (state.status === "undone") {
assessments.push({ repo: repoName, repoDir, action: "already-undone" });
continue;
}
if (state.status === "conflicting") {
assessments.push({ repo: repoName, repoDir, action: "no-action", detail: "rename did not complete" });
continue;
Expand Down Expand Up @@ -185,6 +194,11 @@ export async function assessSyncUndo(record: OperationRecord, wsDir: string): Pr
continue;
}

if (state.status === "undone") {
assessments.push({ repo: repoName, repoDir, action: "already-undone" });
continue;
}

if (state.status === "conflicting") {
const op = await detectOperation(repoDir);
if (op) {
Expand Down
Loading
Loading