Find every stale worktree, not just the managed ones #14
Reference in New Issue
Block a user
Delete Branch "benvin/worktree-gc"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
agentws pruneonly looked under the worktree root, so the hand-made worktrees in~/src/prodenv/.worktreeswere invisible to it, and a checkout mid-rebase, on a detached HEAD or backed by a vanished repo could be classified removable.git worktree liston each source checkout as well as the worktree root--no-fetch,--json,--include-unmanagedand--include-keepReviewed against the stated why (discover worktrees
git worktree listfinds outside the worktree root; stop classifying mid-rebase/detached checkouts as removable). Build/vet/lint/tests all clean.Verdict: issues found — one high-severity classification bug. Not merge-ready as-is.
[HIGH] Orphan classification conflates "backing repo confirmed gone" with "any git error" — bypasses every safety check and hard-deletes.
cmd/agentws/main.go:291-296:Any failure of
rev-parse --abbrev-ref HEADorrev-parse --git-common-dir— not just "the backing repo directory was actually deleted" — is read as orphan.classify()(prune.go:145-147) short-circuits orphan straight toverdictRemove, skipping dirty/locked/in-progress-op entirely, andremoveOrphanDir(main.go:539-552) does a bareos.RemoveAll(wt.path)guarded only by path containment — no dirty check, no lock check, no confirmation the repo is actually gone vs. a transient error (NFS hiccup, permission blip, git under load, momentarily-unmounted srcDir). Before this PR the same error just made the entry silently invisible tomanagedWorktrees()(continue // skip) — safe by omission. This PR turns "can't read it" into "delete it," reachable on a plainagentws prune --yeswith no--include-*flags, for a tool whose source root plausibly lives on network/removable storage under agent automation.Fix: only classify orphan after independently confirming the backing directory doesn't exist (e.g. stat the gitdir target the
.gitfile names) instead of trusting any non-nil error from two git subcommands; on an ambiguous error, keep + report "inspection failed", same asclassify()already does elsewhere (prune.go:169).[MEDIUM]
--include-keephelp text undersells what it can destroy.prune.go:91:"Dangerous: also remove worktrees classified keep (needs --yes; never deletes their branch)". Verified live:git worktree remove --forcesucceeds silently on a worktree with an interactive rebase paused mid-conflict, deleting the sequencer state (rebase-merge dir, todo list) with zero warning and zero git-level refusal. So this flag doesn't just override a keep you disagree with — for the locked/mid-rebase/dirty keep reasons it can permanently lose uncommitted or in-progress work that no branch ever captured. "Never deletes their branch" reads more reassuring than the actual risk. Suggest either stronger wording or restricting--include-keepto the "PR open" keep reason only, leaving locked/dirty/in-progress-op as non-overridable.Scope/atomicity: PR body's 5 bullets don't mention the orphan-directory deletion or stale-registration pruning (
wt.missing,prune.go:377-379) that the diff also adds — a distinct "handle a vanished backing repo/dir" concern, not "discover hand-made worktrees." Not blocking given how interlinked the discovery/safety changes are, but worth a body update or a split-out PR.Classifier ordering: no bugs found.
missing → locked → in-progress-op → dirty → open-PR → upstream-contained/cherry-equivalent → detached-with-unique-commits → PR-merged/closed switchcorrectly puts hard-stop guards ahead of "provably safe" checks.Squash-merge handling: correct and pre-existing (unchanged by this PR) —
git cherryfor patch-equivalence, PR matching viahead.label(nothead.ref, which Gitea rewrites torefs/pull/N/headpost-merge).Unpushed-commits question (hard keep only for detached HEAD; branch-backed worktrees still get removed, branch retained): verified safe. Every
verdictRemoveBranchassignment (prune.go:414,423,440,442,448) setsproven=truein the same statement — no path deletes a branch without it.deleteLocalBranch(main.go:556-562) only force-deletes (-D) whenproven; unproven cases go through plaingit branch -d, which git itself refuses if unmerged. For the plainremoveoutcome I confirmed live thatgit worktree remove --forcenever touchesrefs/heads/*— an unpushed commit's branch and object both survive worktree removal intact in the shared source checkout. So the design is sound for branch-backed worktrees; the one hole is the orphan path above, which never reaches this logic at all.Tests: solid, not rubber-stamped — real fixtures for locked, mid-rebase (
git rebase --exec false), mid-cherry-pick, detached+unique-commits, detached+contained, unmanaged-worktree gating, missing-registration, main-checkout-never-offered,--no-fetchdistrust,--jsonshape,--include-keepbranch-retention. Gap: no test for the orphan false-positive above (only the "confirmed gone" case is exercised), and no test combining--include-unmanagedwith akeepverdict on an unmanaged worktree.Conventions: body 646 chars / 7 lines, why + present-tense bullets, no names/AI references — compliant. Comments are one-liners, no blocks.
go build,go vet,golangci-lint run,go test ./...all clean.