Skip to content

Commit 7d6e326

Browse files
Phlogistiqueclaudegithub-actions
authored
Support rebase merges (#60)
Stacked on #59. A rebase merge lands the branch's content as new commits without merging its history, same as a squash spread over several commits. The old refusal reasons were specific to the merge dance, which merged `SQUASH_COMMIT~` and so could conflict against intermediate copies; the single-merge re-parent (#56) only reads the landed tip's tree, and with `--absorbed` (#59) the recorded parent is the merged branch's tip in both cases. So the squash path handles rebase merges as-is. This deletes the squash-vs-rebase detection (`is_rebase_merge` and its commit-PR association polling, the flakiest part of the action: the association is computed asynchronously and had to be waited for). The only case left to detect is a merge-commit merge, where the children already contain their parent's history and the action only retargets. `tests/test_rebase_merge_skip.sh`, which asserted the old refusal, becomes `tests/test_rebase_merge.sh` and asserts the update. --- _Generated by [Claude Code](https://claude.ai/code/session_01PHUVU3Ek3U9j3LrdjPnyAy)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: github-actions <github-actions@github.com>
1 parent 6c99628 commit 7d6e326

6 files changed

Lines changed: 43 additions & 101 deletions

File tree

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
bash tests/test_conflict_resolution_resume.sh
2121
bash tests/test_conflict_absorbed_resolution.sh
2222
bash tests/test_merge_commit_merge.sh
23-
bash tests/test_rebase_merge_skip.sh
23+
bash tests/test_rebase_merge.sh
2424
2525
e2e-tests:
2626
name: E2E Tests

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ jobs:
134134
135135
### Notes
136136
137-
* Built for squash merges. A PR merged with a merge commit keeps its history, so the action only retargets its children and deletes the branch. Rebase merges are not supported: the action detects them through GitHub's commit-PR association (the merge method itself is recorded nowhere) and comments on each child PR instead of acting.
137+
* Works with squash merges and rebase merges: both land the branch's content as new commits without merging its history, so the children are re-parented onto the landed tip (the squash commit, or the last copied commit of a rebase merge). A PR merged with a merge commit keeps its history, so the action only retargets its children and deletes the branch.
138138
* If a merge hits a conflict, you'll need to resolve it manually; pushing the resolution automatically continues the stack update
139139
* Very large stacks might hit GitHub rate limits
140140
* After retargeting, GitHub sometimes keeps rendering a PR's diff against its old, deleted base, so the PR appears to contain already-merged changes. The branch itself is correct (`git diff <base>...HEAD` shows the real diff). Pushing any commit to the PR usually makes GitHub recompute. Sometimes it doesn't. Tough luck.

action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: 'Stacked PR Update Action'
2-
description: 'Automatically fixes stacked PRs after a base PR is squash-merged'
2+
description: 'Automatically fixes stacked PRs after a base PR is squash- or rebase-merged'
33
author: 'GitHub Actions'
44

55
inputs:

tests/mock_gh.sh

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,6 @@ elif [[ "$1" == "pr" && "$2" == "comment" ]]; then
2929
fi
3030
# Just log the comment command
3131
echo "Mock: gh pr comment $3"
32-
elif [[ "$1" == "api" && "$2" == repos/*/commits/*/pulls ]]; then
33-
# Which PRs introduced this trunk commit (already --jq filtered to bare
34-
# numbers). The merge commit belongs to the merged PR, and so does any sha
35-
# listed in MOCK_REBASE_COPIES (space-separated); anything else was not
36-
# introduced by a PR. SQUASH_COMMIT and PR_NUMBER come from the test's env.
37-
sha="${2#*/commits/}"
38-
sha="${sha%/pulls}"
39-
if [[ "$sha" == "$SQUASH_COMMIT" || " ${MOCK_REBASE_COPIES:-} " == *" $sha "* ]]; then
40-
echo "$PR_NUMBER"
41-
fi
4232
else
4333
echo "Unknown gh command: $@" >&2
4434
exit 1
Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
#!/bin/bash
22
#
3-
# A PR merged with "Rebase and merge" is not supported: the commits on the
4-
# target are new copies, so neither retargeting children as-is nor the squash
5-
# sequence gives a correct result. The action must detect the rebase (the
6-
# commit below the merge sha was also introduced by this PR, per GitHub's
7-
# commit-PR association), comment on the children, and leave the stack alone.
3+
# A PR merged with "Rebase and merge" lands its commits as new copies on the
4+
# target, exactly like a squash spread over several commits: content without
5+
# history. The action treats it like a squash: SQUASH_COMMIT (the PR's
6+
# merge_commit_sha) is the last copy, and the child is re-parented onto it in
7+
# one merge. The intermediate copies never enter the 3-way merge, so they can
8+
# raise no spurious conflicts.
89

910
set -ueo pipefail
1011

@@ -28,7 +29,8 @@ log_cmd git add file.txt
2829
log_cmd git commit -m "Initial commit"
2930
simulate_push main
3031

31-
# feature1: two commits, so the rebase leaves a copy below the merge sha
32+
# feature1: two commits, so the rebase leaves an intermediate copy below the
33+
# merge sha
3234
log_cmd git checkout -b feature1
3335
sed -i '2s/.*/Feature 1 change A/' file.txt
3436
log_cmd git add file.txt
@@ -54,38 +56,47 @@ log_cmd git cherry-pick feature1~1 feature1
5456
MERGE_COMMIT=$(git rev-parse HEAD)
5557
simulate_push main
5658

57-
FEATURE2_BEFORE=$(git rev-parse feature2)
58-
5959
OUT=$(env \
6060
SQUASH_COMMIT="$MERGE_COMMIT" \
6161
MERGED_BRANCH=feature1 \
6262
PR_NUMBER=1 \
6363
TARGET_BRANCH=main \
64-
MOCK_REBASE_COPIES="$(git rev-parse "$MERGE_COMMIT~")" \
6564
GH="$SCRIPT_DIR/mock_gh.sh" \
6665
GIT="$SCRIPT_DIR/mock_git.sh" \
6766
"$SCRIPT_DIR/../update-pr-stack.sh" 2>&1)
6867
echo "$OUT"
6968

70-
if ! grep -q "rebase merges are not supported" <<<"$OUT"; then
71-
echo "❌ Expected the rebase-merge skip message"
72-
exit 1
73-
fi
74-
if ! grep -q "Mock: gh pr comment 2" <<<"$OUT"; then
75-
echo "❌ The child PR must be told the stack was not updated"
69+
if ! git merge-base --is-ancestor "$MERGE_COMMIT" feature2; then
70+
echo "❌ feature2 must be re-parented onto the rebased tip"
71+
log_cmd git log --graph --oneline --all
7672
exit 1
7773
fi
78-
if grep -q "pr edit" <<<"$OUT"; then
79-
echo "No PR must be retargeted"
74+
if ! grep -q "Mock: gh pr edit 2 --base main" <<<"$OUT"; then
75+
echo "The child PR must be retargeted to main"
8076
exit 1
8177
fi
82-
if grep -q "push origin :feature1" <<<"$OUT"; then
83-
echo "❌ The merged branch must be kept"
78+
if ! grep -q "push origin :feature1" <<<"$OUT"; then
79+
echo "❌ The merged branch must be deleted"
8480
exit 1
8581
fi
86-
if [[ "$(git rev-parse feature2)" != "$FEATURE2_BEFORE" ]]; then
87-
echo "❌ feature2's head must not be rewritten"
82+
83+
# The child's PR diff against its new base shows only its own change: the
84+
# parent's changes arrive through the rebased copies on main, not the diff.
85+
EXPECTED_DIFF=$(cat <<'EOF'
86+
-line 14
87+
+Feature 2 content
88+
EOF
89+
)
90+
ACTUAL_DIFF=$(log_cmd git diff main...feature2 | grep '^[+-]' | grep -v '^[+-][+-][+-]')
91+
if [[ "$ACTUAL_DIFF" == "$EXPECTED_DIFF" ]]; then
92+
echo "✅ Triple-dot diff main...feature2 shows only feature2's unique change"
93+
else
94+
echo "❌ Triple-dot diff is wrong"
95+
echo "Expected:"
96+
echo "$EXPECTED_DIFF"
97+
echo "Actual:"
98+
echo "$ACTUAL_DIFF"
8899
exit 1
89100
fi
90101

91-
echo "✅ Rebase merge detected: children warned, stack left alone"
102+
echo "✅ Rebase merge handled like a squash: child re-parented, retargeted, branch deleted"

update-pr-stack.sh

Lines changed: 7 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
# Updates PR stack after merging a PR
44
#
55
# Required environment variables (squash-merge mode):
6-
# SQUASH_COMMIT - The hash of the squash commit that was merged
6+
# SQUASH_COMMIT - The merged PR's merge_commit_sha: the squash commit, or the
7+
# last copied commit of a rebase merge
78
# MERGED_BRANCH - The name of the branch that was merged and will be deleted
89
# TARGET_BRANCH - The name of the branch that the PR was merged into
910
# PR_NUMBER - The number of the PR that was merged
@@ -99,58 +100,6 @@ has_squash_commit() {
99100
&& git merge-base --is-ancestor SQUASH_COMMIT "$BRANCH"
100101
}
101102

102-
# Args: a commit sha. Echoes the numbers of the pull requests that introduced
103-
# the commit to the repository, one per line.
104-
commit_pull_numbers() {
105-
gh api "repos/{owner}/{repo}/commits/$1/pulls" --jq '.[].number' \
106-
|| { echo "❌ Could not list the pull requests that introduced commit $1" >&2; return 1; }
107-
}
108-
109-
# Args: the merge commit sha, the merged PR's number. The association is
110-
# computed asynchronously, some time after the merge. The merge commit always
111-
# belongs to the merged PR, so once it shows up the index has caught up with
112-
# this merge; until then, an empty answer for any commit of the merge means
113-
# nothing. Exits if the association never appears.
114-
wait_for_pull_association() {
115-
local MERGE_SHA="$1" PR_NUMBER="$2"
116-
local NUMBERS
117-
for _ in $(seq 1 24); do
118-
NUMBERS=$(commit_pull_numbers "$MERGE_SHA") || exit 1
119-
if grep -qx "$PR_NUMBER" <<<"$NUMBERS"; then
120-
return 0
121-
fi
122-
sleep "${ASSOCIATION_POLL_SECONDS:-5}"
123-
done
124-
echo "❌ GitHub never associated $MERGE_SHA with PR #$PR_NUMBER; cannot tell a squash from a rebase" >&2
125-
exit 1
126-
}
127-
128-
# Args: the merged PR's number. The event payload does not say which merge
129-
# method was used (GitHub records it nowhere), but GitHub associates every
130-
# trunk commit with the PR that introduced it. A squash introduces a single
131-
# commit, so the commit below SQUASH_COMMIT belongs to an older PR or to
132-
# none; a rebase introduces a copy of each PR commit, so with two or more
133-
# commits the one below SQUASH_COMMIT still belongs to this PR. A
134-
# single-commit PR merges identically under rebase and squash and correctly
135-
# reads as a squash here.
136-
is_rebase_merge() {
137-
local PR_NUMBER="$1"
138-
local MERGE_SHA PARENT_SHA NUMBERS
139-
MERGE_SHA=$(git rev-parse SQUASH_COMMIT)
140-
PARENT_SHA=$(git rev-parse SQUASH_COMMIT~)
141-
142-
NUMBERS=$(commit_pull_numbers "$PARENT_SHA") || exit 1
143-
if [[ -z "$NUMBERS" ]]; then
144-
# Ambiguous: "no PR introduced this commit" (a squash on top of a
145-
# direct push) and "not indexed yet" (a rebase copy) both come back
146-
# empty. Wait until the index has caught up with this merge, then ask
147-
# again; this time empty really means no PR.
148-
wait_for_pull_association "$MERGE_SHA" "$PR_NUMBER"
149-
NUMBERS=$(commit_pull_numbers "$PARENT_SHA") || exit 1
150-
fi
151-
grep -qx "$PR_NUMBER" <<<"$NUMBERS"
152-
}
153-
154103
# Echoes "<number> <head branch>" for each open PR based on the merged branch.
155104
# try, not run: callers run this in a command substitution, where a die would
156105
# only leave the subshell, so they capture the output and die themselves. An
@@ -425,19 +374,11 @@ main() {
425374
return 0
426375
fi
427376

428-
# Rebase merges are not supported: the copies on the target are new
429-
# commits, so a child retargeted as-is would show its parent's changes in
430-
# its diff, and the squash sequence can raise spurious conflicts against
431-
# the intermediate copies. Tell the children and leave everything alone.
432-
if is_rebase_merge "$PR_NUMBER"; then
433-
echo "⚠️ '$MERGED_BRANCH' looks rebase-merged; rebase merges are not supported, leaving the stack alone"
434-
CHILDREN=$(list_child_prs) || die "could not list the PRs based on $MERGED_BRANCH"
435-
while read -r NUMBER BRANCH; do
436-
[[ -n "$BRANCH" ]] || continue
437-
run gh pr comment "$NUMBER" --body "ℹ️ The base branch \`$MERGED_BRANCH\` of this PR was merged with \"Rebase and merge\", which autorestack does not support. Update this PR manually. \`$MERGED_BRANCH\` was kept so this PR stays open."
438-
done <<<"$CHILDREN"
439-
return 0
440-
fi
377+
# A squash merge and a rebase merge both land the branch's content as new
378+
# commits without merging its history, and both take the path below.
379+
# SQUASH_COMMIT (the PR's merge_commit_sha) is the squash commit or the
380+
# last rebased copy; either way its tree carries the merged branch's full
381+
# content, which is all the single-merge re-parent reads from it.
441382

442383
# Find all PRs directly targeting the merged PR's head
443384
CHILDREN=$(list_child_prs) || die "could not list the PRs based on $MERGED_BRANCH"

0 commit comments

Comments
 (0)