Skip to content

ADR 0052: Worktrees live in a configurable sibling directory, not nested .claude/worktrees

Vocabulary amendment: Current pointers use graduateaccept; the decision and reasoning are unchanged.

Status: accepted

Context

§

discern is stack- and agent-neutral, but its isolated-worktree workflow placed every linked worktree at <repo>/.claude/worktrees/<name> — a path baked into the Claude Code worktree-create hook. That location was wrong on three counts:

  1. .claude/ is Claude-Code-specific. Nesting every agent's worktree under one vendor's folder is a category error for an agent-agnostic tool. A Codex or Gemini session has no reason to live under .claude/.
  2. A worktree nested inside its own repo is a documented anti-pattern. Recursive tools double-count it — including discern's own gate, which globs the tree under **; IDEs mis-index the nested checkout; and a tool that walks up to find the repo root mis-resolves the worktree's .git file (a gitlink, not a directory) when it sits beneath the main checkout.
  3. The main checkout sits on the parent path of the worktree. That makes "accidentally edit or commit in the main repo while meaning to work in the worktree" an easy fat-finger, since the main tree literally contains the worktree.

Crucially, the engine was already location-agnostic by design: it discovers worktrees through git's own registry (git worktree list, the admin dir <common>/.git/worktrees/<key>) and derives identity from the git admin-dir basename / DISCERN_WORKTREE_ID — never the checkout path. So the bad location assumption was contained to the Claude-Code feature layer (the create hook) plus tests and docs, not the stack-neutral core.

Decision

§

Place worktrees in a configurable directory, defaulting to a sibling of the repo. For a repo at /path/to/<repo>, worktrees go in /path/to/<repo>.worktrees/<name> — visible, adjacent, and outside the checkout entirely.

  • New config key [worktree].root — the directory under which per-worktree <name> checkouts are created:
    • empty / unset (the default) ⇒ the computed sibling above;
    • a relative path ⇒ resolved against the repo root (.claude/worktrees restores the old nesting for anyone who wants it; ../wts a custom sibling);
    • an absolute path ⇒ used as-is.
  • One shared resolver. resolveWorktreeRoot(repoRoot, config) lives in the feature layer (src/lib/paths.ts), never the engine. Every spawn path goes through it: the create hook builds join(resolveWorktreeRoot(cwd, config), name), and worktree prune passes the resolved root as the orphan-sweep's extraDirs so a fully-orphaned root (no registered worktree left to derive its parent from) is still reclaimed. The dispatch layer — not src/engine/** — resolves and threads the root, preserving the ADR 0040 split: the engine knows no placement convention.
  • Flip the default immediately. discern is pre-public (local dogfooding only), so the move is forward-only: new worktrees land in the sibling location, and existing nested worktrees keep working because the engine finds them via git regardless of where they sit. A schema-12→13 migration surfaces the documented [worktree].root key (empty ⇒ sibling) in an existing config; it changes no behaviour (empty already is the new default) and never clobbers a hand-set value.

Consequences

§
  • The pitfalls are gone. The worktree is outside the repo, so no recursive tool double-counts it, no walk-up mis-resolves its .git file, and the main checkout is no longer the worktree's parent. git status in the main checkout stays clean the moment a worktree is created — without relying on a gitignore rule to hide it.
  • The gitignore no longer fences worktrees. templates/.gitignore.fragment's /.claude/* rule remains (it still covers the materialized .claude/skills/, and a project that opts back into [worktree].root = ".claude/worktrees"), but its stale "worktree checkouts" rationale is dropped.
  • The convention has exactly one home. Placement is computed in resolveWorktreeRoot and nowhere else; the engine and its agent-agnosticism guard (tests/agent_agnostic_test.ts) are untouched — the guard now also scans the shared engine-test harness so a test can't reintroduce the old .claude/worktrees assumption either.
  • "Restore the old nesting" is one config line. A project that preferred the nested layout sets root = ".claude/worktrees".
  • No data migration of existing worktrees. A worktree created before the flip is found, landed with accept, and pruned exactly as before — git tracks it by its admin dir, not its path. (Moving the repo would break a worktree's absolute .git link, but that is a pre-existing git property, unchanged here.)

Alternatives considered

§
  • Keep nesting under .claude/worktrees. Rejected: it is the source of all three pitfalls above, and the .claude/ prefix is wrong for an agent-agnostic tool.
  • Nest under a generic .discern/worktrees/ instead. Drops the Claude-specificity but keeps the worktree inside the repo, so the recursive-glob, .git-file-misresolution, and fat-finger problems all remain. Rejected in favour of a visible sibling.
  • Teach the engine the sibling convention directly. Rejected: it would reintroduce a location assumption into the stack-neutral core that ADR 0040 deliberately kept out. The engine keeps discovering worktrees from git; only the feature/dispatch layer knows where new ones go.
  • Make the move opt-in and keep nesting the default. Rejected: discern is pre-public and the nested default is known-bad, so carrying it forward only to spare a migration that isn't needed (the engine finds old worktrees regardless) trades a clean flip for lasting baggage.
choose openEsc close