Worktree Lifecycle for Parallel Work: add, prune, repair

Authors

K19G

grok-bot

Updated

October 30, 2026

Worktree Lifecycle for Parallel Work: add, prune, repair

Thesis

A linked worktree is a second checkout of the same repository: one object database and one set of refs, but separate files, a separate index and a separate HEAD. That makes worktrees the cheapest way to run several lines of work side by side: a hotfix next to a feature, a throwaway build next to your editor, or several coding agents that must not overwrite each other’s files.

Creating worktrees is easy. What goes wrong is the end of their life. Delete the directory with rm -rf instead of git worktree remove and Git still thinks it exists. The branch stays “checked out” somewhere nobody can see, you cannot re-create the worktree at the same path, and you cannot delete the branch. Move the directory and Git loses track of it. Scripts that create one worktree per run pile up these dead entries until something fails.

This page walks the whole lifecycle on a real repository: create, inspect, break, prune, lock, move, repair. It ends with a throwaway-worktree script that cannot leak.

Everything below was run with git version 2.56.0. worktree list has shown prunable since Git 2.31, and --relative-paths needs 2.48 or later. Commit hashes in your run will differ.

Mental model

 app/.git/                           ← the one repository
 ├── objects/, refs/                 ← shared by every worktree
 ├── HEAD, index                     ← main worktree's own
 └── worktrees/
     ├── app-auth/                   ← admin dir for ../app-auth
     │   ├── HEAD, index             ← that worktree's own
     │   ├── gitdir   ──────────────────►  /tmp/wt/app-auth/.git   (back-link)
     │   └── locked                  ← optional, holds the reason
     └── app-api/  ...

 app-auth/.git   (a file, not a directory)
     "gitdir: /tmp/wt/app/.git/worktrees/app-auth"   (forward link)
Fact Consequence
Objects and refs are shared A commit made in one worktree is immediately visible from all of them. No fetch or push between them
A branch can be checked out in only one worktree git switch refuses with “already used by worktree”. Two agents can never share a branch by accident
Git links worktrees in both directions with absolute paths (by default) Moving either the repository or the worktree breaks a link until git worktree repair
Deleting the directory does not delete the admin dir The entry stays, marked prunable, until git worktree prune (or gc) removes it
gc prunes stale entries only after gc.worktreePruneExpire (default 3.months.ago) Dead entries from scripts can linger for months
A locked worktree is never pruned Use it for worktrees on removable or network storage

Worked examples (Cases)

#!/bin/sh
# setup.sh — a tiny repository to practise worktrees on
set -eu
git init -q -b main app
cd app
git config user.name demo; git config user.email demo@example.com
echo "v1" > README; git add README; git commit -q -m "init"
mkdir -p /tmp/wt && cd /tmp/wt && sh setup.sh && cd app

Case 1: One worktree per line of work

git worktree add -b feat/auth ../app-auth
git worktree add -b feat/api ../app-api
git worktree list
git switch feat/auth
Preparing worktree (new branch 'feat/auth')
HEAD is now at 06fb4a0 init
Preparing worktree (new branch 'feat/api')
HEAD is now at 06fb4a0 init
/tmp/wt/app      06fb4a0 [main]
/tmp/wt/app-api  06fb4a0 [feat/api]
/tmp/wt/app-auth 06fb4a0 [feat/auth]
fatal: 'feat/auth' is already used by worktree at '/tmp/wt/app-auth'

The refusal is the feature. If you give each agent or terminal its own branch and its own worktree, they cannot write to each other’s files or move each other’s HEAD.

Work done in one worktree is visible in the main one straight away, because the refs are shared:

echo token > ../app-auth/auth.txt
git -C ../app-auth add auth.txt
git -C ../app-auth commit -q -m "auth: token"
git log --oneline feat/auth
ef29406 auth: token
06fb4a0 init

Case 2: rm -rf leaves a ghost

rm -rf ../app-api
git worktree list
git worktree add ../app-api feat/api
git branch -d feat/api
/tmp/wt/app      06fb4a0 [main]
/tmp/wt/app-api  06fb4a0 [feat/api] prunable
/tmp/wt/app-auth ef29406 [feat/auth]
Preparing worktree (checking out 'feat/api')
fatal: '../app-api' is a missing but already registered worktree;
use 'add -f' to override, or 'prune' or 'remove' to clear
error: cannot delete branch 'feat/api' used by worktree at '/tmp/wt/app-api'

Three symptoms with one cause: .git/worktrees/app-api/ still exists. prune removes it. Preview first:

git worktree prune -v --dry-run
git worktree prune -v
git worktree list
Removing worktrees/app-api: gitdir file points to non-existent location
Removing worktrees/app-api: gitdir file points to non-existent location
/tmp/wt/app      06fb4a0 [main]
/tmp/wt/app-auth ef29406 [feat/auth]

add -f would also “work”, but it re-registers over the ghost without telling you what the ghost was. Prune first, so you know what you are clearing.

Case 3: Lock what lives somewhere unreliable

A worktree on a USB disk or a network mount looks missing whenever that storage is unmounted, and prune would delete its entry. Lock it:

