Git Worktrees
Adapted from the superpowers plugin (MIT).
Overview
Ensure work happens in an isolated workspace. Prefer your platform's native worktree tools. Fall back to manual git worktrees only when no native tool is available.
Core principle: Detect existing isolation first. Then use native tools. Then fall back to git. Never fight the harness.
Announce at start: "I'm using the git-worktrees skill to set up an isolated workspace."
Step 0: Detect Existing Isolation
Before creating anything, check if you are already in an isolated workspace.
GIT_DIR=$(cd "$(git rev-parse --git-dir)" 2>/dev/null && pwd -P)
GIT_COMMON=$(cd "$(git rev-parse --git-common-dir)" 2>/dev/null && pwd -P)
BRANCH=$(git branch --show-current)
Submodule note: a plain submodule does NOT trip this test - measured with these exact commands,
GIT_DIR and GIT_COMMON both resolve to <super>/.git/modules/<name>, so they compare equal and
the submodule reads as a normal checkout, which is how you want to treat it. The pair differs only
in a linked worktree. Run this if you want it stated explicitly, or when a submodule may itself
have a worktree attached:
# Returns a path when you are inside a submodule; empty otherwise
git rev-parse --show-superproject-working-tree 2>/dev/null
If GIT_DIR != GIT_COMMON (and not a submodule): You are already in a linked worktree. Skip to Step 2 (Project Setup). Do NOT create another worktree.
Report with branch state:
- On a branch: "Already in isolated workspace at
<path>on branch<name>." - Detached HEAD: "Already in isolated workspace at
<path>(detached HEAD, externally managed). Branch creation needed at finish time."
If GIT_DIR == GIT_COMMON (or in a submodule): You are in a normal repo checkout.
Has the user already indicated their worktree preference in your instructions? If not, ask for consent before creating a worktree:
"Would you like me to set up an isolated worktree? It protects your current branch from changes."
Honor any existing declared preference without asking. If the user declines consent, work in place and skip to Step 2.
Step 1: Create Isolated Workspace
You have two mechanisms. Try them in this order.
1a. Native Worktree Tools (preferred)
The user has asked for an isolated workspace (Step 0 consent). Do you already have a way to create a worktree? It might be a tool with a name like EnterWorktree, WorktreeCreate, a /worktree command, or a --worktree flag. If you do, use it and skip to Step 2.
Native tools handle directory placement, branch creation, and cleanup automatically. Using git worktree add when you have a native tool creates phantom state your harness can't see or manage.
Only proceed to Step 1b if you have no native worktree tool available.
1b. Git Worktree Fallback
Only use this if Step 1a does not apply - you have no native worktree tool available. Create a worktree manually using git.
Directory Selection
Follow this priority order. Explicit user preference always beats observed filesystem state.
Check your instructions for a declared worktree directory preference. If the user has already specified one, use it without asking.
Check for an existing project-local worktree directory:
ls -d .worktrees 2>/dev/null # Preferred (hidden) ls -d worktrees 2>/dev/null # AlternativeIf found, use it. If both exist,
.worktreeswins.If there is no other guidance available, default to
.worktrees/at the project root.Bind the outcome, because every step below refers to the directory you just chose:
LOCATION=$(ls -d .worktrees 2>/dev/null || ls -d worktrees 2>/dev/null || echo .worktrees) BRANCH_NAME=<your-topic-branch>
Safety Verification (project-local directories only)
MUST verify directory is ignored before creating worktree:
# Check the directory you CHOSE, not both names. `git check-ignore` matches a PATHNAME and
# does not care whether it exists, so testing both with `||` reports "ignored" whenever
# EITHER name is listed - including the one you are not about to use.
git check-ignore -q "$LOCATION" 2>/dev/null
If NOT ignored: Add to .gitignore, commit the change, then proceed.
Why critical: Prevents accidentally committing worktree contents to repository.
Create the Worktree
# $LOCATION and $BRANCH_NAME were bound in Directory Selection above. Unset, this
# expands to "/<branch>" at the filesystem root, and the failure looks like a sandbox denial.
path="$LOCATION/$BRANCH_NAME"
git worktree add "$path" -b "$BRANCH_NAME"
cd "$path"
Sandbox fallback: If git worktree add fails with a permission error (sandbox denial), tell the user the sandbox blocked worktree creation and you're working in the current directory instead. Then run setup and baseline tests in place.
Step 2: Project Setup
Auto-detect and run appropriate setup:
# Node.js
if [ -f package.json ]; then npm install; fi
# Rust
if [ -f Cargo.toml ]; then cargo build; fi
# Python (prefer uv)
if [ -f pyproject.toml ]; then uv sync 2>/dev/null || uv pip install -e .; fi
if [ -f requirements.txt ]; then uv pip install -r requirements.txt 2>/dev/null || pip install -r requirements.txt; fi
# Go
if [ -f go.mod ]; then go mod download; fi
Step 3: Verify Clean Baseline
Run tests to ensure workspace starts clean:
# Use project-appropriate command
npm test / cargo test / pytest / go test ./...
If tests fail: Report failures, ask whether to proceed or investigate.
If tests pass: Report ready.
Report
Worktree ready at <full-path>
Tests passing (<N> tests, 0 failures)
Ready to implement <feature-name>
Step 4: Finishing - Reclaim the Per-Topic Build Cache (wtclean)
git worktree remove deletes the checkout and nothing else. The per-worktree build cache from
Step 2 lives OUTSIDE the checkout on purpose (that is what stops several worktrees fighting over
one CARGO_TARGET_DIR), so it survives the removal and piles up invisibly - usually noticed only
when the disk fills.
scripts/wtclean.py removes the worktree AND those caches together, and shows what it will take
before it takes anything:
uv run scripts/wtclean.py my-feature # the plan, with sizes - deletes nothing
uv run scripts/wtclean.py my-feature --apply # remove exactly what the plan listed
uv run scripts/wtclean.py .worktrees/my-feature --cache-dir ~/.cache/targets/my-feature --apply
Cache locations are a convention, not a discovery. Git cannot be asked where your build cache
lives, so the default candidates are <base>/wt-<topic>-target and <base>/wt-<topic>-clippy,
with <base> your home directory. If yours live somewhere else, name them with --cache-dir
(repeatable) or adjust --base / --prefix / --cache-suffix. A run that matches nothing says
which paths it checked rather than reporting an empty plan as though you had no caches.
The worktree is found the same way, and the search is wider than the cache one. A bare
topic resolves to the first of these that exists: <base>/wt-<topic>, then .worktrees/<topic>,
worktrees/<topic> and .claude/worktrees/<topic> under the directory you run it from, which
covers both layouts Step 1b creates and the one the native worktree tool creates. Naming
--base confines the search to that base. Any other layout is reached by passing the worktree
path outright (wtclean.py .config/wt/my-feature) instead of the bare name.
Two consequences worth knowing before you trust a bare name:
- The three project-local candidates are relative to the directory you run from, not to the
repository root, so running it from a subdirectory checks paths that do not exist and leaves
<base>as the only real candidate. Run it from the directory that holds.worktrees/or.claude/worktrees/. - A bare topic matching more than one candidate is REFUSED, naming each one, and no flag
overrides it -
--discard-uncommittedanswers "delete it anyway", not "pick one for me". The refusal covers the CACHES too, unlike every other worktree refusal: cache candidates are built from the topic alone, so two checkouts sharing a name share them, and a run that could not resolve the name must not delete anything derived from it. A dirty or symlinked worktree is a RESOLVED one, so its caches are unambiguously its own and still go. A stale<base>/wt-<topic>beside a real project-local checkout is a question, and this is a delete with no undo, so answer it by passing the path you mean. When exactly one matches, the plan names it on itswould removeline.
A search that finds nothing names every path it tried, which is the half that was missing when a
bare name reaching only <base> reported nothing to remove for a 513 MB checkout sitting in
.claude/worktrees/.
What it refuses, because a delete is not undoable. Every refusal below is decided at PLAN time,
so the dry run lists them as REFUSED rather than leaving them for --apply to discover. An
--apply can still report a FAILED on top of those - a removal that git or the filesystem
rejects is not knowable until it is attempted:
- It is a dry run until
--apply, and--applyremoves exactly what the plan listed - it does not re-scan, so a directory created after you read the plan is not swept up with it. - A worktree holding uncommitted or untracked work is refused.
--discard-uncommittedoverrides that and forwards--forcetogit worktree remove, which DISCARDS the work. - A target that is a symbolic link is refused: removing through a link can destroy data outside the directory you named. On Windows this does not cover a directory JUNCTION, which is not reported as a symbolic link; the "resolves outside the base" refusal is what covers that.
- A parent reference anywhere in the argument (
../../etc,wt-a/../../etc) is refused outright, never normalised - taking the basename first would turn../../etcinto the innocent-looking nameetc. A path WITHOUT a parent reference is not refused: it names the worktree directly (relative to where you run from, like any path you type), which is what the third example above does. What has to be a bare name is the TOPIC, and the topic is the argument's last path segment with the prefix stripped - it is what the cache candidates are built from, so.worktrees/wt-my-featurestill looks for<base>/wt-my-feature-target.
Exit codes: 0 = nothing blocked, 1 = something was refused or could not be removed, 2 = usage
error. --json emits the machine-readable envelope; warnings go to stderr.
Quick Reference
| Situation | Action |
|---|---|
| Already in linked worktree | Skip creation (Step 0) |
| In a submodule | Treat as normal repo (Step 0 guard) |
| Native worktree tool available | Use it (Step 1a) |
| No native tool | Git worktree fallback (Step 1b) |
.worktrees/ exists |
Use it (verify ignored) |
worktrees/ exists |
Use it (verify ignored) |
| Both exist | Use .worktrees/ |
| Neither exists | Check instruction file, then default .worktrees/ |
| Directory not ignored | Add to .gitignore + commit |
| Permission error on create | Sandbox fallback, work in place |
| Tests fail during baseline | Report failures + ask |
| No package.json/Cargo.toml | Skip dependency install |
| Returning to an OLD worktree | git status --porcelain FIRST - a long-lived worktree can hold an abandoned prior op's dirty state; git stash push -u it. Never commit -a over it. |
| Sharing a build cache dir | Give each worktree its OWN CARGO_TARGET_DIR (or equivalent). One shared incremental cache across trees with different sources serializes builds on the target lock, and makes the compiler link the OTHER worktree's crate and emit phantom errors that name real symbols - so it reads as a defect in the code in front of you and sends you editing correct code. THE TELL: grep your own worktree for the name the compiler SUGGESTS; zero hits means you are reading a sibling's build. (Measured: nine errors of the form no method named X found, help: there is a method X_ns with a similar name, where the suggested name existed in no file of the tree being built.) Use a compiler cache (sccache) for cross-tree reuse instead. |
| Worktree deleted, disk still full | Its per-topic build cache is still there - git worktree remove never touches it. uv run scripts/wtclean.py <topic> [--apply] (Step 4) |
Common Mistakes
Fighting the harness
- Problem: Using
git worktree addwhen the platform already provides isolation - Fix: Step 0 detects existing isolation. Step 1a defers to native tools.
Skipping detection
- Problem: Creating a nested worktree inside an existing one
- Fix: Always run Step 0 before creating anything
Skipping ignore verification
- Problem: Worktree contents get tracked, pollute git status
- Fix: Always use
git check-ignorebefore creating project-local worktree
Assuming directory location
- Problem: Creates inconsistency, violates project conventions
- Fix: Follow priority: explicit instructions > existing project-local directory > default
Proceeding with failing tests
- Problem: Can't distinguish new bugs from pre-existing issues
- Fix: Report failures, get explicit permission to proceed
Red Flags
Never:
- Create a worktree when Step 0 detects existing isolation
- Use
git worktree addwhen you have a native worktree tool (e.g.,EnterWorktree). This is the #1 mistake - if you have it, use it. - Skip Step 1a by jumping straight to Step 1b's git commands
- Create worktree without verifying it's ignored (project-local)
- Skip baseline test verification
- Proceed with failing tests without asking
Always:
- Run Step 0 detection first
- Prefer native tools over git fallback
- Follow directory priority: explicit instructions > existing project-local directory > default
- Verify directory is ignored for project-local
- Auto-detect and run project setup
- Verify clean test baseline