Decision document · fork-with-state · 2026-05-17
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, 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.
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.
--with-state -w fork/new, no conflictsNote that GetWorktreeForBranch is called twice at steps 3a and 5. Both return empty. Step 5’s redundancy is benign here.
--with-state -w fork/exp where fork/exp already has a worktreeThe plan author’s defensive justification: imagine a future refactor that moves preflight elsewhere and accidentally drops Check #1a:
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.
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)
| Aspect | CLI | TUI |
|---|---|---|
| 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.
Pick an option to copy a decision string back to chat.
internal/git.” Adding a second helper (collision) alongside the first (preflight) is consistent.ValidateForkWithStateDestination cover the validation logic once.
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.
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.