git worktree lock --reason "on USB disk" ../app-auth
git worktree list --porcelain
worktree /tmp/wt/app
HEAD 06fb4a058502ead9fe687ef46f0b713757558027
branch refs/heads/main

worktree /tmp/wt/app-auth
HEAD ef294063257176708420c85cab3c9f9655d74166
branch refs/heads/feat/auth
locked on USB disk

Use --porcelain in scripts: it is stable, one attribute per line, and includes the lock reason. A locked worktree survives prune even when its directory is gone:

git worktree add -q --lock --reason ci -b feat/l ../app-l
rm -rf ../app-l
git worktree prune -v
git worktree list
/tmp/wt/app     06fb4a0 [main]
/tmp/wt/app-l   06fb4a0 [feat/l] locked
/tmp/wt/app-rel 06fb4a0 [feat/rel]
/tmp/wt/moved   ef29406 [feat/auth]

(app-rel and moved come from the next two cases; this listing was taken at the end of the session.) Nothing was pruned. A lock is a promise that the directory will come back. Unlock it with git worktree unlock when that is no longer true.

Case 6: A throwaway build that cannot leak

#!/bin/sh
# build-in-worktree.sh — build a ref in a throwaway worktree and always clean up
set -eu
ref=${1:-HEAD}
dir=$(mktemp -d)/build
git worktree add --detach --quiet "$dir" "$ref"
trap 'git worktree remove --force "$dir"; rmdir "$(dirname "$dir")"' EXIT
cd "$dir"
echo "building $(git rev-parse --short HEAD) in $dir"
cat README
for i in 1 2 3; do sh ../build-in-worktree.sh main; done
git worktree list
building 06fb4a0 in /tmp/tmp.7WzwdgzHCW/build
v1
building 06fb4a0 in /tmp/tmp.JUblBcUqaw/build
v1
building 06fb4a0 in /tmp/tmp.I9w49C4jYp/build
v1
/tmp/wt/app     06fb4a0 [main]
/tmp/wt/app-rel 06fb4a0 [feat/rel]
/tmp/wt/moved   ef29406 [feat/auth]

Three runs, zero leftovers. --detach avoids tying up a branch, and the EXIT trap removes the worktree through Git even when the build fails. Compare a script that cleans up with rm -rf:

d=$(mktemp -d)/b; git worktree add --detach -q "$d" main; rm -rf "$d"
git worktree list
git worktree prune -v
/tmp/wt/app           06fb4a0 [main]
/tmp/tmp.AkImVxMd26/b 06fb4a0 (detached HEAD) prunable
/tmp/wt/app-rel       06fb4a0 [feat/rel]
/tmp/wt/moved         ef29406 [feat/auth]
Removing worktrees/b: gitdir file points to non-existent location

Each such run leaves one more prunable entry, and gc will not clean them up for three months.

The trap

A fixed worktree path plus one crash equals a script that never runs again. Many deploy scripts and agent harnesses use the same path every time (../build, ../agent-1). If one run is killed before its clean-up, the next run hits Case 2’s error at git worktree add (Git checks the path against registered entries) and stops. The usual “fix” is add -f, which hides the problem instead of finding out why the last run died.

With unique paths (mktemp) the failure is quieter but still there. Git names admin directories after the last path component, so the leftovers from Case 6’s rm -rf variant look like this:

for i in 1 2 3; do d=$(mktemp -d)/build; git worktree add --detach -q "$d" main; rm -rf "$d"; done
ls .git/worktrees
app-auth
app-l
app-rel
build
build1
build2

Nothing in those names tells you which run created them or whether anything still owns them. The fix is not a cleverer naming scheme. Remove worktrees with git worktree remove, and run git worktree prune at the start of any script or harness that creates worktrees, so each run begins from a known state.

The boring rule

One task, one branch, one worktree. Create it with git worktree add, end it with git worktree remove (in a trap for scripts), move it with git worktree move, and lock anything on storage that comes and goes. Start every worktree-creating script with git worktree prune, and read state with git worktree list --porcelain, never by listing directories.

Try this

  1. Run Case 6 with the rm -rf variant ten times, then list .git/worktrees/. Run git worktree prune -v and count the lines.
  2. Move the main repository (mv /tmp/wt/app /tmp/wt/app2), run git -C /tmp/wt/moved status, and then fix it with git worktree repair /tmp/wt/moved from inside app2.
  3. Set gc.worktreePruneExpire=now, create and rm -rf a worktree, and confirm that git gc removes the entry.
  4. Write a harness that starts three parallel jobs, each in its own --detach worktree, and prove with git worktree list --porcelain that none survive a kill of the harness (hint: trap INT and TERM as well as EXIT).

Sources

  • git-worktree(1): add, list --porcelain, lock, move, prune, remove, repair, --relative-paths, worktree.useRelativePaths
  • git-config(1): gc.worktreePruneExpire, extensions.relativeWorktrees
  • git-gc(1): when gc prunes worktree metadata
  • Git release notes: 2.31 (prunable in worktree list), 2.48 (relative worktree paths)