Distrust origin/<branch> when prune's fetch fails
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

A stale remote-tracking ref survives a failed fetch and the next successful
--prune deletes it, so it cannot prove a branch's commits survive upstream.
Record whether the pruning fetch succeeded and gate the origin/<branch>
existence and containment proofs on it; a failed fetch removes the worktree and
keeps the branch. Local-object proofs and the merged head SHA are unaffected.
This commit is contained in:
2026-09-10 00:18:54 +10:00
parent c1c02c01cf
commit 387653a3c0
4 changed files with 149 additions and 7 deletions
+15 -4
View File
@@ -40,6 +40,9 @@ type repoCtx struct {
defBranch string
prs map[string]agent.PullRequest
prsKnown bool
// fetched records that this run's pruning fetch succeeded; without it an
// origin/<branch> ref may be stale and due for deletion, so it proves nothing.
fetched bool
}
func newPruneCmd() *cobra.Command {
@@ -139,12 +142,15 @@ func plannedVerdict(r pruneResult, keepBranches bool) string {
// newRepoCtx refreshes a source repo and collects the signals prune classifies
// against. A failed fetch or an unreachable Gitea is reported and tolerated:
// the git-only signals still work offline.
// the signals that hold offline still work, and the rest are recorded as
// unverified.
func newRepoCtx(out io.Writer, prs prLister, srcDir string) (repoCtx, error) {
ctx := repoCtx{srcDir: srcDir, prs: map[string]agent.PullRequest{}}
repo := filepath.Base(srcDir)
if err := agent.GitFetchPrune(srcDir, "origin", credentialHelperArgs()...); err != nil {
_, _ = fmt.Fprintf(out, "warn: fetch %s: %v (using local refs)\n", repo, err)
_, _ = fmt.Fprintf(out, "warn: fetch %s: %v (remote state unverified)\n", repo, err)
} else {
ctx.fetched = true
}
def, err := agent.GitRemoteDefaultBranch(srcDir, "origin")
if err != nil {
@@ -223,7 +229,8 @@ func headContainedIn(dir, ref string) bool {
// provably-upstream work loses its branch too, and anything unproven keeps its
// branch so no commits become unreachable. A PR's state alone never authorises
// deleting a branch — git must confirm HEAD is contained in what merged or in
// what origin still holds.
// what origin still holds, and origin's refs only count when this run's pruning
// fetch refreshed them.
func classify(wt managedWt, ctx repoCtx) (pruneResult, error) {
res := pruneResult{wt: wt}
dirty, err := agent.GitIsDirty(wt.path)
@@ -261,18 +268,22 @@ func classify(wt managedWt, ctx repoCtx) (pruneResult, error) {
}
remote := "origin/" + wt.branch
onOrigin := hasPR && agent.GitRemoteBranchExists(ctx.srcDir, "origin", wt.branch)
onOrigin := hasPR && ctx.fetched && agent.GitRemoteBranchExists(ctx.srcDir, "origin", wt.branch)
switch {
case hasPR && pr.Merged && headContainedIn(wt.path, pr.Head.Sha):
res.verdict, res.reason, res.proven = verdictRemoveBranch, fmt.Sprintf("PR merged #%d, HEAD contained in the merged head", pr.Number), true
case hasPR && pr.Merged && onOrigin && headContainedIn(wt.path, remote):
res.verdict, res.reason, res.proven = verdictRemoveBranch, fmt.Sprintf("PR merged #%d, HEAD contained in %s", pr.Number, remote), true
case hasPR && pr.Merged && !ctx.fetched:
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR merged #%d, fetch failed so %s is unverified", pr.Number, remote)
case hasPR && pr.Merged:
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR merged #%d, local commits not in the merged head", pr.Number)
case hasPR && onOrigin && headContainedIn(wt.path, remote):
res.verdict, res.reason, res.proven = verdictRemoveBranch, fmt.Sprintf("PR closed #%d, HEAD contained in %s", pr.Number, remote), true
case hasPR && onOrigin:
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR closed #%d, local commits not on %s", pr.Number, remote)
case hasPR && !ctx.fetched:
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR closed #%d, fetch failed so %s is unverified", pr.Number, remote)
case hasPR:
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR closed #%d, branch gone", pr.Number)
case ctx.prsKnown: