Every coding agent I run works in its own Git worktree. Five agents on five branches, nobody stepping on anybody. The catch is that each worktree is a full checkout with its own node_modules, target and build output, and nothing ever cleans them up.
Here’s my 0xinsider repo today, trimmed to three of its seven worktrees:
$ residue scan ~/Work/0xinsiderresidue 0.0.1 · activity: lsof inspected 591 of 849 running processes
~/Work/0xinsider · 7 linked worktrees · 14.8 GB keep 3.4 GB ~/Work/0xinsider/.worktrees/mainline (detached dbc6383893a3) in use: redis-cli[20521], bash[31816], tail[36487] (+5 more) · ignored: .local-dev/, .vercel/ (+5 more) keep 2.3 GB ~/Work/0xinsider/.local-dev/lane-build/slot-1 [lane-build/slot-1] locked: merge-lane build slot, owned by tools/run lane-build (#26273) · in use: bash[28685], bash[28937], bash[29120] (+24 more) · ignored: .cargo/, .local-dev/ (+1 more) keep 1.3 GB ~/Work/0xinsider/.worktrees/32696-potd-drop-selected-by-column [fix/potd-drop-selected-by-column] ignored: .cargo/
0 candidates of 7 linked worktrees · 0 B allocated · about 0 B reclaimable14.8 GB, and the tool says none of it is safe to delete. That’s on purpose, and I’ll get to it. First, why I didn’t just delete the lot myself.
The obvious cleanup is git worktree remove. Without --force, Git refuses to remove a worktree with modified or untracked files, so it feels safe. It isn’t, because ignored files don’t count. I checked on Git 2.50.1: put a .env in a clean worktree, git status shows nothing, git worktree remove exits 0, and the .env is gone. Same for a local SQLite database, scratch notes, anything your .gitignore covers.
Git also has no idea whether an agent is still working in there.
So I built residue and released it today as open source under Whiteheap. It’s a Rust CLI with three commands: scan, explain and remove. It finds linked worktrees, measures them, and removes one only when nothing in it would be lost.
How residue decides
residue runs the same checks on every linked worktree (you’ll see the full set in a second). It’s a candidate only when all of them pass. One failure means keep. A check it couldn’t finish means unknown, which also keeps it. The main checkout is never a candidate.
Most of the checks are what you’d expect: staged or modified files, untracked files, a lock, a rebase or merge in progress, a process using the directory. A few catch things git status misses. A file marked assume-unchanged or skip-worktree can hold edits Git won’t show you. A detached HEAD that no branch, tag or remote ref contains is a commit you’d lose. A nested repo or a mount point means the directory holds more than a checkout.
Ignored files get their own rule. A worktree stays unless every ignored entry matches a regenerable list: dependency and build directories their tool recreates, like node_modules, .next, dist, target, .venv and __pycache__. .env is not on the list.
That’s why my scan came back empty. Here’s one of those worktrees up close:
$ residue explain ~/Work/0xinsider/.worktrees/32696-potd-drop-selected-by-columnworktree ~/Work/0xinsider/.worktrees/32696-potd-drop-selected-by-columnrepository ~/Work/0xinsiderHEAD 50c5beedcbd7 on branch fix/potd-drop-selected-by-columnverdict keep: 1 check blocks removal.
checks pass identity the directory is the linked worktree Git lists for this repository pass locked not locked pass activity no process observed using it (lsof inspected 586 of 831 running processes; processes it cannot see are not checked) pass tracked_changes no staged or modified tracked files pass untracked no untracked files block ignored ignored entries not known to be regenerable: .cargo/. `git worktree remove` would delete them without asking. If they are regenerable, add their names to the residue config or pass --regenerable pass index_flags no tracked file is marked assume-unchanged or skip-worktree pass operation no rebase, merge, cherry-pick, revert or bisect in progress pass private_refs no worktree-private refs pass head on branch fix/potd-drop-selected-by-column; removal keeps the branch and its commits pass layout no submodules and not a sparse checkout pass contents every entry is readable; no nested repository or mount point
facts (never block) local-only commits 0 (compared with local remote-tracking refs; residue never fetches) upstream origin/fix/potd-drop-selected-by-column newest change on disk 2026-11-06 21:25 UTC (9 minutes ago) size 1.3 GB allocated, 1.2 GB apparent, about 1.3 GB reclaimable reclaimable excludes files hard-linked from outside; copy-on-write clones are not detected ignored entries .cargo/ (kept)Everything passes except a .cargo/ directory residue doesn’t know how to judge. If it’s safe to regenerate, I say so once in ~/.config/residue/regenerable or pass --regenerable .cargo. If it isn’t, residue just stopped me from deleting it. Either way it’s my call, not a guess.
Notice the “facts” block. Local-only commits, a gone upstream, the newest change on disk and size never block removal. residue reports them so you can decide, instead of rolling them into a vague “safe” label. It never fetches, so “local-only” means local-only as of your last fetch.
My real repo is messy, so here’s a scratch repo with four worktrees in the states agents usually leave them in:
$ residue scan appresidue 0.0.1 · activity: lsof inspected 587 of 822 running processes
app · 4 linked worktrees · 70.1 MB candidate 50.0 MB app/.worktrees/fix-login [fix-login] branch kept · no remote-tracking refs · changed just now keep 20.0 MB app/.worktrees/spike [spike] ignored: .env keep 16.4 KB app/.worktrees/search [search] 1 untracked candidate 12.3 KB app/.worktrees/review (detached a1a28a17e302) detached · no remote-tracking refs · changed just now
disk 387 GB free of 995 GB (39%), 3774.7M of 3779.0M inodes free (filesystem of app)2 candidates of 4 linked worktrees · 50.0 MB allocated · about 50.0 MB reclaimablenext: residue explain PATH · residue remove PATH · keep one: git worktree lock PATHfix-login has its work committed and 50 MB of node_modules and dist. spike has 20 MB of node_modules too, but also a .env, so it stays. search has an untracked notes.md. review is a detached HEAD that main already contains, so nothing is lost if it goes.
Removing a worktree
residue remove prints a plan and waits for you to type yes:
$ residue remove app/.worktrees/fix-login app/.worktrees/spikeresidue 0.0.1
will remove (1): app/.worktrees/fix-login [fix-login] 50.0 MB allocated, 50.0 MB apparent, about 50.0 MB reclaimable deletes ignored: dist/, node_modules/ no process observed using it (lsof inspected 538 of 767 running processes; processes it cannot see are not checked)
not removed: app/.worktrees/spike keep: ignored: .env details: residue explain PATH
Remove 1 worktree with `git worktree remove`? Branches and commits stay. Type yes to continue: yesremoved app/.worktrees/fix-login (branch fix-login kept); about 50.0 MB reclaimableRight before each removal it takes a fresh process snapshot and runs every check again. If anything changed since the plan (the verdict, HEAD, the branch, a new lock, the set of ignored files), it skips that worktree. Then it hands the deletion to git worktree remove without --force, so residue never deletes anything Git itself would refuse to. The fix-login branch and its commit are still there afterwards. residue never deletes a branch, never kills a process and never unlocks anything.
The activity check is the part I’d be most skeptical of, so here’s exactly what it does. On macOS it takes one lsof snapshot of the working directories and open files of every process your user can inspect. On Linux it reads /proc. The answer is one of three states: in_use, not_observed or unknown. If lsof is missing, times out or prints something residue can’t parse, that’s unknown, and nothing gets removed. Other Unix systems have no probe at all, so nothing there is ever a candidate.
not_observed doesn’t mean idle. A Claude Code session you closed last night and plan to resume tomorrow has no process. Processes owned by other users, or running inside a container or VM that shares the disk, are invisible to the probe. That’s why removal asks a human. To keep a worktree, lock it with Git’s own mechanism, which other tools respect too:
git worktree lock --reason "resume on Monday" PATHresidue treats every lock as final.
When stdin isn’t a terminal, residue refuses to delete anything unless you pass --yes. That flag is for the session that owns the worktree and knows its task is done, like an agent’s last step:
residue remove --yes --json "$WORKTREE"Exit status 0 means the worktree is gone. Exit status 3 means it’s still there, and the JSON says why with stable check ids. Here’s what the agent gets back for spike:
residue remove --yes --json app/.worktrees/spike \ | jq '.results[] | {path, result, blocked_by: [.worktree.checks[] | select(.status != "pass") | .id]}'{ "path": "app/.worktrees/spike", "result": "refused", "blocked_by": [ "ignored" ]}Don’t put --yes in a cron job that feeds residue every candidate. An idle session you plan to resume looks exactly like a finished one.
Install it with Homebrew on macOS or Linux:
brew install whiteheap/tap/residuePrebuilt binaries for macOS (Apple silicon and Intel) and static Linux (x86_64 and arm64) are on the releases page, each with a SHA-256 checksum and a GitHub build-provenance attestation you can check with gh attestation verify. From source, you need Rust 1.85 or later:
cargo install --locked --git https://github.com/whiteheap/residue --tag v0.0.1residue needs Git 2.36 or later on your PATH.
The whole thing is about 3,400 lines of Rust with one dependency, libc. A tool that deletes directories on your machine should be small enough to read in an afternoon. Every Git call runs with hooks, fsmonitor and optional locks turned off, and with inherited GIT_DIR-style variables removed, so running residue from inside a hook can’t point it at the wrong repository.
This is 0.0.1, and I’d rather you know the gaps up front. There’s no automated test suite yet. Every verdict was checked by hand with the release binary on real repos: macOS 26.3.1 on Apple silicon with Git 2.50.1, and Ubuntu 24.04 arm64 with Git 2.43.0. The Intel Mac binary was cross-compiled and never executed. Windows isn’t supported. Freed space is an estimate, because residue can’t see APFS or Btrfs clones and snapshots.
It also only handles worktrees. Package stores and build caches have their own cleanup commands that know what’s safe, like pnpm store prune, docker builder prune and go clean -cache. Use those.
Other tools go after the same problem with different tradeoffs, and the README has a short section on when you’d pick them instead. residue does one job: it removes a worktree from any tool only when every check passes, and lets Git do the deleting.
The code is on GitHub under MIT. If residue keeps something it should have removed, open an issue with the residue explain --json output for that worktree. If it ever removes something it shouldn’t have, report it privately as SECURITY.md describes, since that’s the one bug this tool can’t have.
Contributions are welcome, especially test runs on platforms I haven’t covered. CONTRIBUTING.md has the setup, the checks CI runs, and the script I use to build a scratch repo with one worktree in every state.