Worktree Lifecycle for Parallel Work: add, prune, repair
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 appCase 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/authPreparing 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/authef29406 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 listRemoving 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 --porcelainworktree /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 4: remove protects your work, mv breaks the link, repair fixes it
git worktree unlock ../app-auth
echo wip > ../app-auth/x
git worktree remove ../app-authfatal: '../app-auth' contains modified or untracked files, use --force to delete it
remove refuses to throw away uncommitted work. That safety check is exactly what rm -rf skips.
Now move the worktree with plain mv:
mv ../app-auth ../moved
git worktree list
git -C ../moved status --short
git worktree repair ../moved
git worktree list
cat ../moved/.git
cat .git/worktrees/app-auth/gitdir/tmp/wt/app 06fb4a0 [main]
/tmp/wt/app-auth ef29406 [feat/auth] prunable
?? x
repair: gitdir incorrect: /tmp/wt/app/.git/worktrees/app-auth/gitdir
/tmp/wt/app 06fb4a0 [main]
/tmp/wt/moved ef29406 [feat/auth]
/tmp/wt/moved/.git
gitdir: /tmp/wt/app/.git/worktrees/app-auth
Inside ../moved, Git still works (the forward link is intact). From the main repository the worktree looks dead, and a prune at that moment would have deleted its entry. repair rewrote the back-link. Better still, move worktrees with git worktree move, which updates both links, and run repair from inside the main repository after moving the repository itself.
Case 5: Relative links for repositories that move
If the whole tree gets moved together (a container bind mount at a different path, a synced home directory), use relative links:
git worktree add --relative-paths -b feat/rel ../app-rel
cat ../app-rel/.git
cat .git/worktrees/app-rel/gitdir
git config --get extensions.relativeWorktreesPreparing worktree (new branch 'feat/rel')
HEAD is now at 06fb4a0 init
gitdir: ../app/.git/worktrees/app-rel
../../../../app-rel/.git
true
Git turns on the repository extension extensions.relativeWorktrees, so Git versions older than 2.48 will refuse to open the repository. Set worktree.useRelativePaths=true to make it the default for new worktrees.
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 READMEfor i in 1 2 3; do sh ../build-in-worktree.sh main; done
git worktree listbuilding 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/worktreesapp-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 withgit worktree remove(in atrapfor scripts), move it withgit worktree move, and lock anything on storage that comes and goes. Start every worktree-creating script withgit worktree prune, and read state withgit worktree list --porcelain, never by listing directories.
Try this
- Run Case 6 with the
rm -rfvariant ten times, then list.git/worktrees/. Rungit worktree prune -vand count the lines. - Move the main repository (
mv /tmp/wt/app /tmp/wt/app2), rungit -C /tmp/wt/moved status, and then fix it withgit worktree repair /tmp/wt/movedfrom insideapp2. - Set
gc.worktreePruneExpire=now, create andrm -rfa worktree, and confirm thatgit gcremoves the entry. - Write a harness that starts three parallel jobs, each in its own
--detachworktree, and prove withgit worktree list --porcelainthat none survive akillof the harness (hint: trapINTandTERMas well asEXIT).
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
gcprunes worktree metadata - Git release notes: 2.31 (
prunableinworktree list), 2.48 (relative worktree paths)