Name
liminaltrees — every git worktree on your machine, at once
Synopsis
liminaltrees [--root path]
liminaltrees scan [--json] [--root path]
liminaltrees gc [--dry-run] [--yes] [--root path]
liminaltrees new branch [--subject text]
liminaltrees config [set-root path]
liminaltrees init zsh|bash|fish
liminaltrees install-hook [--uninstall]
liminaltrees ui [--root path]
--root takes a repository or a directory of them, and belongs after the subcommand. Every run prints the scope it resolved before it prints anything else.
Install
$ curl -fsSL https://lt.different.productions/install.sh | sh
macOS 15 or later. Universal binary, ~1 MB, no runtime dependencies. Nothing to hand-edit. The script checks its own SHA-256 before unpacking, and you can read it first — it is served as plain text.
Description
git worktree list shows you one repository at a time, and tells you nothing about what any of the worktrees are for. So they accumulate — the ones you made, the ones your agents made and named after a hash, and the ones whose contents your OS quietly deleted without telling git.
liminaltrees finds all of them, works out what each one is, and removes the ones whose work has already landed. Anything holding work that exists nowhere else gets pushed somewhere safe first, and is only removed once that push is confirmed.
It sees through a squash merge, which git branch --merged cannot: the squashed commit has different parents and a different hash, so the original branch reads as unmerged forever. That is why worktrees for work you shipped weeks ago are still sitting there.
Setup
The command above is enough for liminaltrees, scan and gc. Two optional commands wire up the rest. Each edits one file, and each undoes itself.
- liminaltrees config set-root ~/where/your/repos/live
- Only if your checkouts are somewhere it does not already look — it tries ~/Developer, ~/Projects, ~/code, ~/src, ~/dev, and the directory holding whatever repository you are standing in. liminaltrees config show tells you what it picked and why.
- eval "$(liminaltrees init zsh)"
- Add to ~/.zshrc. Defines wt <branch>, which creates a worktree and drops you inside it. It has to be a shell function because no process can change its parent shell's directory. bash and fish too.
- liminaltrees install-hook
- Records where agent worktrees came from at the moment they are created, so an abandoned one is still identifiable after its branch and pull request are gone. Adds one Claude Code SessionStart hook and leaves the rest of the file alone. --uninstall restores it exactly.
Commands
- liminaltrees
- Opens the dashboard. Piped, prints a table instead, so | grep still works.
- liminaltrees gc
- Shows you exactly what it would do, grouped by what it intends. Runs only if you type y.
- liminaltrees gc --dry-run
- Shows the plan and stops. Never prompts.
- liminaltrees scan --json
- For the agents that made this mess in the first place. Stable tokens, not prose.
- liminaltrees new branch
- Creates a worktree and records what it is, before anyone has to guess later. Prints only the path, so cd "$(liminaltrees new my-branch)" works.
- liminaltrees config show
- Where it will look, and whether that came from your config or a guess.
How it decides
Checks run in order. The first one that matches decides, so the order is the whole safety policy.
- Detached, on a commit no upstream branch hasheld — nothing to rescue onto, and its registration holds the only ref
- Registered, but the directory is goneremove — bookkeeping only; the branch ref outlives it
- Anything wrote to it in the last hourheld — something is working in there
- Uncommitted filesrescue — those bytes exist in one place on earth
- Commits on no remote, and no patch upstreamrescue — still unique
- Its pull request merged or closedremove — this is the one that catches squash merges
- Every commit's patch is already on the default branchremove — it landed some other way
- Clean, pushed, unmergedyour call — safe to delete, but still somebody's plan
It will never
- touch a worktree something is writing to
- Anything written in the last hour is off limits, and that check outranks the rescue rules — pushing a half-finished state out from under a live agent run is worse than leaving the disk alone.
- delete work that exists nowhere else
- Uncommitted, unpushed, or reachable from no remote: it gets committed, pushed to a fresh wip/ branch, and confirmed on the remote. Only then is the worktree removed. A rescue that fails cancels that removal and nothing else.
- guess
- If it cannot reach the forge and cannot prove a branch landed, it says so and leaves the worktree alone. Not knowing is a result, not a reason to delete.
- act without showing you first
- Every removal is listed, with the single fact that decided it, before anything runs.
Files
Guarantee
A worktree is removed only when every commit in it is reachable from origin and the working tree is clean. After any run, git fsck reaches everything it reached before.
That is the point of the whole design: a plan you can approve without reading it closely. A cleanup tool you have to read closely every time is a cleanup tool you stop running.
Bugs
Rescue has no automated test coverage — exercising it means creating remote branches. Every classification rule is tested; the push-and-verify sequence has been read, not run.
In 0.1.0, --root typed after a subcommand was silently ignored and the run covered the whole machine instead. If you are on 0.1.0, do not trust a scoped gc. Fixed in 0.2.0, which prints the scope it resolved above everything else.
Removing a worktree also removes its DerivedData, which git cannot restore — it is rebuilt by building again. The plan lists every build directory by name before anything runs, and reports that figure separately from the git one.
macOS only. It shells out to stat -f, which is the BSD spelling, and DerivedData is an Xcode idea.
See also
git-worktree(1), git-cherry(1), and the twenty-three checkouts you forgot about