Skip to content

V025 Multi-Worktree Workflow

Problem

A V025 checkout is not a single working directory. It is several git worktrees sharing one repository, each playing a distinct role — but those roles are nowhere documented. Every new contributor and every Claude session re-derives them from git error messages and history:

  • worktree-manager.sh create fails because main is checked out in some other worktree.
  • A merge into main requires a "dance" — you cd into a different worktree to run the merge, because you can't check main out where you are.
  • The active workspace carries a long-lived branch whose name has drifted from its content, and the fix (rename in place) is an unwritten convention.
  • Spec numbers assigned on unmerged branches collide with numbers the current worktree can't see.

Each symptom has its own surface explanation, so the shared root cause stays invisible. The knowledge lives in lived practice and in per-user MEMORY.md, but memory is the wrong home: it truncates, drifts as state changes, and is per-user not per-repo. These are repo-level toolkit conventions and belong in the toolkit's documentation surface. This doc is that home.

The three roles

V025 uses three worktree roles, each with a distinct lifecycle. A worktree's role is defined by the branch it holds, not by its path.

Role Branch it holds Lifecycle
Main-Holder main The worktree that currently holds mainoften none. main is frequently checked out nowhere; it only needs a holder at merge time, and that holder is usually thrown away afterward. Not a standing role — a transient state. See merge into main and EC-1 below.
Active-Workspace a long-lived chore/* or docs/* branch The repo-root checkout where day-to-day tooling commands run and commit (intake-qualify routings, spec births, doc-sync updates). Accumulates commits between merges to main. When its branch name no longer reflects its content, it is renamed in place, not deleted — it is the only checkout this worktree has. See rename the active-workspace branch.
Feature feature/spec-NNN-* One per active spec, created from main via worktree-manager.sh create, living under .worktrees/feature/. Built through the spec workflow (/vt-c-2-plan/vt-c-3-build → …), then merged back into main after /vt-c-complete. Removed once merged.

Why "Main-Holder" is a transient state, not a standing role. Because main is usually held nowhere, there is normally no Main-Holder to find. Merges are performed by temporarily giving some worktree main (or spinning up a throwaway holder), merging, then releasing it. If you go looking for a permanent main-holder worktree, you often won't find one — and that is correct, not a broken state.

Edge cases referenced in this doc

Three recurring situations are labeled EC-1 / EC-2 / EC-3 throughout the procedures below:

Standard procedures

Create a feature worktree

From the Active-Workspace, always use the manager script (never raw git worktree add — the script copies .env* files, manages .gitignore, and keeps the directory layout consistent):

bash ${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh create feature/spec-NNN-slug

The script creates the branch from main and handles the held-main case gracefully (fixed at commit b53fed62). /vt-c-activate Step 6.7 offers to do this for you at activation time.

Side effect to undo when main is held nowhere (EC-1). The script's currency step runs git checkout main in the Active-Workspace before adding the worktree. When main is held elsewhere that checkout fails harmlessly and is skipped; but when main is held nowhere it succeeds and leaves the Active-Workspace sitting on main — HEAD is never restored. After the command returns, confirm with git -C "<active-workspace-path>" branch --show-current and, if it now reads main, restore it: git -C "<active-workspace-path>" checkout <active-workspace-branch>.

Merge into main

Never check out main in the Active-Workspace to merge. If main is held in another worktree you'll get a already used by worktree error (see anti-patterns); if it's held nowhere you could check it out, but the convention is to keep the Active-Workspace on its own long-lived branch. Instead:

  1. Find who holds main, if anyone:
    git worktree list --porcelain | awk '
      /^worktree /{p=$0; sub(/^worktree /,"",p)} /^branch refs\/heads\/main$/{print p}'
    
  2. If a holder path is printed (an absolute path from step 1), merge the source branch in that worktree without changing your own cwd:
    git -C "<main-holder-path>" merge --no-ff <source-branch>
    
  3. If nothing is printed (main held nowhere — the common case, EC-1), create a throwaway holder worktree on main, merge in it, then remove it:
    # Anchor every path on the MAIN worktree root (the first entry git lists), so these
    # steps work unchanged from any cwd — repo root OR a feature worktree.
    root="$(git worktree list --porcelain | awk 'NR==1{sub(/^worktree /,"",$0);print;exit}')"
    
    # Attach the EXISTING main branch with raw `git worktree add` — the one documented
    # exception to "always use the manager script". The script can't do this: it always
    # passes `-b` (→ `fatal: a branch named 'main' already exists`) and its currency step
    # would `git checkout main` in the Active-Workspace, stranding it on main.
    git worktree add "$root/.worktrees/main-holder" main
    git -C "$root/.worktrees/main-holder" merge --no-ff <source-branch>
    git worktree remove "$root/.worktrees/main-holder"
    

The pre-commit hook has a MERGE_HEAD exception, so --no-ff merges into main need no --no-verify. This is a local ephemeral-branch workflow — main is not pushed by default.

Sync a fresh feature branch after a main-merge

A feature worktree created from main before the day's Active-Workspace commits were merged is now behind. Bring it current from inside the feature worktree (EC-3):

git merge main

Do this right after creation if main moved between when the feature branch forked and when you started work.

Rename the active-workspace branch

When the Active-Workspace branch name no longer fits what's on it (EC-2), rename in place rather than deleting — the branch is this worktree's only checkout:

git branch -m <old-name> <new-name>

(Real example: docs/doc-sync-2026-05-18chore/scratch once the branch had accumulated more than doc-sync work.)

Anti-patterns & error signatures

You see / you do What it means What to do instead
fatal: '<branch>' is already used by worktree '<path>' The branch (often main) is checked out in the worktree at <path>. Git allows a branch in only one worktree at a time. Don't check it out here. cd "<path>" and work there, or use a throwaway holder. See merge into main.
Checking out main in the Active-Workspace to merge Breaks the role separation; fails outright if main is held elsewhere. Merge from the Main-Holder (or a throwaway holder), never from the Active-Workspace.
Deleting the Active-Workspace branch because its name is stale Loses the worktree's only checkout and its accumulated pre-merge commits. git branch -m to rename in place.
New feature worktree's first build sees stale main state The branch forked from main before a recent merge. git merge main inside the feature worktree (EC-3).
spec-from-requirements proposes a SPEC number that already exists elsewhere The tool sees only the current worktree's registry, not IDs on unmerged branches or in main since you forked. Compute the true next-free ID first — see spec-numbering across branches.

Spec-numbering across branches

Why this manual step exists: /vt-c-spec-from-requirements reads only the current worktree's .design-state.yaml when choosing the next SPEC ID. In a fragmented multi-worktree checkout that view is incomplete, so the number it proposes can collide. This section is a manual patch over that single-worktree limitation — if spec-from-requirements is ever fixed to scan all branches, this procedure can be retired.

There are two ways the numbering can go wrong. Compute both and take the maximum, then use max + 1.

Mode A — fragmented-ahead

Spec numbers live on unmerged feature branches carrying IDs higher than main's registry. The true next-free ID is the max across all local branches' committed registry:

# Highest SPEC ID declared on ANY local branch (not just the current worktree)
for b in $(git branch --format='%(refname:short)'); do
  git show "$b:.design-state.yaml" 2>/dev/null | grep -oE 'SPEC-[0-9]+'
done | grep -oE '[0-9]+$' | sort -n | tail -1

git show sees only committed registry state. A spec ID can also exist as an on-disk directory that is uncommitted or not-yet-registered (exactly the drift window that caused the 2026-07-04 incident). Add the companion scan and take the max of the two:

# Run from the repo root that physically contains .worktrees/ (the main worktree).
# One `.` walk covers both the root's own specs/ and every worktree under .worktrees/.
find . -type d -path '*/specs/[0-9]*' 2>/dev/null \
  | grep -oE 'specs/[0-9]+' | grep -oE '[0-9]+' | sort -n | tail -1

