prune decides a worktree is merged by testing commit ancestry against the default branch. A squash merge produces a commit with no parent link to the branch commits, so the branch tip is genuinely not an ancestor even though its content has landed. On a repository that squash merges, every branch that ships leaves behind a worktree that prune classifies as unmerged and refuses to remove, permanently.
Version: v2.1.1
Reproduce
git init --bare origin.git
git clone origin.git work && cd work
git commit --allow-empty -m init && git push -u origin main
git switch -c feature
echo hi > f.txt && git add f.txt && git commit -m "add f"
echo there >> f.txt && git commit -am "extend f"
git push -u origin feature
# squash merge, as GitHub "Squash and merge" does
git switch main
git merge --squash feature && git commit -m "add f (#1)"
git push
git merge-base --is-ancestor feature main; echo "ancestor: $?" # 1, not an ancestor
git diff main feature # empty, content is identical
The branch has shipped. Its content is byte-identical to main. prune sees exit 1 from the ancestry test and holds the worktree.
Observed on a real branch: PR merged, upstream issue auto-closed by the merge, the squash commit an ancestor of origin/main, and HEAD..origin/main containing only that squash commit plus one unrelated commit, so the branch was strictly behind and held nothing unique. prune still reported:
unmerged:
1 HEAD not merged into refs/remotes/origin/main
Why this compounds
Squash merge is the default or the house rule on a lot of projects, including every repository this pool serves. So the failure is not occasional, it is every shipped branch. Each one leaves a worktree that no prune run will ever reclaim, and because prune reports success while doing it, nothing surfaces the growth. The pool leaks in exactly the workflow treehouse is built for.
An unattended prune --all --yes on a timer makes it quieter still: the job succeeds every week and the held set only grows.
Possible tests that survive a squash
None of these is obviously best, so listing rather than prescribing:
- Content comparison. Compute the branch's own contribution,
git diff $(git merge-base HEAD <default>) HEAD, and check whether the default branch already contains it. Catches squashes of any number of commits, which is the common case.
git cherry. Patch-id equivalence catches a rebased or cherry-picked single commit, and a squash of exactly one commit. It does not catch several commits collapsed into one, so it is a partial answer on its own.
- Ask the forge. Query whether a PR whose head is this branch is merged. Accurate, but adds a network dependency and forge-specific code to what is currently pure git.
- Deleted upstream branch as a hint. A gone remote-tracking branch is a decent signal that the branch shipped, since most forges delete the head branch on merge. Cheap, and safe if treated as a hint rather than proof.
A conservative combination would be ancestry first, then content comparison, and only report unmerged when both say no.
Separate observation, happy to split into its own issue
prune also treats an available pool slot as stale when it is clean and merged. That is literally true, but an available pre-warmed slot is the thing the tool exists to provide, and deleting it means the next consumer pays full cold cost. On a large repository, a weekly sweep deleted tens of GiB of warm pool that then had to be rebuilt from scratch.
So the same predicate errs in both directions at once: it discards warm slots that are still useful, and keeps shipped-branch worktrees that are not. Say the word and I will open that half separately.
prunedecides a worktree is merged by testing commit ancestry against the default branch. A squash merge produces a commit with no parent link to the branch commits, so the branch tip is genuinely not an ancestor even though its content has landed. On a repository that squash merges, every branch that ships leaves behind a worktree thatpruneclassifies asunmergedand refuses to remove, permanently.Version: v2.1.1
Reproduce
The branch has shipped. Its content is byte-identical to
main.pruneseesexit 1from the ancestry test and holds the worktree.Observed on a real branch: PR merged, upstream issue auto-closed by the merge, the squash commit an ancestor of
origin/main, andHEAD..origin/maincontaining only that squash commit plus one unrelated commit, so the branch was strictly behind and held nothing unique.prunestill reported:Why this compounds
Squash merge is the default or the house rule on a lot of projects, including every repository this pool serves. So the failure is not occasional, it is every shipped branch. Each one leaves a worktree that no
prunerun will ever reclaim, and becauseprunereports success while doing it, nothing surfaces the growth. The pool leaks in exactly the workflowtreehouseis built for.An unattended
prune --all --yeson a timer makes it quieter still: the job succeeds every week and the held set only grows.Possible tests that survive a squash
None of these is obviously best, so listing rather than prescribing:
git diff $(git merge-base HEAD <default>) HEAD, and check whether the default branch already contains it. Catches squashes of any number of commits, which is the common case.git cherry. Patch-id equivalence catches a rebased or cherry-picked single commit, and a squash of exactly one commit. It does not catch several commits collapsed into one, so it is a partial answer on its own.A conservative combination would be ancestry first, then content comparison, and only report
unmergedwhen both say no.Separate observation, happy to split into its own issue
prunealso treats anavailablepool slot as stale when it is clean and merged. That is literally true, but an available pre-warmed slot is the thing the tool exists to provide, and deleting it means the next consumer pays full cold cost. On a large repository, a weekly sweep deleted tens of GiB of warm pool that then had to be rebuilt from scratch.So the same predicate errs in both directions at once: it discards warm slots that are still useful, and keeps shipped-branch worktrees that are not. Say the word and I will open that half separately.