|
| 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