Mode B — stale-behind-main

The current branch forked from main long ago and is many commits behind; its registry does not reflect IDs main has since assigned. Reading the current branch tells you nothing about this. Check divergence before creating a spec on any long-lived branch:

git rev-list --left-right --count main...HEAD   # output: "<behind>\t<ahead>" vs main

If <behind> is non-trivial, reconcile against main's registry before picking a number:

git show main:.design-state.yaml | grep -oE 'SPEC-[0-9]+' | grep -oE '[0-9]+$' | sort -n | tail -1

Take the max of Mode A and Mode B results; the next-free ID is that + 1.

Illustrative layout example

This is an illustrative example of the role pattern at one point in time — your checkout will differ. It shows the shape, it is not a maintained inventory. Run git worktree list yourself for the current picture.

A git worktree list might look like this:

V025-claude-toolkit                         [chore/scratch]        ← Active-Workspace (repo root)
.worktrees/feature/spec-149-...             [feature/spec-149-...] ← Feature
.worktrees/feature/spec-151-...             [feature/spec-151-...] ← Feature
V025-wt-ims-workflow-gates                  [docs/doc-sync-...]    ← a sibling worktree (not currently holding main)

Note what's absent: no line shows [main]. In this snapshot main is held nowhere — the normal resting state (EC-1). A Main-Holder appears only transiently, at merge time.

Where this came from

  • SPEC-143 activation session (2026-05-28) surfaced the three symptoms — a worktree-manager.sh failure under held main, the cross-worktree merge dance, and the implicit branch-rename — and recognized them as one missing convention doc. Source proposal: intake/processed/from-projects/2026-05-28-v025-multi-worktree-workflow-conventions.md.
  • The 2026-07-04 spec-numbering incident is the worked example for Mode B: specs were created on chore/scratch, which had forked from main and was ~60 commits behind, so the tool mis-numbered them; the fix was a surgical re-apply of only the genuinely-new specs onto current main. This very SPEC-151 session then hit the fragmented-ahead hazard it documents — a scan of all branches surfaced a dropped SPEC-152 still living on chore/scratch, higher than main's registry.
  • The surface bug behind symptom 1 was fixed separately at commit b53fed62 (intake/processed/bugs/2026-05-28-resolved-worktree-manager-fails-when-from-branch-checked-out-elsewhere.md).
  • plugins/core-standards/skills/using-git-worktrees/SKILL.md — how to create an isolated worktree
  • plugins/core-standards/skills/git-worktree/SKILL.md — the worktree-manager.sh wrapper
  • plugins/core-standards/skills/activate/SKILL.md — Step 6.7 detects the held-main case at activation
  • docs/solutions/patterns/cross-repo-spec-tracking.md — the sibling "one repo, one spec namespace" rule
  • intake/projects.yaml — registry of projects for cross-project awareness