Require git proof before prune deletes a branch
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

A merged or closed PR no longer authorises a delete on its own: HEAD must
be contained in the PR's head commit or in origin/<branch>, otherwise the
worktree goes and the branch stays. Branch deletion runs `git branch -d`
first and falls back to -D only for a proven branch.

Reword the cherry check to say patches reached the default branch's
history, print the verdict --keep-branches will actually perform, and warn
when a PR listing hits the pagination cap instead of reading it as "no PR".
This commit is contained in:
2026-09-10 00:00:22 +10:00
parent 4bbeaae8f0
commit c1c02c01cf
7 changed files with 249 additions and 47 deletions
+115 -6
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -140,6 +141,11 @@ func mergedPR(number int, branch string) map[string]any {
}
}
func withHeadSha(pr map[string]any, sha string) map[string]any {
pr["head"].(map[string]any)["sha"] = sha
return pr
}
func openPR(number int, branch string) map[string]any {
return map[string]any{
"number": number,
@@ -202,8 +208,9 @@ func TestPruneMergedPRWithDeletedBranch(t *testing.T) {
f := newFixture(t)
wt := f.addWorktree(t, "benvin/merged")
writeCommit(t, wt, "m.txt", "m\n", "work")
head := git(t, wt, "rev-parse", "HEAD")
srv := fakeGitea(t, mergedPR(3, "benvin/merged"))
srv := fakeGitea(t, withHeadSha(mergedPR(3, "benvin/merged"), head))
out := run(t, client(srv), true, false)
assertVerdict(t, out, "benvin/merged", verdictRemoveBranch, "PR merged #3")
@@ -260,7 +267,7 @@ func TestPruneCherryCleanBranch(t *testing.T) {
srv := fakeGitea(t)
out := run(t, client(srv), true, false)
assertVerdict(t, out, "benvin/squashed", verdictRemoveBranch, "cherry-clean")
assertVerdict(t, out, "benvin/squashed", verdictRemoveBranch, "patch-equivalent commits in origin/main history")
if agent.GitBranchExists(f.srcDir, "benvin/squashed") {
t.Error("cherry-clean branch should be deleted")
@@ -315,7 +322,7 @@ func TestPruneClosedPRWithBranchOnOrigin(t *testing.T) {
srv := fakeGitea(t, closedPR(6, "benvin/closed"))
out := run(t, client(srv), true, false)
assertVerdict(t, out, "benvin/closed", verdictRemoveBranch, "PR closed #6, branch on origin")
assertVerdict(t, out, "benvin/closed", verdictRemoveBranch, "PR closed #6, HEAD contained in origin/benvin/closed")
if exists(wt) {
t.Error("worktree should have been removed")
@@ -367,8 +374,9 @@ func TestPruneDryRunChangesNothing(t *testing.T) {
contained := f.addWorktree(t, "benvin/contained")
merged := f.addWorktree(t, "benvin/merged")
writeCommit(t, merged, "m.txt", "m\n", "work")
head := git(t, merged, "rev-parse", "HEAD")
srv := fakeGitea(t, mergedPR(8, "benvin/merged"))
srv := fakeGitea(t, withHeadSha(mergedPR(8, "benvin/merged"), head))
out := run(t, client(srv), false, false)
if !strings.Contains(out, "dry run") {
@@ -384,14 +392,18 @@ func TestPruneDryRunChangesNothing(t *testing.T) {
}
}
// --keep-branches removes worktrees but leaves every branch alone.
// --keep-branches removes worktrees but leaves every branch alone, and the
// printed verdict says so.
func TestPruneKeepBranches(t *testing.T) {
f := newFixture(t)
wt := f.addWorktree(t, "benvin/contained")
srv := fakeGitea(t)
out := run(t, client(srv), true, true)
assertVerdict(t, out, "benvin/contained", verdictRemoveBranch, "contained")
assertVerdict(t, out, "benvin/contained", verdictRemove, "contained")
if strings.Contains(out, verdictRemoveBranch) {
t.Errorf("--keep-branches must not print a branch-deleting verdict:\n%s", out)
}
if exists(wt) {
t.Error("worktree should have been removed")
@@ -439,6 +451,103 @@ func TestPruneNoWorktrees(t *testing.T) {
}
}
// Commits made after the PR merged exist nowhere else, so a merged PR alone
// must not authorise deleting the branch.
func TestPruneMergedPRWithCommitsAfterMerge(t *testing.T) {
f := newFixture(t)
wt := f.addWorktree(t, "benvin/ahead")
writeCommit(t, wt, "a.txt", "a\n", "merged work")
merged := git(t, wt, "rev-parse", "HEAD")
writeCommit(t, wt, "b.txt", "b\n", "work after the merge")
srv := fakeGitea(t, withHeadSha(mergedPR(9, "benvin/ahead"), merged))
out := run(t, client(srv), true, false)
assertVerdict(t, out, "benvin/ahead", verdictRemove, "PR merged #9")
if exists(wt) {
t.Error("worktree should have been removed")
}
if !agent.GitBranchExists(f.srcDir, "benvin/ahead") {
t.Error("branch with commits beyond the merged head must survive")
}
}
// HEAD proven contained in the merged head still loses its branch.
func TestPruneMergedPRContainedInMergedHead(t *testing.T) {
f := newFixture(t)
wt := f.addWorktree(t, "benvin/landed")
writeCommit(t, wt, "a.txt", "a\n", "work")
head := git(t, wt, "rev-parse", "HEAD")
srv := fakeGitea(t, withHeadSha(mergedPR(10, "benvin/landed"), head))
out := run(t, client(srv), true, false)
assertVerdict(t, out, "benvin/landed", verdictRemoveBranch, "HEAD contained in the merged head")
if exists(wt) {
t.Error("worktree should have been removed")
}
if agent.GitBranchExists(f.srcDir, "benvin/landed") {
t.Error("branch contained in the merged head should be deleted")
}
}
// A surviving remote branch only covers what was pushed to it; later local
// commits keep the branch.
func TestPruneClosedPRWithCommitsBeyondOrigin(t *testing.T) {
f := newFixture(t)
wt := f.addWorktree(t, "benvin/beyond")
writeCommit(t, wt, "c.txt", "c\n", "pushed work")
git(t, wt, "push", "origin", "benvin/beyond")
writeCommit(t, wt, "d.txt", "d\n", "local only")
srv := fakeGitea(t, closedPR(11, "benvin/beyond"))
out := run(t, client(srv), true, false)
assertVerdict(t, out, "benvin/beyond", verdictRemove, "local commits not on origin/benvin/beyond")
if exists(wt) {
t.Error("worktree should have been removed")
}
if !agent.GitBranchExists(f.srcDir, "benvin/beyond") {
t.Error("branch with commits beyond origin must survive")
}
}
// truncatedLister stands in for a repo with more PRs than the listing cap.
type truncatedLister struct{ prs []agent.PullRequest }
func (l truncatedLister) ListPRs(string, string) ([]agent.PullRequest, error) {
return l.prs, fmt.Errorf("unkin/repo: %w after 1000 pull requests", agent.ErrPRListTruncated)
}
// A truncated listing still classifies the PRs it saw, but a branch missing
// from it reads as unknown rather than as having no PR.
func TestPruneWarnsOnTruncatedPRListing(t *testing.T) {
f := newFixture(t)
listed := f.addWorktree(t, "benvin/listed")
writeCommit(t, listed, "a.txt", "a\n", "work")
head := git(t, listed, "rev-parse", "HEAD")
unlisted := f.addWorktree(t, "benvin/unlisted")
writeCommit(t, unlisted, "b.txt", "b\n", "work")
var pr agent.PullRequest
pr.Number, pr.State, pr.Merged = 12, "closed", true
pr.Head.Label, pr.Head.Sha = "benvin/listed", head
out := run(t, truncatedLister{prs: []agent.PullRequest{pr}}, true, false)
if !strings.Contains(out, "truncated") || !strings.Contains(out, "older PRs unseen") {
t.Errorf("output should warn about the truncated listing:\n%s", out)
}
assertVerdict(t, out, "benvin/listed", verdictRemoveBranch, "PR merged #12")
assertVerdict(t, out, "benvin/unlisted", verdictRemove, "PR state unknown")
if agent.GitBranchExists(f.srcDir, "benvin/listed") {
t.Error("branch of a merged PR seen in the listing should be deleted")
}
if !agent.GitBranchExists(f.srcDir, "benvin/unlisted") {
t.Error("branch missing from a truncated listing must survive")
}
}
// Managed repos are not all under AGENTWS_OWNER, so the Gitea path comes from
// origin's URL; a non-Gitea remote falls back to the configured owner.
func TestRepoPathFollowsOrigin(t *testing.T) {