If you use git worktrees heavily you accumulate dozens of them, each a full working copy with its own dependencies and build output. Deleting the stale ones reclaims real space — I reclaimed about 78 GB, and wrote up the measurement separately.
Deleting the wrong one destroys work.
So the whole problem reduces to one question: is this workspace still needed? That's a question about git state, and no existing cleaner asks it. This is the build log for the tool that does — including the two answers I shipped that were wrong, and the tests that passed while protecting nothing.
The tool is swarfkit (swarf, on npm,
MIT, zero dependencies). What follows is the part that was hard.
Why this became a CLI and not a shell script
My first version was a short shell script. It found worktrees and deleted the
ones whose branch git branch --merged reported as merged. It was wrong in a way
I'll get to, but the reason it couldn't stay a shell script is separate and
worth naming:
The decision is a five-rule evaluation, and every rule can fail to evaluate.
Not "return false" — fail to evaluate. git status can error. The upstream ref
can be missing. The default branch can be unresolvable. In shell, a failed
command and a command that returned "no" look the same: empty output and a
non-zero exit you probably didn't check. For a program whose job is deleting
directories, conflating "the answer is no" with "I couldn't get an answer" is the
one bug you cannot ship.
Every branch in that logic needed a test. Once you want tests, exit codes,
argument parsing and machine-readable output, you want a program. It stayed
dependency-free — Node's standard library and git on the PATH is the entire
requirement — because a disk cleanup tool that needs its own node_modules is
an unfunny joke.
Two tiers of risk, not one
The first design decision that paid off: stop treating "reclaim disk space" as one operation. There are two, and they deserve different rules.
Tier 1 — build artifacts. node_modules, .next, dist, build,
.turbo. These are derived: regenerable from source you still have. Deleting
one costs you a rebuild. There is no state to lose, so the rule is permissive —
swarf clean will remove them even from a worktree you're actively editing.
Tier 2 — whole worktrees. Deleting one of these can destroy the only copy of work. That gets five rules, and all five must pass:
- Not the main worktree, not the current one, not locked.
- No uncommitted changes.
- An upstream exists, and nothing on the branch is unpushed.
- The branch is merged into the default branch.
- The last commit is older than
--min-age(default 7 days).
The verdict vocabulary matters as much as the rules. If only rule 5 fails,
the verdict is caution — merged, clean, pushed, just recent — and it's skipped
unless you ask for it. If any rule fails, blocked. And critically, if a rule
cannot be evaluated, also blocked:
// Rule 4 — merged into the default branch, squash merges included.
if (opts.defaultBranch === null) {
reasons.push("could not resolve the default branch");
} else if (wt.branch === opts.defaultBranch) {
reasons.push("is the default branch");
} else {
const merged = await isMergedEquivalent(wt.repoRoot, opts.defaultBranch, wt.branch);
if (!merged) reasons.push(`not merged into ${opts.defaultBranch}`);
}
Unevaluatable is not a special case handled once. It's the default direction of every failure in the file. The tool fails toward keeping your files.
Dry run is the default; deletion needs a verb
swarf with no arguments prints a report and deletes nothing, no matter what
flags you pass it. There is no --dry-run, because dry run isn't a mode — it's
what the tool does. To delete, you type a second word:
swarf --root ~/dev/acme # report only
swarf clean --root ~/dev/acme # tier 1: build artifacts
swarf prune --root ~/dev/acme # tier 2: whole worktrees, safe ones only
Both destructive verbs print the report, ask for confirmation, and then — this is the part that matters — re-scan before deleting. A worktree can pick up uncommitted changes while you're reading the prompt. If it changed, it gets re-evaluated and skipped. The plan is not the authority; the state at deletion time is.
The interesting problem: detecting a squash merge
Rule 4 is where the real work was, and where I was wrong twice.
Wrong answer #1: git branch --merged
The obvious check tests ancestry: are the branch's commits reachable from the default branch? A squash merge rewrites the branch's commits into one new commit on the target, so the original commits are never ancestors of anything. After a squash merge the branch reads as unmerged, forever.
Squash merge is the default on most hosted platforms. So an ancestry check doesn't fail at the margins — it marks nearly every genuinely merged branch as unmerged, and the tool never deletes anything. It fails safe, which is why it can sit in a codebase looking reasonable, being useless.
Wrong answer #2: git cherry
The fix is to compare content instead of identity. git cherry compares
patch IDs — a hash of a commit's diff — so the same change under a different
commit SHA still counts as present upstream. It prints one line per commit on
your branch:
- <sha> an equivalent patch exists upstream
+ <sha> no equivalent upstream — unshipped work
All - lines, or no output at all, means merged. This handles rebase merges
correctly. I shipped it, with tests, and it was still wrong.
Here is the honest part. git cherry compares per-commit patch IDs. A squash
merge collapses N commits into one upstream commit whose patch is the union of
all N diffs. That union matches the patch ID of none of the original commits —
unless N is 1.
So the check worked on single-commit branches and silently failed on every other one. Two- and three-commit branches read as unmerged. In other words: it worked on the trivial case and broke on every normal pull request.
And the test suite was fully green. The fixture that built a squash-merge scenario squashed a branch with exactly one commit — the one width where per-commit patch IDs happen to work. A passing suite told me the feature worked. It only ever tested the case that couldn't fail.
The answer that holds
If a squash merge produces one commit containing the branch's whole diff, then synthesise that commit yourself and ask about it. Take the branch's tree, parent it on the merge base, and you have exactly the commit the squash would have made:
const base = await gitOut(repoRoot, ["merge-base", upstream, head]);
if (!base) return false;
const synthetic = await git(repoRoot, [
"commit-tree", `${head}^{tree}`, "-p", base, "-m", "swarfkit squash probe",
], PROBE_ENV);
if (synthetic.code !== 0) return false;
const res = await git(repoRoot, ["cherry", upstream, synthetic.stdout.trim()]);
const lines = res.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
return lines.length === 1 && lines[0]!.startsWith("-");
Its patch is the squashed diff, so it matches the real squash commit upstream regardless of how many commits the branch had.
Three details are load-bearing:
- It demands positive evidence. The return is
lines.length === 1 && startsWith("-")— exactly one line, marked present-upstream. Empty output, a+, a failedmerge-base, a failedcommit-tree, any unexpected shape: allfalse. The check must prove the branch shipped, not merely fail to disprove it. A branch that was squash-merged and then had another commit added is correctly refused. - It's a second chance, not a replacement.
git cherryruns first; the synthetic probe only runs when that says no. - The probe writes a real object.
git commit-treeputs a commit in your object database. It's unreferenced and unreachable, sogit gccollects it like any other dangling object — but the tool is therefore not strictly read-only, and its README says so. Committer identity and timestamps are pinned to fixed values so re-scanning the same branch reuses the same hash rather than minting a new object every run.
Verified at one, two and three commits, plus the squash-then-extra-commit case that must be refused. The width of the branch was the variable my original test never varied.
The defect that would have deleted work
The squash-merge bug failed safe. This one didn't.
Git resolves an ambiguous short ref by searching refs/tags/ before
refs/heads/. So on a repo with a branch named v1.2.0 and an ordinary release
tag also named v1.2.0, passing the bare name v1.2.0 to git cherry compares
the tag — which points at the released commit, whose content is of course
already upstream. Empty output. Merged.
Every other rule legitimately passed: clean tree, pushed, old enough. The verdict
was safe, and the tool would have deleted the only local copy of unmerged work.
Git does warn about this. It prints warning: refname 'v1.2.0' is ambiguous to
stderr — and my code captured stdout and threw stderr away. The signal was
there and I had discarded it.
The fix is one line in each direction:
const upstream = `refs/heads/${defaultBranch}`;
const head = `refs/heads/${branch}`;
Fully qualifying costs nothing, because both names provably come from
refs/heads/ in the first place. Worth noting the shadowing works in both
positions — a tag can shadow the default branch just as easily as the feature
branch, so both had to be qualified and both had to be tested.
The general lesson: when you shell out, stderr is part of the return value. Discarding it discards the warnings that describe exactly the case you didn't think of.
Tests that passed while protecting nothing
I had good coverage. Then I ran mutation testing — break the code deliberately, confirm the suite goes red — and found three tests that were decorative.
An assertion inside a conditional that never ran. The expectation was nested under a branch that no test input satisfied. The test passed by never reaching its own assertion. Coverage tools counted the test as executed, because it was.
A symlink-escape test that escaped nowhere. The hard invariant in the delete path resolves symlinks and confirms the target really lives inside a discovered worktree, so a symlink can't be used to redirect a delete outside it. The test built a symlink pointing at an "outside" target — and the target was accidentally created inside the worktree it was supposed to escape. The test asserted the delete was refused; the delete was refused for the wrong reason. It would have passed with the safety check deleted entirely. That was the single most dangerous line in the repository, and it was in the test file.
A 15-test CLI suite that didn't test its own most important behaviour. I removed the re-scan-before-delete step described above — the thing that stops a worktree being deleted after it goes dirty — and ran the CLI suite. All fifteen passed. Every test exercised paths where the re-scan agreed with the original scan, so removing it changed no observable output.
None of these were visible by reading the code. Reading a test tells you what it intends to assert. Only breaking the implementation tells you what it does assert.
The same gap shows up in any suite that's graded on count rather than on what it catches. If you want a structured version of that question across reliability, security and test discipline, the Production-Readiness Scorecard walks the dimensions worth checking before you call something done.
What's still wrong, on purpose
One limitation survives, and the README states it plainly: git cherry
ignores merge commits. If a branch's only unique content is a fix made while
resolving a conflict inside a merge commit, and that fix exists as its own commit
nowhere else, the patch comparison won't see it. The branch reads as fully merged
and is eligible for deletion.
It's narrow — any other unique commit on the branch still trips the check — and
two things limit the damage: the tool only ever deletes a branch it has confirmed
is fully pushed, so the work almost always still exists on the remote; and plain
swarf reports without deleting, so you can read the verdict first.
But it is the one case where "when unsure, keep the files" doesn't help, because the tool isn't unsure. It's confidently wrong. A known limitation you've written down is a different class of problem from one you haven't found, and shipping the disclosure was cheaper and more honest than pretending the heuristic is complete.
The takeaway
Two of the three bugs here were hidden by a green test suite, and the third was hidden by a test that was actively lying. The pattern is the same each time: the test exercised the case the implementation already handled.
For a guard that exists to prevent data loss, the only test that counts is the one you have watched fail with the guard removed. Write it, break the guard, confirm red, put the guard back. It takes a minute, and it is the sole evidence that your test is attached to the behaviour you think it is.
If you have worktrees piling up, npx swarfkit --root ~/dev will tell you what
it thinks — and only that, until you type a second word.