Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
bash tests/test_conflict_resolution_resume.sh
bash tests/test_conflict_absorbed_resolution.sh
bash tests/test_merge_commit_merge.sh
bash tests/test_rebase_merge_skip.sh
bash tests/test_rebase_merge.sh

e2e-tests:
name: E2E Tests
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ jobs:

### Notes

* 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.
* 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.
* If a merge hits a conflict, you'll need to resolve it manually; pushing the resolution automatically continues the stack update
* Very large stacks might hit GitHub rate limits
* 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.
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: 'Stacked PR Update Action'
description: 'Automatically fixes stacked PRs after a base PR is squash-merged'
description: 'Automatically fixes stacked PRs after a base PR is squash- or rebase-merged'
author: 'GitHub Actions'

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

set -ueo pipefail

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

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

FEATURE2_BEFORE=$(git rev-parse feature2)

OUT=$(env \
SQUASH_COMMIT="$MERGE_COMMIT" \
MERGED_BRANCH=feature1 \
PR_NUMBER=1 \
TARGET_BRANCH=main \
MOCK_REBASE_COPIES="$(git rev-parse "$MERGE_COMMIT~")" \
GH="$SCRIPT_DIR/mock_gh.sh" \
GIT="$SCRIPT_DIR/mock_git.sh" \
"$SCRIPT_DIR/../update-pr-stack.sh" 2>&1)
echo "$OUT"

if ! grep -q "rebase merges are not supported" <<<"$OUT"; then
echo "❌ Expected the rebase-merge skip message"
exit 1
fi
if ! grep -q "Mock: gh pr comment 2" <<<"$OUT"; then
echo "❌ The child PR must be told the stack was not updated"
if ! git merge-base --is-ancestor "$MERGE_COMMIT" feature2; then
echo "❌ feature2 must be re-parented onto the rebased tip"
log_cmd git log --graph --oneline --all
exit 1
fi
if grep -q "pr edit" <<<"$OUT"; then
echo "❌ No PR must be retargeted"
if ! grep -q "Mock: gh pr edit 2 --base main" <<<"$OUT"; then
echo "❌ The child PR must be retargeted to main"
exit 1
fi
if grep -q "push origin :feature1" <<<"$OUT"; then
echo "❌ The merged branch must be kept"
if ! grep -q "push origin :feature1" <<<"$OUT"; then
echo "❌ The merged branch must be deleted"
exit 1
fi
if [[ "$(git rev-parse feature2)" != "$FEATURE2_BEFORE" ]]; then
echo "❌ feature2's head must not be rewritten"

# The child's PR diff against its new base shows only its own change: the
# parent's changes arrive through the rebased copies on main, not the diff.
EXPECTED_DIFF=$(cat <<'EOF'
-line 14
+Feature 2 content
EOF
)
ACTUAL_DIFF=$(log_cmd git diff main...feature2 | grep '^[+-]' | grep -v '^[+-][+-][+-]')
if [[ "$ACTUAL_DIFF" == "$EXPECTED_DIFF" ]]; then
echo "✅ Triple-dot diff main...feature2 shows only feature2's unique change"
else
echo "❌ Triple-dot diff is wrong"
echo "Expected:"
echo "$EXPECTED_DIFF"
echo "Actual:"
echo "$ACTUAL_DIFF"
exit 1
fi

echo "✅ Rebase merge detected: children warned, stack left alone"
echo "✅ Rebase merge handled like a squash: child re-parented, retargeted, branch deleted"
73 changes: 7 additions & 66 deletions update-pr-stack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
# Updates PR stack after merging a PR
#
# Required environment variables (squash-merge mode):
# SQUASH_COMMIT - The hash of the squash commit that was merged
# SQUASH_COMMIT - The merged PR's merge_commit_sha: the squash commit, or the
# last copied commit of a rebase merge
# MERGED_BRANCH - The name of the branch that was merged and will be deleted
# TARGET_BRANCH - The name of the branch that the PR was merged into
# PR_NUMBER - The number of the PR that was merged
Expand Down Expand Up @@ -99,58 +100,6 @@ has_squash_commit() {
&& git merge-base --is-ancestor SQUASH_COMMIT "$BRANCH"
}

