Skip to content

Commit 8e565f3

Browse files
authored
Merge branch 'main' into claude/fail-loud-resume-reads-3njvdx
2 parents 3b790a2 + 0386ed1 commit 8e565f3

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
@@ -85,6 +86,63 @@ has_squash_commit() {
8586
&& git merge-base --is-ancestor SQUASH_COMMIT "$BRANCH"
8687
}
8788

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

312371
log_cmd git update-ref SQUASH_COMMIT "$SQUASH_COMMIT"
313372

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

323410
# Track successfully updated vs conflicted branches separately
324411
UPDATED_TARGETS=()

0 commit comments

Comments
 (0)