From 0d01e2634a9ce3d42b987f5be22f3916038c734b Mon Sep 17 00:00:00 2001 From: Preetam Dwivedi Date: Wed, 17 Jun 2026 11:25:12 -0700 Subject: [PATCH 1/2] ci(rebase-stack): own branch deletion (require auto-delete off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary ### Why? The "Rebase Stacked PRs" job silently no-ops when the repo's "Automatically delete head branches" setting is on. On merge, GitHub deletes the merged head branch, which auto-retargets every child PR's base from the merged head branch to the merged base (e.g. main) *before* the job runs. The job discovers children via `gh pr list --base `, which then returns nothing — so it rebases nothing, force-pushes nothing, and still reports success. Run #257 (and #256 before it) did exactly this, leaving the downstream stack (#258/#259/#260) with doubled diffs that had to be rebased by hand. The fix is to keep "Automatically delete head branches" off (now done at the repo level) and let this workflow own head-branch cleanup. With the setting off, GitHub leaves child bases untouched, the lookup finds them, and the job rebases the stack and then deletes the merged branch itself — for both stacked and non-stacked PRs. ### What? - Document the hard requirement that "Automatically delete head branches" stays off, with the retarget-race rationale, in the workflow header. - Log a clear line when a merge has no child PRs instead of returning silently. - Clarify the branch-deletion step: it runs on every merged PR (stacked or not) and is skipped only when a child rebase failed; fix the misleading "All stacked PRs rebased successfully" message that printed even on no-op runs. No behavior change on the happy path — the job already deleted the merged branch on success; this makes the intent explicit and the logs legible. ## Test Plan - ✅ `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/rebase-stack.yml'))"` — YAML parses. - Confirmed `delete_branch_on_merge` is `false` via `gh api repos/uber/submitqueue`. - Post-merge: the next Rebase Stack run should log child rebases or "No open child PRs … nothing to rebase", then "Deleting merged branch …" and actually remove it. Co-authored-by: Cursor --- .github/workflows/rebase-stack.yml | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/rebase-stack.yml b/.github/workflows/rebase-stack.yml index b4ec5c74..26f44765 100644 --- a/.github/workflows/rebase-stack.yml +++ b/.github/workflows/rebase-stack.yml @@ -34,6 +34,17 @@ # 2. rebase --onto branch3 (replays C3) # 3. Delete branch1 # Result: main -> PR2(branch2, C2') -> PR3(branch3, C3') +# +# REPO SETTING REQUIREMENT — "Automatically delete head branches" MUST be OFF: +# This workflow is the sole owner of head-branch deletion. If GitHub +# auto-deletes the merged head branch, it ALSO auto-retargets every child +# PR's base from the merged head branch to the merged base (e.g. main) +# BEFORE this workflow runs. The `gh pr list --base ` lookup +# below would then find no children, and the stack would be silently left +# un-rebased (the job still goes green). With the setting OFF, GitHub leaves +# the branch and the child bases untouched, so this job can find the +# children, rebase them, retarget their bases, and finally delete the merged +# branch itself — for both stacked and non-stacked PRs. name: Rebase Stacked PRs @@ -127,6 +138,7 @@ jobs: --jq '.[] | "\(.number) \(.headRefName)"') if [ -z "$prs" ]; then + echo "No open child PRs based on '${lookup_base}' — nothing to rebase." return 0 fi @@ -258,15 +270,17 @@ jobs: "$MERGED_BASE" \ || rebase_result=$? - # Delete the merged PR's head branch only if the rebase chain - # succeeded. If it failed, some child PRs may still have this - # branch as their base — deleting it would cause GitHub to close - # those child PRs. + # Delete the merged PR's head branch. This runs on EVERY merged PR, + # whether or not it had a stack of child PRs — this workflow, not + # GitHub, owns branch cleanup (see the REPO SETTING REQUIREMENT in + # the header). The single exception is a failed child rebase: some + # child PRs may still target this branch, and deleting it would make + # GitHub close them, so we keep it for manual resolution. echo "" if [ "$rebase_result" -eq 0 ]; then echo "Deleting merged branch: $MERGED_HEAD" - git push origin --delete "$MERGED_HEAD" 2>/dev/null || echo "Branch already deleted." - echo "=== All stacked PRs rebased successfully ===" + git push origin --delete "$MERGED_HEAD" 2>/dev/null || echo "Branch already deleted or never existed." + echo "=== Done: children (if any) rebased; merged branch deleted ===" else echo "Keeping merged branch '$MERGED_HEAD' to avoid closing child PRs whose base was not updated." echo "=== Rebase chain stopped due to conflicts ===" From 061c028bc70c8aebf5bae229066e2dcecc3cc165 Mon Sep 17 00:00:00 2001 From: Preetam Dwivedi Date: Wed, 17 Jun 2026 11:37:40 -0700 Subject: [PATCH 2/2] ci(rebase-stack): reap merged branches via no-dependents sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary ### Why? With "Automatically delete head branches" off, this workflow owns branch cleanup — but it only deleted the merged branch at the end of its own run. When a child rebase conflicts, the run intentionally keeps the merged branch (the child still bases on it) and never fires again. After the author manually rebases the child and retargets it to main, nothing deletes the now-orphaned merged branch, so merged branches linger forever. ### What? Replace the outcome-gated single-branch delete with an invariant-based sweep, `cleanup_orphaned_merged_branches`, that runs on every invocation: for each recently merged PR whose head branch still exists, delete it iff no open PR references it as a base or head. This deletes the just-merged branch on a clean run, keeps it when an immediate child rebase failed, and reaps branches stranded by earlier conflicted runs on the next merge once their children were fixed. Branch existence is snapshotted in one `git ls-remote`; the merged base (e.g. main) is never touched. Conflicted runs now also emit a `::warning::` for visibility while still exiting non-fatally. ## Test Plan - ✅ `yaml.safe_load` parses the workflow. - ✅ `bash -n` on the extracted `run:` script. - ✅ Simulated the sweep loop with mock data (dedup, skip-gone, skip-base, consider-delete all behave). Co-authored-by: Cursor --- .github/workflows/rebase-stack.yml | 97 +++++++++++++++++++++++++----- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/.github/workflows/rebase-stack.yml b/.github/workflows/rebase-stack.yml index 26f44765..5f726a51 100644 --- a/.github/workflows/rebase-stack.yml +++ b/.github/workflows/rebase-stack.yml @@ -18,9 +18,12 @@ # rebase silently altered code, it refuses to force-push. # 5. If a rebase hits conflicts, it leaves a comment with manual fix # instructions and stops processing that chain. -# 6. Deletes the merged PR's head branch after all child PRs have been -# retargeted and rebased. If the chain failed, the branch is kept -# to avoid closing child PRs whose base was not yet updated. +# 6. Sweeps merged head branches and deletes any that no open PR still +# references (as base or head). This deletes the just-merged branch once +# its children are retargeted, keeps it when an immediate child rebase +# failed (that child still bases on it), and — because the sweep runs on +# every merge — also reaps branches stranded by an earlier conflicted run +# after their children were manually rebased and retargeted. # # Why "rebase --onto" instead of "--fork-point": # GitHub Actions runs on a fresh clone with no reflog, so --fork-point @@ -252,6 +255,70 @@ jobs: return 0 } + # cleanup_orphaned_merged_branches enforces the branch-lifecycle + # invariant: a head branch is deleted once its PR is merged AND no + # open PR still references it (as a base — a child waiting to be + # rebased — or as a head). It runs on every invocation, so besides + # cleaning up the branch just merged, it also reaps branches left + # behind by an EARLIER conflicted run: when this job cannot auto- + # rebase a child it intentionally keeps the merged branch (the child + # still bases on it); that run never fires again, so nothing would + # otherwise delete the branch after the author manually rebases the + # child and retargets it off. This sweep closes that gap on the next + # merge. + cleanup_orphaned_merged_branches() { + echo "" + echo "Sweeping for merged head branches with no open dependents..." + + # One network round-trip for the set of branches that still exist. + local existing + existing=$(git ls-remote --heads origin | sed 's#.*refs/heads/##') + + local merged + merged=$(gh pr list \ + --state merged \ + --limit 100 \ + --json number,headRefName \ + --jq '.[] | "\(.number) \(.headRefName)"') + + if [ -z "$merged" ]; then + echo " No merged PRs to consider." + return 0 + fi + + # A branch may back several merged PRs over time; only consider it + # once. + local seen="" + while IFS=' ' read -r num branch; do + [ -n "$branch" ] || continue + case " $seen " in *" $branch "*) continue ;; esac + seen="$seen $branch" + + # Skip branches already gone, and never touch the merged base + # (e.g. main) even if it somehow shows up as a head ref. + grep -qxF "$branch" <<< "$existing" || continue + [ "$branch" = "$MERGED_BASE" ] && continue + + # Keep the branch while any OPEN PR still uses it as a base (a + # child waiting to be rebased) or as a head (the branch was reused + # for a new open PR). Deleting a base branch out from under an + # open child is exactly the breakage this workflow exists to + # prevent. + local base_deps head_uses + base_deps=$(gh pr list --base "$branch" --state open --json number --jq 'length') + head_uses=$(gh pr list --head "$branch" --state open --json number --jq 'length') + + if [ "$base_deps" = "0" ] && [ "$head_uses" = "0" ]; then + echo " Deleting orphaned merged branch '$branch' (PR #$num) — no open dependents." + git push origin --delete "$branch" 2>/dev/null || echo " (already gone)" + else + echo " Keeping '$branch' (open base deps=$base_deps, open head uses=$head_uses)." + fi + done <<< "$merged" + + return 0 + } + echo "Merged PR: ${MERGED_HEAD} -> ${MERGED_BASE}" echo "Merged head SHA: ${MERGED_HEAD_SHA}" @@ -260,8 +327,8 @@ jobs: # Kick off the recursive rebase. Immediate children of the merged PR # get rebased onto MERGED_BASE, using MERGED_HEAD_SHA as the old # fork point (the tip of the now-merged branch before it was deleted). - # "|| rebase_result=$?" prevents set -e from aborting — we always - # want to clean up the merged branch regardless of rebase outcome. + # "|| rebase_result=$?" prevents set -e from aborting — we always run + # the cleanup sweep regardless of rebase outcome. rebase_result=0 rebase_chain \ "$MERGED_HEAD" \ @@ -270,18 +337,16 @@ jobs: "$MERGED_BASE" \ || rebase_result=$? - # Delete the merged PR's head branch. This runs on EVERY merged PR, - # whether or not it had a stack of child PRs — this workflow, not - # GitHub, owns branch cleanup (see the REPO SETTING REQUIREMENT in - # the header). The single exception is a failed child rebase: some - # child PRs may still target this branch, and deleting it would make - # GitHub close them, so we keep it for manual resolution. echo "" if [ "$rebase_result" -eq 0 ]; then - echo "Deleting merged branch: $MERGED_HEAD" - git push origin --delete "$MERGED_HEAD" 2>/dev/null || echo "Branch already deleted or never existed." - echo "=== Done: children (if any) rebased; merged branch deleted ===" + echo "=== Stack rebase complete ===" else - echo "Keeping merged branch '$MERGED_HEAD' to avoid closing child PRs whose base was not updated." - echo "=== Rebase chain stopped due to conflicts ===" + echo "::warning::Rebase chain stopped due to conflicts; see the PR comment for manual steps." + echo "=== Stack rebase incomplete ===" fi + + # Reap merged head branches that nothing depends on anymore. This + # deletes MERGED_HEAD on a clean run, KEEPS it when an immediate child + # rebase failed (that child still bases on it), and cleans up branches + # stranded by earlier conflicted runs once their children were fixed. + cleanup_orphaned_merged_branches