Skip to content

Commit 1cf33fe

Browse files
authored
ci(workflow): auto-rebase stacked PRs on merge (#133)
## Summary 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)
1 parent 11fbe75 commit 1cf33fe

1 file changed

Lines changed: 249 additions & 0 deletions

File tree

.github/workflows/rebase-stack.yml

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

0 commit comments

Comments
 (0)