Skip to content

Commit 0386ed1

Browse files
Phlogistiqueclaudegithub-actions
authored
Handle merge-commit merges, skip rebase merges with a comment (#49)
The squash sequence assumes `SQUASH_COMMIT~` is the target just before the merge, which only holds for squashes. GitHub records the merge method nowhere (payload, REST, GraphQL), so the action infers it and now handles each shape: * Merge commit: detected by the second parent. The merge carries the parent's commits into the target, so the children's heads are already valid; the action only retargets the children and deletes the merged branch, the same end state GitHub produces natively when the branch is deleted. Going through the squash sequence also produced a correct result (verified by simulation), but pushed a redundant synthetic merge to every child, with a CI run each. * Rebase: detected through GitHub's commit-PR association (`/commits/{sha}/pulls`, "the merged pull request that introduced the commit"). A squash introduces a single commit, so the commit below the merge sha belongs to an older PR or to none; a rebase introduces a copy of each PR commit, so the commit below still belongs to this PR (verified against real merges on a scratch repo). The association is computed asynchronously, ~15-30s after the merge in testing, so an empty answer is only trusted once the merge sha itself is associated; on API failure or timeout the run aborts rather than guessing. Not supported: the copies are new commits, so a child retargeted as-is would show the parent's changes in its diff, and the squash sequence can raise spurious conflicts against the intermediate copies (observed in simulation). The action comments on each child PR that the stack must be updated manually and leaves everything alone, including the merged branch. * Single-commit PRs merge identically under rebase and squash and correctly read as squashes. A PR whose commits are all merge commits would defeat the rebase detection, but GitHub refuses to rebase-merge those (`rebaseable: false`, verified on a scratch repo). Stacked on #45. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JHvKryT4QUpHYdNq9YEQxX --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: github-actions <github-actions@github.com>
1 parent 0af0330 commit 0386ed1

9 files changed

Lines changed: 292 additions & 2 deletions

.github/workflows/tests.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ jobs:
1717
bash tests/test_rebase_workflow.sh
1818
bash tests/test_mixed_workflows.sh
1919
bash tests/test_conflict_resolution_resume.sh
20+
bash tests/test_merge_commit_merge.sh
21+
bash tests/test_rebase_merge_skip.sh
2022
2123
e2e-tests:
2224
name: E2E Tests

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ jobs:
132132
133133
### Notes
134134
135-
* Currently only supports squash merges
135+
* 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.
136136
* If a merge hits a conflict, you'll need to resolve it manually; pushing the resolution automatically continues the stack update
137137
* Very large stacks might hit GitHub rate limits
138138

tests/mock_gh.sh

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ if [[ "$1" == "pr" && "$2" == "list" ]]; then
2323
elif [[ "$1" == "pr" && "$2" == "edit" ]]; then
2424
# Just log the edit command
2525
echo "Mock: gh pr edit $3 --base $5"
26+
elif [[ "$1" == "pr" && "$2" == "comment" ]]; then
27+
# Just log the comment command
28+
echo "Mock: gh pr comment $3"
29+
elif [[ "$1" == "api" && "$2" == repos/*/commits/*/pulls ]]; then
30+
# Which PRs introduced this trunk commit (already --jq filtered to bare
31+
# numbers). The merge commit belongs to the merged PR, and so does any sha
32+
# listed in MOCK_REBASE_COPIES (space-separated); anything else was not
33+
# introduced by a PR. SQUASH_COMMIT and PR_NUMBER come from the test's env.
34+
sha="${2#*/commits/}"
35+
sha="${sha%/pulls}"
36+
if [[ "$sha" == "$SQUASH_COMMIT" || " ${MOCK_REBASE_COPIES:-} " == *" $sha "* ]]; then
37+
echo "$PR_NUMBER"
38+
fi
2639
else
2740
echo "Unknown gh command: $@" >&2
2841
exit 1

tests/test_merge_commit_merge.sh

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
#!/bin/bash
2+
#
3+
# A PR merged with a merge commit (not squashed) keeps its history, so stacked
4+
# children already contain the parent's commits and their heads must not be
5+
# rewritten. The action only retargets the children and deletes the merged
6+
# branch.
7+
8+
set -ueo pipefail
9+
10+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11+
source "$SCRIPT_DIR/../command_utils.sh"
12+
13+
simulate_push() {
14+
log_cmd git update-ref "refs/remotes/origin/$1" "$1"
15+
}
16+
17+
TEST_REPO=$(mktemp -d)
18+
cd "$TEST_REPO"
19+
echo "Created test repo at $TEST_REPO"
20+
21+
log_cmd git init -b main
22+
log_cmd git config user.email "test@example.com"
23+
log_cmd git config user.name "Test User"
24+
25+
echo "line" > file.txt
26+
log_cmd git add file.txt
27+
log_cmd git commit -m "Initial commit"
28+
simulate_push main
29+
30+
log_cmd git checkout -b feature1
31+
echo "f1" >> file.txt
32+
log_cmd git add file.txt
33+
log_cmd git commit -m "Add feature 1"
34+
simulate_push feature1
35+
36+
log_cmd git checkout -b feature2
37+
echo "f2" >> file.txt
38+
log_cmd git add file.txt
39+
log_cmd git commit -m "Add feature 2"
40+
simulate_push feature2
41+
42+
# Merge feature1 into main with a real merge commit
43+
log_cmd git checkout main
44+
log_cmd git merge --no-ff --no-edit feature1
45+
MERGE_COMMIT=$(git rev-parse HEAD)
46+
simulate_push main
47+
48+
FEATURE2_BEFORE=$(git rev-parse feature2)
49+
50+
OUT=$(env \
51+
SQUASH_COMMIT="$MERGE_COMMIT" \
52+
MERGED_BRANCH=feature1 \
53+
PR_NUMBER=1 \
54+
TARGET_BRANCH=main \
55+
GH="$SCRIPT_DIR/mock_gh.sh" \
56+
GIT="$SCRIPT_DIR/mock_git.sh" \
57+
"$SCRIPT_DIR/../update-pr-stack.sh" 2>&1)
58+
echo "$OUT"
59+
60+
if ! grep -q "merged with a merge commit" <<<"$OUT"; then
61+
echo "❌ Expected the merge-commit message"
62+
exit 1
63+
fi
64+
if [[ "$(git rev-parse feature2)" != "$FEATURE2_BEFORE" ]]; then
65+
echo "❌ feature2's head must not be rewritten"
66+
exit 1
67+
fi
68+
69+
# Children must be retargeted before the merged branch is deleted (deleting a
70+
# PR's base branch closes the PR).
71+
EDIT_LINE=$(grep -n "pr edit 2 --base main" <<<"$OUT" | cut -d: -f1 | head -1 || true)
72+
DELETE_LINE=$(grep -n "push origin :feature1" <<<"$OUT" | cut -d: -f1 | head -1 || true)
73+
if [[ -z "$EDIT_LINE" ]]; then
74+
echo "❌ Child PR must be retargeted onto main"
75+
exit 1
76+
fi
77+
if [[ -z "$DELETE_LINE" ]]; then
78+
echo "❌ Merged branch must be deleted"
79+
exit 1
80+
fi
81+
if [[ "$EDIT_LINE" -gt "$DELETE_LINE" ]]; then
82+
echo "❌ Retarget must happen before the branch deletion (edit=$EDIT_LINE delete=$DELETE_LINE)"
83+
exit 1
84+
fi
85+
86+
# The untouched head keeps a clean diff against the new base
87+
ACTUAL_DIFF=$(git diff main...feature2 | grep '^[+-]' | grep -v '^[+-][+-][+-]')
88+
if [[ "$ACTUAL_DIFF" != "+f2" ]]; then
89+
echo "❌ Diff main...feature2 should show only feature2's change, got:"
90+
echo "$ACTUAL_DIFF"
91+
exit 1
92+
fi
93+
94+
echo "✅ Merge-commit merge: children retargeted, heads untouched, branch deleted"

tests/test_mixed_workflows.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ run_update_pr_stack() {
9090
env \
9191
SQUASH_COMMIT=$SQUASH_COMMIT \
9292
MERGED_BRANCH=feature1 \
93+
PR_NUMBER=1 \
9394
TARGET_BRANCH=main \
9495
GH="$SCRIPT_DIR/mock_gh.sh" \
9596
GIT="$SCRIPT_DIR/mock_git.sh" \

tests/test_rebase_merge_skip.sh

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#!/bin/bash
2+
#
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.
8+
9+
set -ueo pipefail
10+
11+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12+
source "$SCRIPT_DIR/../command_utils.sh"
13+
14+
simulate_push() {
15+
log_cmd git update-ref "refs/remotes/origin/$1" "$1"
16+
}
17+
18+
TEST_REPO=$(mktemp -d)
19+
cd "$TEST_REPO"
20+
echo "Created test repo at $TEST_REPO"
21+
22+
log_cmd git init -b main
23+
log_cmd git config user.email "test@example.com"
24+
log_cmd git config user.name "Test User"
25+
26+
for i in $(seq 1 15); do echo "line $i" >> file.txt; done
27+
log_cmd git add file.txt
28+
log_cmd git commit -m "Initial commit"
29+
simulate_push main
30+
31+
# feature1: two commits, so the rebase leaves a copy below the merge sha
32+
log_cmd git checkout -b feature1
33+
sed -i '2s/.*/Feature 1 change A/' file.txt
34+
log_cmd git add file.txt
35+
log_cmd git commit -m "feature1: commit A"
36+
sed -i '6s/.*/Feature 1 change B/' file.txt
37+
log_cmd git add file.txt
38+
log_cmd git commit -m "feature1: commit B"
39+
simulate_push feature1
40+
41+
log_cmd git checkout -b feature2
42+
sed -i '14s/.*/Feature 2 content/' file.txt
43+
log_cmd git add file.txt
44+
log_cmd git commit -m "feature2: change"
45+
simulate_push feature2
46+
47+
# main advances independently, then feature1 is rebase-merged: GitHub copies
48+
# each PR commit onto the target, which cherry-pick reproduces
49+
log_cmd git checkout main
50+
sed -i '10s/.*/Main hotfix/' file.txt
51+
log_cmd git add file.txt
52+
log_cmd git commit -m "main: hotfix"
53+
log_cmd git cherry-pick feature1~1 feature1
54+
MERGE_COMMIT=$(git rev-parse HEAD)
55+
simulate_push main
56+
57+
FEATURE2_BEFORE=$(git rev-parse feature2)
58+
59+
OUT=$(env \
60+
SQUASH_COMMIT="$MERGE_COMMIT" \
61+
MERGED_BRANCH=feature1 \
62+
PR_NUMBER=1 \
63+
TARGET_BRANCH=main \
64+
MOCK_REBASE_COPIES="$(git rev-parse "$MERGE_COMMIT~")" \
65+
GH="$SCRIPT_DIR/mock_gh.sh" \
66+
GIT="$SCRIPT_DIR/mock_git.sh" \
67+
"$SCRIPT_DIR/../update-pr-stack.sh" 2>&1)
68+
echo "$OUT"
69+
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"
76+
exit 1
77+
fi
78+
if grep -q "pr edit" <<<"$OUT"; then
79+
echo "❌ No PR must be retargeted"
80+
exit 1
81+
fi
82+
if grep -q "push origin :feature1" <<<"$OUT"; then
83+
echo "❌ The merged branch must be kept"
84+
exit 1
85+
fi
86+
if [[ "$(git rev-parse feature2)" != "$FEATURE2_BEFORE" ]]; then
87+
echo "❌ feature2's head must not be rewritten"
88+
exit 1
89+
fi
90+
91+
echo "✅ Rebase merge detected: children warned, stack left alone"

tests/test_rebase_workflow.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ run_update_pr_stack() {
8585
env \
8686
SQUASH_COMMIT=$SQUASH_COMMIT \
8787
MERGED_BRANCH=feature1 \
88+
PR_NUMBER=1 \
8889
TARGET_BRANCH=main \
8990
GH="$SCRIPT_DIR/mock_gh.sh" \
9091
GIT="$SCRIPT_DIR/mock_git.sh" \

tests/test_update_pr_stack.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ run_update_pr_stack() {
7878
env \
7979
SQUASH_COMMIT=$SQUASH_COMMIT \
8080
MERGED_BRANCH=feature1 \
81+
PR_NUMBER=1 \
8182
TARGET_BRANCH=main \
8283
GH="$SCRIPT_DIR/mock_gh.sh" \
8384
GIT="$SCRIPT_DIR/mock_git.sh" \

update-pr-stack.sh

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# SQUASH_COMMIT - The hash of the squash commit that was merged
77
# MERGED_BRANCH - The name of the branch that was merged and will be deleted
88
# TARGET_BRANCH - The name of the branch that the PR was merged into
9+
# PR_NUMBER - The number of the PR that was merged
910
#
1011
# Required environment variables (conflict-resolved mode):
1112
# PR_BRANCH - The head branch of the PR being resumed
@@ -79,6 +80,63 @@ has_squash_commit() {
7980
&& git merge-base --is-ancestor SQUASH_COMMIT "$BRANCH"
8081
}
8182

83+
# Args: a commit sha. Echoes the numbers of the pull requests that introduced
84+
# the commit to the repository, one per line.
85+
commit_pull_numbers() {
86+
gh api "repos/{owner}/{repo}/commits/$1/pulls" --jq '.[].number' \
87+
|| { echo "❌ Could not list the pull requests that introduced commit $1" >&2; return 1; }
88+
}
89+
90+
# Args: the merge commit sha, the merged PR's number. The association is
91+
# computed asynchronously, some time after the merge. The merge commit always
92+
# belongs to the merged PR, so once it shows up the index has caught up with
93+
# this merge; until then, an empty answer for any commit of the merge means
94+
# nothing. Exits if the association never appears.
95+
wait_for_pull_association() {
96+
local MERGE_SHA="$1" PR_NUMBER="$2"
97+
local NUMBERS
98+
for _ in $(seq 1 24); do
99+
NUMBERS=$(commit_pull_numbers "$MERGE_SHA") || exit 1
100+
if grep -qx "$PR_NUMBER" <<<"$NUMBERS"; then
101+
return 0
102+
fi
103+
sleep "${ASSOCIATION_POLL_SECONDS:-5}"
104+
done
105+
echo "❌ GitHub never associated $MERGE_SHA with PR #$PR_NUMBER; cannot tell a squash from a rebase" >&2
106+
exit 1
107+
}
108+
109+
# Args: the merged PR's number. The event payload does not say which merge
110+
# method was used (GitHub records it nowhere), but GitHub associates every
111+
# trunk commit with the PR that introduced it. A squash introduces a single
112+
# commit, so the commit below SQUASH_COMMIT belongs to an older PR or to
113+
# none; a rebase introduces a copy of each PR commit, so with two or more
114+
# commits the one below SQUASH_COMMIT still belongs to this PR. A
115+
# single-commit PR merges identically under rebase and squash and correctly
116+
# reads as a squash here.
117+
is_rebase_merge() {
118+
local PR_NUMBER="$1"
119+
local MERGE_SHA PARENT_SHA NUMBERS
120+
MERGE_SHA=$(git rev-parse SQUASH_COMMIT)
121+
PARENT_SHA=$(git rev-parse SQUASH_COMMIT~)
122+
123+
NUMBERS=$(commit_pull_numbers "$PARENT_SHA") || exit 1
124+
if [[ -z "$NUMBERS" ]]; then
125+
# Ambiguous: "no PR introduced this commit" (a squash on top of a
126+
# direct push) and "not indexed yet" (a rebase copy) both come back
127+
# empty. Wait until the index has caught up with this merge, then ask
128+
# again; this time empty really means no PR.
129+
wait_for_pull_association "$MERGE_SHA" "$PR_NUMBER"
130+
NUMBERS=$(commit_pull_numbers "$PARENT_SHA") || exit 1
131+
fi
132+
grep -qx "$PR_NUMBER" <<<"$NUMBERS"
133+
}
134+
135+
# Echoes "<number> <head branch>" for each open PR based on the merged branch.
136+
list_child_prs() {
137+
log_cmd gh pr list --base "$MERGED_BRANCH" --json number,headRefName --jq '.[] | "\(.number) \(.headRefName)"'
138+
}
139+
82140
# Args: head branch, base branch, PR number. git commands use the branch; gh
83141
# commands use the number, since a head branch can carry several PRs.
84142
update_direct_target() {
@@ -298,17 +356,46 @@ main() {
298356
check_env_var "SQUASH_COMMIT"
299357
check_env_var "MERGED_BRANCH"
300358
check_env_var "TARGET_BRANCH"
359+
check_env_var "PR_NUMBER"
301360

302361
log_cmd git update-ref SQUASH_COMMIT "$SQUASH_COMMIT"
303362

363+
# A merge-commit merge does not rewrite history: each child's head already
364+
# contains the merged branch's commits, and the merge commit carries them
365+
# into TARGET_BRANCH. The heads need no synthetic merge; just retarget the
366+
# children and delete the merged branch.
367+
if git rev-parse --verify --quiet SQUASH_COMMIT^2 >/dev/null; then
368+
echo "✓ '$MERGED_BRANCH' was merged with a merge commit, not squashed; retargeting children without touching their heads"
369+
while read -r NUMBER BRANCH; do
370+
[[ -n "$BRANCH" ]] || continue
371+
log_cmd gh pr edit "$NUMBER" --base "$TARGET_BRANCH"
372+
done < <(list_child_prs)
373+
# Deleting a PR's base branch closes the PR, so the retargets come first.
374+
log_cmd git push origin ":$MERGED_BRANCH"
375+
return 0
376+
fi
377+
378+
# Rebase merges are not supported: the copies on the target are new
379+
# commits, so a child retargeted as-is would show its parent's changes in
380+
# its diff, and the squash sequence can raise spurious conflicts against
381+
# the intermediate copies. Tell the children and leave everything alone.
382+
if is_rebase_merge "$PR_NUMBER"; then
383+
echo "⚠️ '$MERGED_BRANCH' looks rebase-merged; rebase merges are not supported, leaving the stack alone"
384+
while read -r NUMBER BRANCH; do
385+
[[ -n "$BRANCH" ]] || continue
386+
log_cmd 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."
387+
done < <(list_child_prs)
388+
return 0
389+
fi
390+
304391
# Find all PRs directly targeting the merged PR's head
305392
INITIAL_NUMBERS=()
306393
INITIAL_TARGETS=()
307394
while read -r NUMBER BRANCH; do
308395
[[ -n "$BRANCH" ]] || continue
309396
INITIAL_NUMBERS+=("$NUMBER")
310397
INITIAL_TARGETS+=("$BRANCH")
311-
done < <(log_cmd gh pr list --base "$MERGED_BRANCH" --json number,headRefName --jq '.[] | "\(.number) \(.headRefName)"')
398+
done < <(list_child_prs)
312399

313400
# Track successfully updated vs conflicted branches separately
314401
UPDATED_TARGETS=()

0 commit comments

Comments
 (0)