Skip to content

Commit 9fa0afe

Browse files
behinddwallsclaude
andcommitted
ci(workflow): auto-rebase stacked PRs on merge
When a PR in a stack is merged (squash or rebase), GitHub retargets the next PR's base to main but doesn't rebase the branch — leaving stale parent commits in the diff. This workflow automates the fix: - Triggers on PR merge, finds child PRs stacked on the merged branch - Rebases each child onto the merged PR's base using `git rebase --onto` to replay only the child's own commits - Walks the full chain recursively to any depth (PR2 → PR3 → PR4...) - On conflict: leaves a comment with manual fix instructions, stops chain - Deletes the merged branch after rebasing (replaces GitHub auto-delete) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cedec95 commit 9fa0afe

1 file changed

Lines changed: 216 additions & 0 deletions

File tree

.github/workflows/rebase-stack.yml

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# Rebase Stacked PRs
2+
#
3+
# Problem:
4+
# When using stacked PRs (main -> PR1 -> PR2 -> PR3), merging PR1 via
5+
# squash or rebase causes GitHub to retarget PR2's base to main. However,
6+
# PR2's branch still contains PR1's original commits, so its diff shows
7+
# both PR1 and PR2 changes — a broken diff that confuses reviewers.
8+
#
9+
# Solution:
10+
# This workflow triggers when any PR is merged and automatically:
11+
# 1. Finds all open PRs whose base branch is the merged PR's head branch
12+
# (i.e., the next PR in the stack).
13+
# 2. Rebases each child PR onto the merged PR's base (e.g., main), using
14+
# "git rebase --onto" to replay only the child's own commits.
15+
# 3. Walks the full chain recursively — if PR2 is rebased, PR3 (based on
16+
# PR2) is also rebased onto the new PR2, and so on to any depth.
17+
# 4. If a rebase hits conflicts, it leaves a comment with manual fix
18+
# instructions and stops processing that chain.
19+
# 5. Deletes the merged PR's head branch (replaces GitHub's auto-delete).
20+
#
21+
# Why "rebase --onto" instead of "--fork-point":
22+
# GitHub Actions runs on a fresh clone with no reflog, so --fork-point
23+
# (which arh uses locally) cannot detect fork points. Instead, we use
24+
# the merged PR's head SHA from the event payload as the explicit old
25+
# base, achieving the same result.
26+
#
27+
# Example: main -> PR1(branch1, C1) -> PR2(branch2, C2) -> PR3(branch3, C3)
28+
# PR1 merges into main:
29+
# 1. rebase --onto origin/main <old-branch1-sha> branch2 (replays C2)
30+
# 2. rebase --onto <new-branch2-sha> <old-branch2-sha> branch3 (replays C3)
31+
# 3. Delete branch1
32+
# Result: main -> PR2(branch2, C2') -> PR3(branch3, C3')
33+
34+
name: Rebase Stacked PRs
35+
36+
on:
37+
pull_request:
38+
types:
39+
- closed
40+
41+
permissions:
42+
contents: write
43+
pull-requests: write
44+
45+
jobs:
46+
rebase-stack:
47+
name: Rebase Stack
48+
if: github.event.pull_request.merged == true
49+
runs-on: ubuntu-latest
50+
steps:
51+
- uses: actions/checkout@v4
52+
with:
53+
# Fetch full history so rebase --onto works correctly.
54+
fetch-depth: 0
55+
token: ${{ secrets.GITHUB_TOKEN }}
56+
57+
- name: Rebase stacked PRs
58+
env:
59+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
60+
MERGED_HEAD: ${{ github.event.pull_request.head.ref }}
61+
MERGED_BASE: ${{ github.event.pull_request.base.ref }}
62+
MERGED_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
63+
run: |
64+
set -euo pipefail
65+
66+
git config user.name "github-actions[bot]"
67+
git config user.email "github-actions[bot]@users.noreply.github.com"
68+
69+
# rebase_chain walks the stack depth-first, rebasing each child PR
70+
# onto its new base and recursing into grandchildren.
71+
#
72+
# It uses "git rebase --onto" to replay only a branch's OWN commits:
73+
# git rebase --onto <new_base> <old_base_sha> <branch>
74+
# This takes the commits between old_base_sha and branch tip, and
75+
# replays them onto new_base — discarding the parent's commits that
76+
# the child was carrying.
77+
#
78+
# Args:
79+
# $1 - lookup_base: branch name to find child PRs (gh pr list --base)
80+
# $2 - rebase_onto: SHA or ref to rebase onto
81+
# $3 - old_base_sha: SHA of the old base tip; commits between this and
82+
# the child branch tip are the child's own commits
83+
# $4 - new_pr_base: branch name to set as the PR's new base in GitHub
84+
rebase_chain() {
85+
local lookup_base="$1"
86+
local rebase_onto="$2"
87+
local old_base_sha="$3"
88+
local new_pr_base="$4"
89+
90+
# Find open PRs whose base branch matches the lookup branch.
91+
local prs
92+
prs=$(gh pr list \
93+
--base "$lookup_base" \
94+
--state open \
95+
--json number,headRefName \
96+
--jq '.[] | "\(.number) \(.headRefName)"')
97+
98+
if [ -z "$prs" ]; then
99+
return 0
100+
fi
101+
102+
while IFS=' ' read -r pr_number pr_branch; do
103+
echo ""
104+
echo "=== Rebasing PR #${pr_number} (${pr_branch}) ==="
105+
echo " onto: ${rebase_onto}"
106+
echo " old base SHA: ${old_base_sha}"
107+
108+
git fetch origin "$pr_branch"
109+
git checkout -B "$pr_branch" "origin/$pr_branch"
110+
111+
# Save the pre-rebase tip. When we recurse into grandchildren,
112+
# this becomes their old_base_sha (the boundary between this
113+
# branch's commits and the grandchild's commits).
114+
local old_child_tip
115+
old_child_tip=$(git rev-parse HEAD)
116+
117+
# Replay only this branch's own commits onto the new base.
118+
if ! git rebase --onto "$rebase_onto" "$old_base_sha" "$pr_branch" 2>&1; then
119+
echo "::warning::Rebase failed for PR #${pr_number} (${pr_branch})."
120+
git rebase --abort 2>/dev/null || true
121+
122+
# Leave instructions for manual resolution.
123+
local comment_body
124+
comment_body=$(cat <<EOF
125+
:warning: **Automatic stack rebase failed**
126+
127+
This PR could not be automatically rebased after its base PR was merged. The rebase hit conflicts that need manual resolution.
128+
129+
**To fix manually:**
130+
\`\`\`bash
131+
git fetch origin
132+
git checkout ${pr_branch}
133+
git rebase --onto origin/${new_pr_base} ${old_base_sha} ${pr_branch}
134+
# resolve conflicts, then:
135+
git push --force-with-lease
136+
\`\`\`
137+
138+
Then update this PR's base branch:
139+
\`\`\`bash
140+
gh pr edit ${pr_number} --base ${new_pr_base}
141+
\`\`\`
142+
EOF
143+
)
144+
gh pr comment "$pr_number" --body "$comment_body"
145+
146+
# Update the base even on failure so GitHub shows the correct
147+
# target branch, even if the diff is still wrong.
148+
gh pr edit "$pr_number" --base "$new_pr_base"
149+
150+
echo "::warning::Stopping chain at PR #${pr_number} due to conflicts."
151+
return 1
152+
fi
153+
154+
local new_child_tip
155+
new_child_tip=$(git rev-parse HEAD)
156+
157+
echo " rebased: ${old_child_tip} -> ${new_child_tip}"
158+
159+
# Push the rebased branch. --force-with-lease fails if someone
160+
# else pushed to this branch concurrently, preventing data loss.
161+
if ! git push --force-with-lease origin "$pr_branch" 2>&1; then
162+
echo "::warning::Force push failed for PR #${pr_number} (${pr_branch})."
163+
164+
gh pr comment "$pr_number" --body ":warning: **Automatic stack rebase failed**
165+
166+
The rebase succeeded but force-push failed for \`$pr_branch\`. This may be due to a concurrent push. Please rebase manually."
167+
168+
gh pr edit "$pr_number" --base "$new_pr_base"
169+
return 1
170+
fi
171+
172+
# Point the PR at the correct base branch in GitHub.
173+
gh pr edit "$pr_number" --base "$new_pr_base"
174+
echo " PR #${pr_number} base updated to '${new_pr_base}'."
175+
176+
# Recurse into grandchildren: PRs whose base is this child's
177+
# branch. They need to rebase onto the NEW child tip (post-rebase),
178+
# using the OLD child tip as their fork point. Their GitHub base
179+
# stays as pr_branch since that branch still exists.
180+
rebase_chain "$pr_branch" "$new_child_tip" "$old_child_tip" "$pr_branch" || return 1
181+
182+
done <<< "$prs"
183+
184+
return 0
185+
}
186+
187+
echo "Merged PR: ${MERGED_HEAD} -> ${MERGED_BASE}"
188+
echo "Merged head SHA: ${MERGED_HEAD_SHA}"
189+
190+
git fetch origin "$MERGED_BASE"
191+
192+
# Kick off the recursive rebase. Immediate children of the merged PR
193+
# get rebased onto MERGED_BASE, using MERGED_HEAD_SHA as the old
194+
# fork point (the tip of the now-merged branch before it was deleted).
195+
# "|| rebase_result=$?" prevents set -e from aborting — we always
196+
# want to clean up the merged branch regardless of rebase outcome.
197+
rebase_result=0
198+
rebase_chain \
199+
"$MERGED_HEAD" \
200+
"origin/$MERGED_BASE" \
201+
"$MERGED_HEAD_SHA" \
202+
"$MERGED_BASE" \
203+
|| rebase_result=$?
204+
205+
# Delete the merged PR's head branch. This replaces GitHub's
206+
# auto-delete-head-branch setting, giving us control over timing
207+
# (we delete only after the stack is rebased).
208+
echo ""
209+
echo "Deleting merged branch: $MERGED_HEAD"
210+
git push origin --delete "$MERGED_HEAD" 2>/dev/null || echo "Branch already deleted."
211+
212+
if [ "$rebase_result" -eq 0 ]; then
213+
echo "=== All stacked PRs rebased successfully ==="
214+
else
215+
echo "=== Rebase chain stopped due to conflicts ==="
216+
fi

0 commit comments

Comments
 (0)