Add agentws prune #13
Reference in New Issue
Block a user
Delete Branch "benvin/agentws-prune"
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?
Agents leave their managed worktrees behind, and
agentws rmtakes one path at a time with no idea whether a branch's work is safely upstream, so clearing an accumulation by hand risks destroying unmerged commits.--yesapplies,--keep-branchesspares every branchReviewed the classification logic in detail, including git-level experiments for the "merge commit only" and "reverted upstream" scenarios named in the review brief. Two safety-relevant findings; rest looks solid (precedence order, dry-run gating, label-vs-ref PR matching, Gitea-unreachable degrade, and repo-path derivation all check out under adversarial cases I tried).
1.
classify(cmd/agentws/prune.go:232-243) can delete a branch carrying commits that never reached the remote — no SHA proof backs the "PR merged"/"branch on origin" verdictsBoth fallback branches are reached only once line 228 (
unmerged == 0) has already failed, i.e. git itself has just proven there are local commits with no patch-equivalent upstream. From there:pr.Mergedflag alone and deletes the branch. If the agent added commits on the branch after the PR's head was captured (common if a worktree sits around post-merge for a quick follow-up beforeagentws rm), those commits are destroyed.PullRequest.Head.Sha(internal/agent/gitea.go) is already fetched but never used to check the local tip is actually contained in what merged.GitRemoteBranchExists) only checks a ref by that name exists on origin, not that it points at (or is an ancestor of) local HEAD. A closed-unmerged branch with local-only follow-up commits beyond the last push classifies asremove+branch"branch on origin" and loses them.GitDeleteBranchis invoked withforce=true(-D, cmd/agentws/main.go:327) unconditionally, so git's own "not merged" guard is bypassed too — these two branches are the only thing standing between a stale-but-present remote/PR and permanent loss of local-only commits.Suggest gating both on
GitIsAncestor(wt.path, "HEAD", "origin/"+wt.branch)(or againstpr.Head.Sha) before trusting Gitea's state as "safe to delete."2.
git cherry"cherry-clean" doesn't mean the code is still present upstream (internal/agent/git.go GitUnmergedCommits, prune.go:228-231)Verified experimentally: if a squash lands upstream and is later reverted with a new commit,
git cherrystill reports the original branch commit as-(matched), sounmerged == 0and prune reports "cherry-clean" / removes the branch — even though the tip of the default branch no longer contains that change. The historical commit is still reachable in origin's history (not literally destroyed), but the "provably upstream" label is misleading for this case. Worth a doc caveat at minimum; a stronger fix would check the matched upstream commit is itself an ancestor of the currentorigin/<default>tip.Minor, non-blocking:
--keep-branchesstill printsremove+branchas the verdict even though the branch survives (cmd/agentws/prune.go:210) — cosmetically misleading, not unsafe.ListPRspagination caps atmaxPRPages*prPageSize= 1000 PRs (internal/agent/gitea.go) with no warning if truncated; a repo past that could silently under-report PR state for old branches.Everything else matches the stated design: dirty and open-PR keep-precedence is correctly enforced and tested (
TestPruneNeverTouchesDirtyWorktree,TestPruneKeepsOpenPR),head.labelvsrefs/pull/<n>/headmatching is correct and tested both ways, dry-run performs no mutation, Gitea-unreachable degrades to git-only signals with a warning rather than a silent input, and an unresolvable default branch keeps the whole repo's worktrees.GitFetchPruneis tightly scoped to this command's needs and fine to ship in the same PR.Reviewed the containment-proof logic in
prune.goagainst the stated goals. Theprovenflag, the-d-then--Dfallback, keep-precedence, the truncation sentinel, dry-run, and reason-string wording all check out — every path that setsverdictRemoveBranchalso setsproven = true,headContainedInandGitIsAncestorread an unresolvable ref as not-contained (verified byTestGitIsAncestor's bogus-ref case), the truncated-listing path leavesprsKnownfalse so an unlisted branch falls to "PR state unknown" (kept), andgit branch -dis always tried before any-D.One gap:
newRepoCtx(prune.go:143-171) tolerates a failedgit fetch --prune— it prints a warning and continues on stale local refs, with no flag recording that the fetch didn't happen this run.GitRemoteBranchExistsis documented as "accurate only after a pruning fetch" (git.go), butclassify'sonOrigincheck (prune.go:264) and the twoheadContainedIn(wt.path, remote)containment proofs it feeds (prune.go:268, 272-273, the merged-PR-via-origin and closed-PR paths) call it unconditionally, regardless of whether this run's fetch actually succeeded.Concretely: if
origin/<branch>was deleted upstream (PR abandoned/rejected without a Gitea "merged" record, or manually deleted) and this run's fetch fails while a stalerefs/remotes/origin/<branch>survives from an earlier successful fetch, the closed-PR path can mark the branch delete "proven" against a ref this run never actually verified. The local branch we then delete was the only durable copy; the stale remote-tracking ref that "proved" containment is itself due to disappear on the next successful--prunefetch.Suggest: have
newRepoCtxrecord whether the fetch succeeded, and refuse theonOrigin/remote-branch containment proofs (falling through to the unprovenverdictRemovecase, worktree-only) when it didn't. Theorigin/<default>ancestor/cherry checks are lower risk since a default branch normally only grows, but the same staleness caveat applies to them in principle.Everything else looked solid — nice test coverage of the "commits after merge" / "commits beyond origin" over-claim cases and the Gitea-unreachable degradation path.