---
title: "residue-cli: Reclaim your storage from Git worktrees, automatically and safely."
description: "residue-cli removes a Git worktree only when nothing in it would be lost. How its 15 checks decide, how it deletes and restores, and residue-ui for the Mac."
canonical: "https://www.trevorlasn.com/blog/residue-cli-reclaim-storage-from-git-worktrees"
author: "Trevor I. Lasn"
published: "2026-11-09"
last-updated: "2026-11-09"
topics: ["Developer tools", "AI agents", "Open source"]
---

# residue-cli: Reclaim your storage from Git worktrees, automatically and safely.

residue-cli removes a Git worktree only when nothing in it would be lost. How its 15 checks decide, how it deletes and restores, and residue-ui for the Mac.

By Trevor I. Lasn · Published 2026-11-09 · https://www.trevorlasn.com/blog/residue-cli-reclaim-storage-from-git-worktrees

Right now my Mac has 13 linked Git worktrees under `~/Work`. Coding agents made most of them, one per task, and 11.6 GB sit in a single repository.

Agents are good at making worktrees and bad at cleaning them up. [residue-cli](https://github.com/whiteheap/residue-cli) does the cleanup: it removes a worktree only when nothing in it would be lost, whichever agent left it.

I wrote about [why I built it](https://www.trevorlasn.com/blog/residue-git-worktree-cleanup) three days ago. This is what it looks like to use today, how it decides what to remove, and the Mac app, [residue-ui](https://github.com/whiteheap/residue-ui).

One command installs it, and on macOS and Linux it also turns on automatic cleanup:

```bash
curl -fsSL https://residue.whiteheap.com/install.sh | sh
```

Homebrew alone installs the `residue` command without the service:

```bash
brew install whiteheap/tap/residue
```

Here's `residue scan ~/Work` on my Mac, trimmed to three of the repositories that have worktrees:

```shellsession
$ residue scan ~/Work
residue 0.0.11 · as of 15:08:54 +02:00 · activity: lsof inspected 475 of 732 running processes

Work/0xinsider · 5 linked worktrees · 11.6 GB
  keep        2.1 GB  Work/0xinsider/.worktrees/33726-agent-login-claude-code-ux  [feat/agent-login-claude-code-ux]
                      in use: bash[73041], perl[74009], perl[74022] (+3 more) · ignored: .cargo/ (1 file, 667 B), .local-dev/ (2 files, 0 B)
  keep        2.1 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) · ignored: .cargo/ (1 file, 249 B), .local-dev/ (1 file, 0 B) (+1 more; 3 entries hold 4 files, 3.4 MB)

Work/docs.0xinsider.com · 3 linked worktrees · 5.1 MB
  keep        2.2 MB  Work/docs-entitlement-diagnostics-16235  [docs/entitlement-diagnostics-16235]
                      1 change

Work/residue · 3 linked worktrees · 19.4 MB
  candidate  12.9 MB  Work/residue-wt/launch-code  [fix/launch-hardening]
                      branch kept · all commits on a remote · changed 2 minutes ago
  keep        3.3 MB  Work/residue-wt/readme  [docs/readme-from-site]
                      1 change · 1 untracked
  candidate   3.2 MB  Work/residue-wt/probe-bytes  [fix/probe-path-bytes]
                      branch kept · all commits on a remote · changed 5 minutes ago

disk  475 GB free of 995 GB (48%), 339.0M of 346.3M inodes free  (filesystem of Work/0xinsider)
2 candidates of 13 linked worktrees · 16.2 MB allocated · about 16.2 MB reclaimable
```

Only 2 of the 13 can go, and that's the point. A worktree is a `candidate` only when every check passes: no changes, no untracked files, no lock, no process using it, and no ignored file its tool can't rebuild.

The 0xinsider worktrees hold a running process, a lock or local build state. The two residue ones have every commit on a remote, so removing them loses nothing.

That scan only reports. The install script also starts `residue watch` as a launchd agent on macOS or a systemd user service on Linux, and it watches your usual code folders, like `~/src` and `~/Work`:

```shellsession
$ residue service status
launchd agent com.whiteheap.residue.watch: running (pid 30573)
runs: /opt/homebrew/bin/residue watch --grace 1h --idle 2d --min-free 10 /Users/trevor.lasn/Work
watches: ~/Work
removal records: kept 30d (--keep-removed, the default)
```

It removes a worktree in only two cases. Either its upstream branch was deleted, which is what merging a pull request with branch deletion does, and it hasn't changed for an hour. Or the disk drops below 10% free, and then worktrees untouched for two days go, largest first.

It fetches the remotes your worktrees track about every 30 minutes, so it sees a merged branch without you running `git fetch`.

Here's a removal from my log, with the path shortened. An agent had left this worktree in `/private/tmp`, a place I'd never have looked:

```text
2026-11-07T13:03:00Z residue watch: removed /private/tmp/.../scratchpad/c0/docs-33359 (86.7 MB): its upstream branch origin/docs/props-max-spread-33359 was deleted and it was unchanged for 1h
```

## Under the hood: 15 checks, then a delete that can back out

`scan`, `explain`, `remove`, the Mac app and `watch` all run the same checks. `scan` and `explain` stop at the report and never delete.

residue-cli is about 22,500 lines of Rust with one dependency, libc. It asks Git for everything Git knows, runs Git's own commands to move and delete, and asks the operating system which processes are where.

A scan starts from a folder. A path inside a repository selects that repository, and any other folder is searched four levels down, without following symbolic links, crossing filesystems, or entering hidden directories, `node_modules`, `target` or `Library`.

It never decides on its own that a directory is a worktree. The list comes from Git, so a folder that only looks like one is never touched, and the main checkout is never a candidate.

Each check passes, blocks, or is unknown. Every check passing makes a `candidate`. One block makes `keep`, and a check that couldn't finish, with none blocking, makes `unknown`, which keeps the worktree too.

The fourth verdict, `stale`, is a worktree Git still lists whose directory is gone. residue reports it and names `git worktree repair` for one you moved, but no command that clears Git's record, since that would drop its HEAD and reflog.

Age is never a verdict on its own. A last change residue can't read is `unknown`, not inactive.

Here's what each of the 15 checks keeps a worktree for, in the chart's order: Git's records, the files, the directory, then use and time.

| Check | Keeps the worktree when |
| --- | --- |
| `identity` | the path isn't the linked worktree Git lists for this repository |
| `locked` | it's locked with `git worktree lock` |
| `operation` | a rebase, merge, cherry-pick, revert or bisect is in progress |
| `private_refs` | it has per-worktree refs under `refs/worktree/`, `refs/bisect/` or `refs/rewritten/` |
| `head` | HEAD is detached and no branch, tag or remote-tracking ref contains it |
| `layout` | it has submodules or is a sparse checkout |
| `tracked_changes` | a tracked file is staged or modified |
| `untracked` | any untracked file or directory exists |
| `ignored` | an ignored entry matches no regenerable rule |
| `index_flags` | a tracked file is marked assume-unchanged or skip-worktree, so `git status` can't see edits to it |
| `nested_repo` | it holds a `.git` entry other than its own: another repository, or files Git doesn't list |
| `mount_point` | it contains a mount point |
| `contents` | residue couldn't read some entries, or one changed while it read them |
| `activity` | a process has its working directory or an open file inside it |
| `idle` | only with `--idle`: it changed within that duration |

`residue explain` prints every check for one worktree. Here it is on the worktree I wrote the first version of this section in, right after I downloaded the two charts into it, with the paths shortened:

```shellsession
$ residue explain ~/Work/trevorlasn.com-wt/hood
residue 0.0.11 · as of 15:45:18 +02:00 · activity: lsof inspected 493 of 758 running processes

worktree   ~/Work/trevorlasn.com-wt/hood
repository ~/Work/trevorlasn.com
HEAD       75543bc401d6 on branch post-residue-hood
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 493 of 758 running processes; processes it cannot see are not checked)
  pass     tracked_changes no staged or modified tracked files
  block    untracked       4 untracked entries: .../how-it-works-dark.png, .../how-it-works.png, .../ui-how-it-works-dark.png, .../ui-how-it-works.png
  pass     ignored         no ignored files
  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 post-residue-hood; 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 (as of the last fetch)
  upstream                origin/master
  last change             2026-11-09 15:44 +02:00 (just now; .../ui-how-it-works-dark.png in the worktree)
  size                    11.9 MB allocated, 11.1 MB apparent, about 11.9 MB reclaimable
                          reclaimable leaves out hard links from outside; clones are not detected
```

Four images I hadn't committed yet were enough to keep it. An agent's half-finished work looks the same to residue.

The `contents` line also reports the `nested_repo` and `mount_point` checks, and `idle` runs only with `--idle`. That's all 15.

The `ignored` check is the strictest, because `git worktree remove` deletes ignored files without asking. Ignored doesn't mean disposable: `.env` files, `.vercel/` project links, local databases and scratch notes are usually ignored too.

So a worktree stays unless every ignored entry matches a regenerable rule. The 32 built-in rules name what a tool recreates, such as `node_modules`, `.next`, `dist`, `target`, `DerivedData`, `.venv` and `__pycache__`.

Add your own for one repository in its Git config, which every worktree of that repository shares:

```bash
git config --add residue.regenerable web/.tscache
git config --add residue.regenerable '.local-dev/*.lock'
```

A rule without a slash matches a name at any depth, so `cache` matches `web/cache/` but not `web/cache2/`. A rule with a slash is a path from the worktree root, and `*` matches within one segment.

For every repository on the machine, put one rule per line in `~/.config/residue/regenerable`. For one run, pass `--regenerable RULE`.

residue never reads a rule from a file committed to the repository. A repository you clone must not be able to declare your files disposable, and `git clone` never copies Git config.

It also ignores rules set through the environment, like `git -c residue.regenerable=...`. Every child process inherits the environment, so a rule there would widen what residue deletes for all of them, and residue prints a note on stderr instead.

Git reports a fully ignored directory once, and residue judges it as that one entry. When a path rule reaches inside it, residue reads the directory and calls it regenerable only if a rule matches every file, and a directory with more than 100,000 entries isn't judged file by file, so it stays.

Three checks come from one walk of every entry in the worktree: `nested_repo`, `mount_point` and `contents`. residue opens each folder from the one above it and never through a symbolic link, so a folder swapped for a link mid-walk makes `contents` `unknown`.

The sizes come from the same walk, so there's no option to skip it. The README measures it: on a 1.0 GB worktree with 46,572 entries, `residue explain` took 0.65 to 0.83 seconds on an Apple silicon Mac with a warm cache, and `git worktree remove` of an identical copy took 2.7 to 3.0 seconds.

Size counts each hard-linked file once and leaves out files that also have links outside the worktree, such as pnpm's store. It can't see APFS or Btrfs clones and snapshots, so the space actually freed can be smaller.

`activity` is one snapshot of the processes your user can inspect. On macOS it's a single `lsof` run, and the header says what it covered, like `lsof inspected 493 of 758 running processes`. lsof runs in `/`, so it never counts itself as using the worktree you run residue from.

On Linux it reads `/proc/*/cwd`, `exe`, `fd` and `maps`. The `maps` file matters: a native module loaded from a worktree's `node_modules` by a process started elsewhere holds no open file there, only a memory mapping.

FreeBSD uses `procstat -f` from the base system, because a packaged lsof there listed processes without resolving their working directories. On NetBSD, OpenBSD and DragonFly the probe can't read open files, so it can never show a worktree is unused, and residue removes nothing there.

The probe answers `in_use`, `not_observed` or `unknown`. Here's `residue explain --json` on one of my candidates, cut to those fields:

```shellsession
$ residue explain --json ~/Work/residue-wt/readme | jq '.worktree | {verdict, activity, last_change}'
{
  "verdict": "candidate",
  "activity": {
    "state": "not_observed",
    "processes": [],
    "reason": null,
    "detail": null
  },
  "last_change": {
    "unix": 1794231426,
    "at": "2026-11-09T13:37:06Z",
    "source": "git_dir",
    "what": "index"
  }
}
```

`not_observed` isn't proof that nobody needs the worktree. The probe can't see another user's processes, ones in a container or virtual machine, ones on another machine sharing the disk, or a session you closed and mean to resume.

That last gap is why an unattended sweep needs `--idle`. Agent harnesses often reset the shell to the main checkout between commands, so a paused agent has no process in its worktree at all.

The last change is the newest of the worktree's own entries, the files directly in its Git directory, and the timestamp inside the newest HEAD reflog entry. Reads such as `git status`, `git diff` and `git log` don't count, and `remove --all --yes` refuses to run without `--idle`.

In the JSON above, `"source": "git_dir"` with `"what": "index"` means the worktree's index changed last. To keep a session you mean to resume, lock it, since residue treats every lock as final:

```bash
git worktree lock --reason "agent paused, resume Monday" PATH
```

Unpushed commits don't block. Removing a worktree keeps its branch and every commit on it, so `explain` lists them under facts instead, along with whether the upstream is gone, the last change and the size.

`scan` and `explain` never fetch. Local-only means local-only as of your last fetch.

A candidate still isn't deleted the moment it passes. `remove` prints a plan and asks you to type `yes`, and the plan records what you were shown: HEAD, the branch, the lock, the changed and untracked files, every ignored entry with its file count, size and newest change, and when the index and HEAD reflog last changed.

Right before each removal it takes a new process snapshot, runs every check again, and compares the result with the plan. Any difference skips that worktree, so what gets deleted is what you saw, or nothing. The README shows what a skip looks like:

```text
skipped .worktrees/big: node_modules/ changed since you confirmed (1,204 -> 1,388 files)
```

Then it moves the worktree aside with `git worktree move`, to a hidden sibling named `.NAME.residue-removing-PID`, and locks it. That's one rename on the same filesystem, and a worktree that is a mount point is skipped, never copied.

After the move nothing can reach the worktree by its old path, so residue writes the removal record and looks once more at the new path. A fresh process snapshot must see nobody inside, the record must still match, no Git operation may have started, and there must be no private refs, no `.git` but its own, no mount point and nothing it can't read.

Only then does `git worktree remove` delete it, without `--force`. Git checks again for modified and untracked files before it deletes, but not for ignored ones, which is why residue's own `ignored` check has to be strict.

If anything changed, residue moves the worktree back, unlocks it, and the reason ends with `it is back in place`. With `--trash`, `--idle` or the Mac app, the move goes into the repository's trash instead, and the worktree stays there whole.

The move narrows the race but doesn't close it. From the last check until Git finishes, a process that opens the hidden directory by its new name, or one the probe can't see, can still write there, and that write is deleted with the worktree.

residue writes the record and syncs it to disk after the move and before the last check, and writes its refs in one `git update-ref` transaction, all or none. If the record can't be written, it moves the worktree back and deletes nothing.

The run holds the record with flock(2) until its `git worktree remove` exits, and that Git holds it too, even if residue is killed. `residue restore` won't take a record anyone holds, because moving a worktree back while Git is still deleting would let Git delete it where it lands.

If `git worktree remove` itself fails, residue drops the record only when nothing of the worktree is gone. Otherwise the worktree stays under its hidden name, and the reason ends with the `residue restore` command for its record.

If residue is killed between the move and the end of the delete, Git still lists the worktree under its hidden name. The next `residue scan` shows it under `interrupted removals`, with the one command that puts it back.

That command starts with `test ! -e` on the original path, because `git worktree move` into an existing directory would put the worktree inside it. residue never removes a worktree under a hidden name, so nothing touches it until you recover it.

The removal record is a directory in `.git/residue-removed/`, such as `fix-login-20261107T040000Z/`. Its `info` file holds the original path, branch, HEAD, upstream, why and by which command it was removed, when, how long it's kept, its size, and the ignored entries the delete took.

Beside it are copies of the worktree's HEAD reflog and `config.worktree`. A ref, `refs/residue/removed/fix-login-20261107T040000Z`, holds the HEAD commit, so a deleted branch, an expired reflog or `git gc` can't lose it.

Commits that only the worktree's own HEAD reflog knew, such as work on a detached HEAD or the commit before an amend, get refs under `refs/residue/reflog/`. `git worktree remove` deletes that reflog, and without the refs `git gc` would drop them. A record is usually a few kilobytes.

`residue restore` lists the records and the trash, newest first. Given a name, it recreates the worktree with `git worktree add`, never `--force`:

```bash
residue restore
residue restore fix-login
residue restore --to ~/src/app/.worktrees/fix-login-2 fix-login
```

It never moves an existing branch. What it does depends on where the branch is now:

| The branch now | `residue restore` |
| --- | --- |
| at the recorded HEAD, or ahead of it | checks the branch out |
| rewritten, so it no longer contains the recorded HEAD | creates `BRANCH-restored` at the recorded HEAD |
| deleted | creates it again at the recorded HEAD |
| checked out in another worktree | creates `BRANCH-restored` and names that worktree |
| none, because the worktree was on a detached HEAD | restores it detached at the recorded HEAD |

It refuses when something exists at the path, and `--to` restores somewhere else. The new worktree gets its saved HEAD reflog back, but not the ignored files the delete took: restore lists them, and your install or build makes them again.

`watch` never removes a restored worktree again because its branch landed, since you wanted it back after it landed. Low disk can still remove it.

Each record keeps the `--keep-removed` it was written with, 30 days by default, and no later run forgets it sooner. Forgetting deletes the record and its refs in one transaction.

That grace period matters for a branch deleted after a squash merge. The record is then the last thing holding those commits, so restore what you want before it expires, or put it on a branch. Tools that read every ref, like `git log --all` and `git push --mirror`, also see `refs/residue/`.

The trash is `.git/residue-trash/`, one directory per entry with the worktree and an `info` record. Moving there is one rename on the repository's filesystem, `git status` never shows it, and deleting the repository deletes its trash.

A trashed worktree stays registered with Git and locked with the reason `residue: in trash`. Its branch stays checked out there, so `git switch` to that branch elsewhere refuses until you restore it or the purge removes it.

residue refuses to trash a worktree on another filesystem than its repository, because the move would be a copy. The trash frees no disk until a purge: every `remove` run purges entries older than `--keep-trash`, 7 days by default, after running every check on them again except `--idle`.

`watch` doesn't delete anything itself. For a landed branch it starts `residue remove --yes --idle --no-trash --only-if-upstream-gone`, which reads the upstream at the plan, before the move aside, and after it.

The worktree stays if the upstream is back, if there's no upstream, if HEAD is detached, if the upstream is local, or if the ref doesn't resolve to a commit. For low disk, the child runs without `--only-if-upstream-gone` and removes until free space is five points above the threshold.

The fetch is `git fetch --prune --no-tags` for each remote a worktree's branch tracks. It never prompts: SSH runs with `BatchMode=yes` unless you set your own SSH command, so a fetch that needs a password fails, is logged, and changes nothing.

A fetch changes only remote-tracking refs and writes no `FETCH_HEAD`, so it doesn't reset a worktree's idle clock. It's never a reason to remove; it only refreshes the refs the checks read.

On macOS, `watch` waits on kqueue events instead of polling, and it still reads free space every five minutes and rescans every six hours. It scans only after the refs have been quiet for 20 seconds, at most two minutes, so a `git remote rename` in progress doesn't look like deleted branches.

Here's a dry run on my Mac with fetching off. Nothing qualified, so that's all it printed:

```shellsession
$ residue watch --once --dry-run --no-fetch ~/Work
2026-11-09T13:53:06Z residue watch: watching ~/Work (kqueue) · removes: landed and unchanged 1h, or below 10% free and unchanged 2d · removal records: 30d · fetch: off (--no-fetch) · dry run: removes nothing · residue 0.0.11
```

Every deletion, purge, trash move and restore adds a line to a local ledger, `~/Library/Application Support/residue/ledger.jsonl` on macOS, which `residue stats` reads. The ledger never decides a removal and never leaves the machine, and one it can't write is a warning, not a failure.

residue only handles linked worktrees. For pnpm's store, Docker's build cache or toolchain caches, use their own commands, like `pnpm store prune`, `docker builder prune` or `go clean -cache`.

It runs on macOS, Linux, the BSDs and illumos, and not on Windows. It isn't a security boundary either: it runs as you and assumes your other processes aren't trying to fool it.

If you only want the worktrees Claude Code created, Claude Code sweeps its own. Worktrunk creates, switches, merges and removes worktrees. residue only removes, whichever tool made them, and only when every check passes.

## residue-ui, the same checks in a Mac app

[residue-ui](https://github.com/whiteheap/residue-ui) is residue in a window. Every check, size and removal in it comes from the residue binary inside the app, so it can't disagree with your terminal.

The window only reads until you ask it to change something. Worktrees runs `scan --json`, a worktree's checks come from `explain --json`, Restore from `restore --json` and Freed from `stats --json`.

It reads at launch, when you open a screen and when you ask, never on a timer. The app makes no network request of its own; only `watch` and its Preview fetch from your Git remotes.

The Worktrees screen sizes each worktree by the space removing it frees. White can go, gray is kept, and hatched is unknown:

You can select only the worktrees that can go. Move to the trash then runs residue twice: first without `--yes`, which plans and moves nothing, then, after you click, with `--yes` on exactly the planned paths.

That second run checks each worktree again and keeps any that changed. It also passes the plan's time as `--as-of`, so it purges only the trash entries that had expired when you saw the plan.

Restore lists what residue can bring back, newest first. A trash entry comes back whole for at least 7 days, but it keeps its disk space until it's purged into a removal record:

Freed reads residue's own ledger: what it freed on this machine in the last 7 and 30 days, all time, by command and by repository.

These screenshots use example repositories and a made-up history of removals.

Automatic cleanup in the app is the same service as `residue service install`, so turning it on in one shows up in the other. It deletes instead of using the trash, and the app says so before you turn it on.

To install it, download the disk image and drag residue to Applications:

```bash
curl -fLO https://github.com/whiteheap/residue-ui/releases/latest/download/residue-macos.dmg
open residue-macos.dmg
```

It needs macOS 13 or later, on Apple silicon or Intel, and residue comes inside it. It isn't notarized yet, so macOS blocks the first launch. The [README](https://github.com/whiteheap/residue-ui#first-launch) has the three steps to open it.

Use the app when you want to see what's taking space and click. Use the CLI on Linux, in a script, or as an agent's last step, when the session that owns a worktree knows its task is done:

```bash
residue remove --yes --json "$WORKTREE"
```

Run it from outside the worktree. Exit 0 means the worktree is gone, and exit 3 means it's still there.

Each path gets one result, `removed`, `trashed`, `refused`, `skipped`, `failed`, `invalid` or `not_attempted`, with a reason that names the check by its stable id. Every check still runs with `--yes`; it only skips the prompt.

Both are MIT-licensed: [residue-cli](https://github.com/whiteheap/residue-cli) and [residue-ui](https://github.com/whiteheap/residue-ui). The site is [residue.whiteheap.com](https://residue.whiteheap.com).