# Args: a commit sha. Echoes the numbers of the pull requests that introduced
# the commit to the repository, one per line.
commit_pull_numbers() {
gh api "repos/{owner}/{repo}/commits/$1/pulls" --jq '.[].number' \
|| { echo "❌ Could not list the pull requests that introduced commit $1" >&2; return 1; }
}

# Args: the merge commit sha, the merged PR's number. The association is
# computed asynchronously, some time after the merge. The merge commit always
# belongs to the merged PR, so once it shows up the index has caught up with
# this merge; until then, an empty answer for any commit of the merge means
# nothing. Exits if the association never appears.
wait_for_pull_association() {
local MERGE_SHA="$1" PR_NUMBER="$2"
local NUMBERS
for _ in $(seq 1 24); do
NUMBERS=$(commit_pull_numbers "$MERGE_SHA") || exit 1
if grep -qx "$PR_NUMBER" <<<"$NUMBERS"; then
return 0
fi
sleep "${ASSOCIATION_POLL_SECONDS:-5}"
done
echo "❌ GitHub never associated $MERGE_SHA with PR #$PR_NUMBER; cannot tell a squash from a rebase" >&2
exit 1
}

# Args: the merged PR's number. The event payload does not say which merge
# method was used (GitHub records it nowhere), but GitHub associates every
# trunk commit with the PR that introduced it. A squash introduces a single
# commit, so the commit below SQUASH_COMMIT belongs to an older PR or to
# none; a rebase introduces a copy of each PR commit, so with two or more
# commits the one below SQUASH_COMMIT still belongs to this PR. A
# single-commit PR merges identically under rebase and squash and correctly
# reads as a squash here.
is_rebase_merge() {
local PR_NUMBER="$1"
local MERGE_SHA PARENT_SHA NUMBERS
MERGE_SHA=$(git rev-parse SQUASH_COMMIT)
PARENT_SHA=$(git rev-parse SQUASH_COMMIT~)

NUMBERS=$(commit_pull_numbers "$PARENT_SHA") || exit 1
if [[ -z "$NUMBERS" ]]; then
# Ambiguous: "no PR introduced this commit" (a squash on top of a
# direct push) and "not indexed yet" (a rebase copy) both come back
# empty. Wait until the index has caught up with this merge, then ask
# again; this time empty really means no PR.
wait_for_pull_association "$MERGE_SHA" "$PR_NUMBER"
NUMBERS=$(commit_pull_numbers "$PARENT_SHA") || exit 1
fi
grep -qx "$PR_NUMBER" <<<"$NUMBERS"
}

# Echoes "<number> <head branch>" for each open PR based on the merged branch.
# try, not run: callers run this in a command substitution, where a die would
# only leave the subshell, so they capture the output and die themselves. An
Expand Down Expand Up @@ -425,19 +374,11 @@ main() {
return 0
fi

# Rebase merges are not supported: the copies on the target are new
# commits, so a child retargeted as-is would show its parent's changes in
# its diff, and the squash sequence can raise spurious conflicts against
# the intermediate copies. Tell the children and leave everything alone.
if is_rebase_merge "$PR_NUMBER"; then
echo "⚠️ '$MERGED_BRANCH' looks rebase-merged; rebase merges are not supported, leaving the stack alone"
CHILDREN=$(list_child_prs) || die "could not list the PRs based on $MERGED_BRANCH"
while read -r NUMBER BRANCH; do
[[ -n "$BRANCH" ]] || continue
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."
done <<<"$CHILDREN"
return 0
fi
# A squash merge and a rebase merge both land the branch's content as new
# commits without merging its history, and both take the path below.
# SQUASH_COMMIT (the PR's merge_commit_sha) is the squash commit or the
# last rebased copy; either way its tree carries the merged branch's full
# content, which is all the single-merge re-parent reads from it.

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