I use git worktrees heavily for parallel development. One worktree per ticket, across dozens of repositories. They’re especially useful if you work with AI coding agents — each agent gets its own isolated worktree, so it can run tests, install dependencies, and make changes without stepping on your work or another agent’s. The downside is that worktrees accumulate fast. I ended up with 256 of them consuming 28GB of disk, 700+ stale local branches, and 70% of it all referencing branches that were merged months ago.
Here’s how I cleaned up both.
Worktrees vs branches Link to heading
These are different things, and both accumulate independently.
A branch is a lightweight pointer to a commit — just a 41-byte file under .git/refs/. Branches are cheap. Having hundreds of stale ones won’t eat your disk, but they do clutter git branch output and make it harder to find active work.
A worktree is a full working directory checked out to a specific branch. It contains a complete copy of your source files — node_modules, virtual environments, build artefacts, the lot. Worktrees are expensive. Each one can be hundreds of megabytes or more, depending on the project.
When a feature branch gets merged and deleted on the remote, both linger locally: the worktree directory keeps taking up disk, and the local branch ref stays in .git/refs/. Neither goes away on its own. Multiply that across 40+ repos and a few months of work, and you’ve got a lot of dead weight.
Finding stale worktrees Link to heading
A worktree is “stale” if its remote tracking branch no longer exists. After running git fetch --prune, you can check whether origin/<branch> still exists:
git rev-parse --verify refs/remotes/origin/<branch>
If that fails, the remote branch is gone and the worktree is a cleanup candidate.
The cleanup script Link to heading
I keep all worktrees in a .worktrees/ subdirectory within each repo (I covered this convention in my worktrees for parallel development post). This script iterates over every repo that has one, prunes remote tracking info, and removes worktrees whose branches are gone:
#!/bin/bash
set -euo pipefail
find ~/Code -maxdepth 4 -name ".worktrees" -type d | sort | while read worktrees_dir; do
repo_dir="$(dirname "$worktrees_dir")"
repo_name="$(basename "$repo_dir")"
git -C "$repo_dir" rev-parse --git-dir &>/dev/null || continue
git -C "$repo_dir" fetch --prune --quiet 2>/dev/null || true
for wt_path in "$worktrees_dir"/*/; do
[ -d "$wt_path" ] || continue
wt_name="$(basename "$wt_path")"
# Get the branch this worktree is on
branch=$(git -C "$wt_path" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "$wt_name")
# Skip if remote branch still exists
if git -C "$repo_dir" rev-parse --verify "refs/remotes/origin/$branch" &>/dev/null; then
continue
fi
echo "Removing: $repo_name/$wt_name"
if ! git -C "$repo_dir" worktree remove "$wt_path" 2>/dev/null; then
# Force remove if dirty (branch is gone anyway)
git -C "$repo_dir" worktree remove --force "$wt_path" 2>/dev/null || {
# Last resort for corrupted worktree metadata
rm -rf "$wt_path"
}
fi
done
git -C "$repo_dir" worktree prune 2>/dev/null || true
done
What each step does Link to heading
git fetch --prune updates remote tracking refs and removes any that point to deleted remote branches. Without this, git still thinks the remote branch exists locally.
git rev-parse --verify refs/remotes/origin/$branch checks if the remote branch is still there. This is the key check - if it fails, the branch was merged or deleted upstream.
git worktree remove cleanly detaches and removes the worktree directory. If the worktree has uncommitted changes, it refuses without --force.
git worktree prune cleans up stale worktree metadata in .git/worktrees/ for worktrees that were deleted manually (e.g. rm -rf) rather than through git worktree remove.
The three-tier removal Link to heading
The script tries three approaches in order:
- Normal remove - fails if the worktree has uncommitted changes
- Force remove - removes regardless of dirty state (safe because the remote branch is gone)
rm -rf+ prune - handles corrupted worktree metadata where git doesn’t recognise the directory as a worktree anymore
I’d recommend the force approach for stale worktrees. If the remote branch has been deleted, any local-only changes were either already pushed (and merged) or intentionally abandoned.
Cleaning up stale branches Link to heading
Removing worktrees is only half the job. Every merged branch also leaves behind a local branch ref. After git fetch --prune, these show up as [gone] in git branch -vv:
$ git branch -vv | grep ': gone]'
inf-749-remove-sentry a1b2c3d [origin/inf-749-remove-sentry: gone] remove sentry SDK
inf-802-pr-triggers e4f5g6h [origin/inf-802-pr-triggers: gone] skip CI on draft PRs
This script deletes all gone branches across every repo:
#!/bin/bash
set -euo pipefail
find ~/Code -maxdepth 3 -name ".git" -type d | sort | while read gitdir; do
repo_dir="$(dirname "$gitdir")"
# Skip worktree checkouts
[[ "$repo_dir" == */.worktrees/* ]] && continue
repo_name="$(basename "$repo_dir")"
git -C "$repo_dir" fetch --prune --quiet 2>/dev/null || continue
gone_branches=$(git -C "$repo_dir" branch -vv 2>/dev/null | grep ': gone]' | awk '{print $1}')
[ -z "$gone_branches" ] && continue
current=$(git -C "$repo_dir" branch --show-current 2>/dev/null)
deleted=0
while IFS= read -r branch; do
[ "$branch" = "$current" ] && continue
git -C "$repo_dir" branch -D "$branch" &>/dev/null && deleted=$((deleted + 1))
done <<< "$gone_branches"
[ $deleted -gt 0 ] && echo "$repo_name: deleted $deleted branches"
done
The key detail: git fetch --prune removes the remote tracking ref, which marks the local branch as [gone]. Then git branch -D deletes the local branch itself. The script skips the currently checked-out branch to avoid errors.
Results Link to heading
In my case, this took the worktree count from 256 down to 28 and deleted ~700 stale local branches across 46 repos. Total disk reclaimed was about 27GB. The whole thing ran in under two minutes.
Preventing buildup Link to heading
I now run a lighter version of this periodically. You could alias it or add it to a cron job:
alias wt-clean='find ~/Code -maxdepth 4 -name ".worktrees" -type d -exec sh -c '\''
repo="$(dirname "{}")";
git -C "$repo" fetch --prune -q 2>/dev/null;
for wt in "{}"/*/; do
[ -d "$wt" ] || continue;
b=$(git -C "$wt" rev-parse --abbrev-ref HEAD 2>/dev/null || basename "$wt");
git -C "$repo" rev-parse --verify refs/remotes/origin/$b &>/dev/null || {
echo "Removing: $(basename "$repo")/$(basename "$wt")";
git -C "$repo" worktree remove --force "$wt" 2>/dev/null || rm -rf "$wt";
};
done;
git -C "$repo" worktree prune 2>/dev/null
'\'' \;'
Or just run the full script once a month. Worktrees accumulate slowly enough that monthly cleanup keeps things manageable. I also have a shell function with fzf that handles individual worktree deletion day-to-day — the bulk script is for when things have already gotten out of hand.
Further reading Link to heading
- git-worktree documentation - covers
remove,prune, andlist - Git worktrees for parallel development - my earlier post on the worktree workflow
- Git worktree helper with fzf - the shell function I use daily