Item 4: Redundant existing-worktree check in Task 13

Decision document · fork-with-state · 2026-05-17

Contents

Background: existing worktrees in git

git worktree add <path> <branch> creates a directory checked out to <branch>. Once created, git refuses any future git worktree add for that same branch (the one-branch-one-worktree rule). The branch is “claimed” by the worktree.

git worktree list --porcelain reports each branch and the path of its worktree. The helper git.GetWorktreeForBranch(repoRoot, branch) wraps this — returns the path if a worktree exists for that branch, empty string otherwise.

Today’s behavior (pre-feature)

Today, session fork -w <branch> has a reuse-on-collision behavior at session_cmd.go:721-723:

// Check for an existing worktree for this branch before creating a new one
if existingPath, err := git.GetWorktreeForBranch(repoRoot, wtBranch); err == nil && existingPath != "" {
    fmt.Fprintf(os.Stderr, "Reusing existing worktree at %s for branch %s\n", existingPath, wtBranch)
    worktreePath = existingPath
} else {
    // create new worktree...
}

This is intentional: if you keep forking sessions for the same experiment branch, you want them all to land in the same workspace, not stamp out N copies.

USER: agent-deck session fork my-session -w fork/exp │ ▼ ┌─────────────────────────────┐ │ Does branch fork/exp exist? │ └─────────────────────────────┘ no, no -b │ │ yes ▼ ▼ ERROR Continue │ ▼ ┌────────────────────────────────────────┐ │ Does fork/exp already have a worktree? │ └────────────────────────────────────────┘ yes │ │ no ▼ ▼ REUSE that worktree Create new worktree │ │ └──────┬──────┘ ▼ Fork Claude into worktree

What this feature adds, and where collision matters

Fork-with-state must not reuse an existing worktree: materialization would overwrite or interleave with whatever state is already there. A user who runs session fork --with-state -w fork/exp and finds an existing fork/exp worktree must get an error, not a silent reuse.

So the new with-state path needs a refusal that today’s path doesn’t have. Where to put that refusal is the architectural question.

Workflow walkthroughs

Workflow A — Happy path: --with-state -w fork/new, no conflicts

