Agent Git worktrees eat your disk; residue clears safe ones

residue is an open-source Rust CLI that finds the Git worktrees coding agents leave on disk and removes one only when nothing in it would be lost.

Trevor I. Lasn Trevor I. Lasn
· 9 min read

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/0xinsider
residue 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 reclaimable

14.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-column
worktree ~/Work/0xinsider/.worktrees/32696-potd-drop-selected-by-column
repository ~/Work/0xinsider
HEAD 50c5beedcbd7 on branch fix/potd-drop-selected-by-column
verdict 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 app
residue 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 reclaimable
next: residue explain PATH · residue remove PATH · keep one: git worktree lock PATH

fix-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/spike
residue 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: yes
removed app/.worktrees/fix-login (branch fix-login kept); about 50.0 MB reclaimable

Right 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:

Terminal window
git worktree lock --reason "resume on Monday" PATH

residue 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:

Terminal window
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:

Terminal window
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:

Terminal window
brew install whiteheap/tap/residue

Prebuilt 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:

Terminal window
cargo install --locked --git https://github.com/whiteheap/residue --tag v0.0.1

residue 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.


Trevor I. Lasn

Building whiteheap.com and 0xinsider.com.


Found this article helpful? You might enjoy my free newsletter. I share dev tips and insights to help you grow your coding skills and advance your tech career.


Related Articles

Check out these related articles that might be useful for you. They cover similar topics and provide additional insights.

Llm
4 min read

Context7 MCP: Stop LLM Hallucinations with Live Docs

Context7 MCP server pulls version-specific library docs into Claude Code, Cursor, and other AI editors. Setup guide and how it works.

Nov 1, 2025
Read article
Llm
5 min read

Anthropic's Sequential Thinking MCP

Ever wished your AI agent would slow down and think things through? This MCP server does exactly that

Sep 7, 2025
Read article
Llm
6 min read

Building Custom MCP Servers with Next.js and mcp-handler

How to build a MCP server that works with Claude Code, Gemini CLI, Cursor, and more.

Jan 28, 2026
Read article
Llm
7 min read

AI Agents Explained

What AI agents are, how the agent loop works, and why they're different from chatbots.

Jan 30, 2026
Read article
Llm
6 min read

Google Search Console MCP for Claude Code

Pull GSC data into Claude Code to find CTR problems, indexing bugs, and keyword gaps — then fix them in the same conversation.

Feb 15, 2026
Read article
Llm
5 min read

I added Agentic Resource Discovery to 0xinsider so agents can find it

ARD is a new proposal from Google, Microsoft and Hugging Face for how agents find tools. What's in it, what 0xinsider publishes, and what a registry returns.

Sep 23, 2026
Read article
Llm
5 min read

Claude Code Superpowers: How to Add Skills That Plan Before Coding

Superpowers is an MCP plugin for Claude Code that enforces planning, TDD, and debugging workflows. Setup guide with real examples.

Nov 4, 2025
Read article
Llm
3 min read

Chrome DevTools MCP: Let Your AI Agent Debug Your App

Give your AI agent access to your running application. They can see errors, inspect the network tab, check the DOM, and debug issues while you work.

Nov 10, 2025
Read article
Llm
3 min read

Next.js DevTools MCP: Your Development Server Just Got Smarter

The Next.js DevTools MCP connects Claude and Cursor to your running dev server. I use it every day.

Nov 2, 2025
Read article

This article was originally published on https://www.trevorlasn.com/blog/residue-git-worktree-cleanup. It was written by a human and polished using grammar tools for clarity.