Repository navigation
Decide whether merge-and-release's branch cleanup should use compare-and-delete #946
Description
A CodeRabbit finding has recurred identically across three PRs touching merge-and-release/SKILL.md's and drive-pr/SKILL.md's branch cleanup steps: #936 (the original promotion review), #940 (the merge-and-release/drive-pr fix chain), and #945 (the next promotion). Declined each time with the same reasoning below, but it keeps resurfacing, so it needs an actual decision rather than another decline.
What the cleanup steps do today
Both skills' cleanup, after confirming (via gh pr list/gh pr view, matched on headRepository.nameWithOwner and headRefOid) that a feature branch's PR genuinely merged, delete the branch in two separate steps:
- Verify the local and/or remote branch tip still matches the confirmed
headRefOid(git rev-parse --verify,git ls-remote --heads --exit-code). - Delete it (
git branch -D,git push origin --delete), as a separate, later command.
The finding
Between steps 1 and 2 there is a TOCTOU (time-of-check-to-time-of-use) window: a concurrent push could move the branch after verification but before deletion, and the plain delete would remove whatever is there at that moment, not what was verified. CodeRabbit's suggested fix (most detailed version, from #945):
Delete the local ref with an expected-OID operation such as
git update-ref -d "refs/heads/<branch>" "<headRefOid>", and use an explicit expected-value lease for the remote delete; stop when the expected OID does not match or conditional deletion is unavailable. Before deletion, verify that both the fetch and resolved push URLs fororiginrefer to the intended repository, since they may point to different destinations.
The remote half of that is functionally a git push --force-with-lease=<ref>:<expected-oid> for a delete.
Why it's been declined each time
git-commit-conventions's "Never force push" section is a blanket, unconditional rule:
Do not run
git push --forceorgit push --force-with-leaseunder any circumstances. Force pushing rewrites shared history and can cause data loss. This holds regardless of how confident the rewrite looks, a rejected push is recoverable, a force-pushed one is not.
Implementing CodeRabbit's suggestion means carving a narrow, explicit exception into that rule for this one cleanup case. That's a real policy change to a rule that exists because of a specific incident, not a mechanical bug fix an agent should decide unilaterally mid-PR. CodeRabbit's own severity marking on the #945 instance was "Heavy lift", agreeing it's more than a quick fix.
The practical risk is also narrow as things stand: the branch being deleted is one the current task exclusively owns per repo-worktree's isolation model, and deletion happens immediately after GitHub itself confirms the merge, so the race window is small and the actor who could win it would have to be pushing to a branch that just finished its own PR.
The decision needed
- Is the current two-step check-then-delete acceptable as-is (declining the finding permanently, with a note explaining why), or does the race genuinely warrant closing?
- If closing it: does
git-commit-conventions' blanket force-push prohibition get a narrow, explicitly-scoped exception for this one case (an OID-gated delete of a branch this task's own cleanup just verified), or is there a different mechanism that avoids touching that rule at all (e.g., a repository-side branch protection setting, a cleanup lock file, or something else)? - Separately, and lower-stakes: should the cleanup also verify
origin's fetch and push URLs resolve to the intendedowner/repobefore deleting anything, independent of the OID question?
Filed at the maintainer's request after the finding recurred a third time, to review together before deciding.
Activity
ptr727 commentedon Sep 9, 2026
Carried forward from #1309, closed as a duplicate of this issue. It raised the same TOCTOU window (verify the branch tip, then delete it as a separate command) and the same blocked remedy (
git-commit-conventions' unconditional force-push ban), from a different reviewer on a different pull request: coderabbitai on thedevelop -> mainpromotion #1304, deferred there under the instruction to merge the promotion and take the findings up afterwards. That is a fourth recurrence of this finding, after #936, #940 and #945.Two things it adds to the decision framing here:
A third affected surface.
backlog-burndown"Cleanup Is the Orchestrator's" deletes an abandoned or merged group's remote branch on the same verify-then-delete sequence, so whatever is decided binds three skills rather than the two named above:merge-and-release,drive-pr, andbacklog-burndown.The rule ruling out the remedy is stated in only one of them.
drive-prstep 4 says it explicitly: "Never--force-with-leasehere,git-commit-conventionsforbids it unconditionally, this plain verify-then-delete is the safety gate, not a compare-and-swap at delete time."backlog-burndownstates the sequence without naming what it rules out or what residual race remains. So there is a fourth question alongside the three above:#1309 also sharpened the question itself: whether the fleet's unconditional force-push ban is meant to reach a delete-time expected-OID condition on a branch deletion, which pushes no commits and rewrites no history, or whether that ban is about rewriting a shared branch and a guarded delete sits outside it.
ptr727 commentedon Sep 10, 2026
Maintainer's decision, 2026-09-09: the finding is declined permanently, with the reason recorded here so it stops recurring.
The two-step check-then-delete stays. The branch being deleted is one the task exclusively owns per
repo-worktree's isolation model, and the deletion follows GitHub's own confirmation of the merge, so the window is small and the actor who could win it would have to be pushing to a branch that has just finished its own pull request.git-commit-conventions' force-push prohibition is therefore not narrowed. That rule exists because of a specific incident, and this race does not warrant carving an exception into it.This is the third time the finding has been raised. A reviewer raising it a fourth time can be answered with this comment.
Asked while writing the handoff on #1501. Dropping
decision: the question is settled and what remains is recording the decline where a reviewer will meet it.ptr727 commentedon Oct 17, 2026
Closing as decided. The maintainer's 2026-09-09 decision above declines compare-and-delete permanently. The tree matches it:
scripts/pr_review.py cleanupre-reads the remote tip against the pull request'sheadRefOidand confirms the delete by reading the ref back, and its local half re-reads the branch tip beforegit branch -D, both verify-then-delete as decided. That landed in #3186 and was wired into the skills by #3188.