1. resolveForkStateFlags → stateFlags.WithState=true, Branch="fork/new" 2. Refuse if --with-state but no -w (passes) 3. Enter `if stateFlags.WithState {...}` early gate (plan line 1742): ├── GetWorktreeForBranch("fork/new") → empty ✓ (Check #1a) ├── BranchExists("fork/new") → false ✓ (Check #1b) └── PreflightForkWithState(parent) → ok ✓ 4. Exit early gate; compute worktreePath 5. Enter existing-worktree reuse block (plan line 1785): └── GetWorktreeForBranch("fork/new") → empty ✓ (Check #2) → fall into the "create new" branch 6. CreateWorktreeAtStartPoint → ok 7. MaterializeParentState → ok 8. RunWorktreeSetup → ok 9. Start fork

Note that GetWorktreeForBranch is called twice at steps 3a and 5. Both return empty. Step 5’s redundancy is benign here.

Workflow B — --with-state -w fork/exp where fork/exp already has a worktree

1. resolveForkStateFlags → stateFlags.WithState=true, Branch="fork/exp" 2. Refuse if --with-state but no -w (passes) 3. Enter early gate: ├── GetWorktreeForBranch("fork/exp") → "/x/fork-exp" (non-empty) │ → out.Error("branch 'fork/exp' already has a worktree at /x/fork-exp; ...") │ → os.Exit(1) ← EXIT HERE ... (Check #2 in the reuse block is never reached)

Workflow C — The imagined “the early gate got refactored” scenario

The plan author’s defensive justification: imagine a future refactor that moves preflight elsewhere and accidentally drops Check #1a:

1-3. (modified) early gate runs preflight only, no GetWorktreeForBranch 4. Compute worktreePath 5. Enter reuse block: └── GetWorktreeForBranch("fork/exp") → "/x/fork-exp" ├── (with the redundant guard): if stateFlags.WithState → out.Error → os.Exit └── (without the redundant guard): print "Reusing existing worktree..." → worktreePath = "/x/fork-exp" ← would be a BUG 6. CreateWorktreeAtStartPoint runs against the existing branch → CreateWorktreeAtStartPoint refuses (BranchExists check inside it) → out.Error("worktree creation failed...") → os.Exit
Important: even in the “refactored-away” scenario, CreateWorktreeAtStartPoint at step 6 itself rejects the existing branch (per Task 4A line 564: if BranchExists(...) { return false, fmt.Errorf("branch %q already exists", ...) }). The defense isn’t doing what the plan author thought — the bottom-of-stack check catches the slip.

CLI vs TUI architectural comparison

Here are the two handlers side by side, schematically:

                 CLI: handleSessionFork                   TUI: forkSessionCmdWithOptions
                 ─────────────────────                    ──────────────────────────────
                                                          (dialog has already validated
                                                           user input via ForkDialog.Validate
                                                           before this cmd is dispatched)

  ┌─ Parse flags ─────────────────────────┐               ┌─ Receive pre-built opts ─────┐
  │   wtBranch, withState, etc.           │               │   from dialog submit         │
  └───────────────────────────────────────┘               └──────────────────────────────┘
                  │                                                       │
                  ▼                                                       ▼
  ┌─ All-validation block ────────────────┐               ┌─ Existing-worktree reuse ────┐
  │   refuse --with-state without -w      │               │   if GetWorktreeForBranch:   │
  │   if WithState {                      │               │     if opts.WithState:       │
  │     Check #1a: GetWorktreeForBranch ──┼──┐            │       return error           │ ← (only collision check)
  │     Check #1b: BranchExists           │  │            │     else: reuse it           │
  │     Preflight                         │  │            │   else: continue             │
  │   } else if !createNewBranch:         │  │            └──────────────────────────────┘
  │     BranchExists                      │  │                            │
  └───────────────────────────────────────┘  │                            ▼
                  │                          │            ┌─ Git-ops block ──────────────┐
                  ▼                          │            │   if WithState:              │
  ┌─ Compute worktree path ───────────────┐  │            │     Preflight                │
  └───────────────────────────────────────┘  │            │     CreateWorktreeAtStartPoint
                  │                          │            │   else:                      │
                  ▼                          │            │     CreateWorktree           │
  ┌─ Existing-worktree reuse ─────────────┐  │            │   if WithState:              │
  │   if GetWorktreeForBranch:            │  │            │     MaterializeParentState   │
  │     if WithState: error  ─────────────┼──┤            │   RunWorktreeSetup           │
  │     else: reuse                       │  │ redundant  └──────────────────────────────┘
  │   else: continue                      │  │            │
  └───────────────────────────────────────┘  │            │
                  │                          │            ▼
                  ▼                          │        (return msg)
  ┌─ Create / Materialize / Setup ────────┐  │
  │   ...                                 │  │
  └───────────────────────────────────────┘  │
                                             │
                                             └─ This is the redundant pair (CLI only)

Key structural differences

AspectCLITUI
Where validation lives One big “all-validation” block early (plan line 1742-1779); flags and validation tightly coupled in one function. Input validation in the dialog (ForkDialog.Validate()); the cmd handler does git work.
Error idiom os.Exit(1) after out.Error(...). Return sessionForkedMsg{err, sourceID} for the update loop to display.
Reuse-block ancestry Inherited from today’s code at session_cmd.go:721 → plan line 1785-1796. Inherited from today’s code at home.go:8499-8501 → plan line 2960-2968.
Where the new with-state collision check was added In both the early validation block (Check #1a) and the reuse block (Check #2). Only in the reuse block (line 2960-2968), the sole collision check.

The asymmetry happened because the CLI plan author wrote the gate first and then “defensively” mirrored into the reuse block, while the TUI plan author saw the existing reuse block as the natural insertion point and stopped there.

Decision — four options

Choose how to resolve the redundancy

Pick an option to copy a decision string back to chat.

Option A — Remove CLI Check #2’s with-state branch
Surfaces stay asymmetric (CLI checks early; TUI in reuse block); each has a single source of truth. Simplest change. ~10 plan lines.
Option B — Make TUI symmetric to CLI
Add an early collision check to TUI; remove TUI’s reuse-block with-state branch. Imposes symmetry where the surfaces have legitimately different shapes. Not recommended.
Option C — Keep the redundancy (status quo)
Defense-in-depth that doesn’t actually defend (the CreateWorktreeAtStartPoint internal check already catches the slip). Adds reader confusion. Not recommended.

Why D is best for maintainability

Why D is best for user experience

UX is unaffected by any of these options — the user gets the same error messages regardless. CLI prints via out.Error, TUI returns the error from the cmd handler. The shared helper returns a typed error; each surface formats it appropriately.

Argument against D

D is the biggest change: touches internal/git/worktree_with_state.go (add helper + new error type + 2-3 tests), cmd/agent-deck/session_cmd.go (replace 2 inline checks with 1 helper call), internal/ui/home.go (replace inline check with 1 helper call), and the spec (update test inventory + architecture description).

Extracting helpers prematurely is a real anti-pattern. Two callers is the minimum threshold for DRY; if a third use case never materializes, the indirection added cost without payoff. Here we have exactly two callers (CLI and TUI), and the FWS-009 precedent says this is the time to extract.

Decision copied to clipboard