From 73a37f670818c9b5effa66d8d34ae5af7bc96e95 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:02:55 +0000 Subject: [PATCH 1/6] Skip PRs merged with a merge commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action triggers on any merged PR, but its merge sequence assumes the squash shape: SQUASH_COMMIT~ as "target just before the merge" and an -s ours record of the squash. A real merge commit satisfies neither - and needs no fixing at all, since history is not rewritten and stacked children keep correct diffs. Detect the second parent and bail out before touching anything. Rebase merges remain indistinguishable from squashes in the payload and are processed as before. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JHvKryT4QUpHYdNq9YEQxX --- .github/workflows/tests.yml | 1 + README.md | 2 +- tests/test_merge_commit_skip.sh | 69 +++++++++++++++++++++++++++++++++ update-pr-stack.sh | 6 +++ 4 files changed, 77 insertions(+), 1 deletion(-) create mode 100755 tests/test_merge_commit_skip.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 44d3915..8595e92 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,6 +17,7 @@ jobs: bash tests/test_rebase_workflow.sh bash tests/test_mixed_workflows.sh bash tests/test_conflict_resolution_resume.sh + bash tests/test_merge_commit_skip.sh e2e-tests: name: E2E Tests diff --git a/README.md b/README.md index 9b6094e..552689b 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ jobs: ### Notes -* Currently only supports squash merges +* Currently only supports squash merges; PRs merged with a merge commit are detected and skipped (history isn't rewritten, so stacked PRs stay valid as-is) * 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 diff --git a/tests/test_merge_commit_skip.sh b/tests/test_merge_commit_skip.sh new file mode 100755 index 0000000..dfc36f8 --- /dev/null +++ b/tests/test_merge_commit_skip.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# +# A PR merged with a merge commit (not squashed) must be left alone: history is +# not rewritten, so stacked children stay valid and need no synthetic merge. + +set -ueo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../command_utils.sh" + +simulate_push() { + log_cmd git update-ref "refs/remotes/origin/$1" "$1" +} + +TEST_REPO=$(mktemp -d) +cd "$TEST_REPO" +echo "Created test repo at $TEST_REPO" + +log_cmd git init -b main +log_cmd git config user.email "test@example.com" +log_cmd git config user.name "Test User" + +echo "line" > file.txt +log_cmd git add file.txt +log_cmd git commit -m "Initial commit" +simulate_push main + +log_cmd git checkout -b feature1 +echo "f1" >> file.txt +log_cmd git add file.txt +log_cmd git commit -m "Add feature 1" +simulate_push feature1 + +log_cmd git checkout -b feature2 +echo "f2" >> file.txt +log_cmd git add file.txt +log_cmd git commit -m "Add feature 2" +simulate_push feature2 + +# Merge feature1 into main with a real merge commit +log_cmd git checkout main +log_cmd git merge --no-ff --no-edit 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 \ + TARGET_BRANCH=main \ + 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 "merged with a merge commit" <<<"$OUT"; then + echo "❌ Expected the merge-commit skip message" + exit 1 +fi +if grep -q "pr edit" <<<"$OUT"; then + echo "❌ No PR must be retargeted" + exit 1 +fi +if [[ "$(git rev-parse feature2)" != "$FEATURE2_BEFORE" ]]; then + echo "❌ feature2 must not be modified" + exit 1 +fi +echo "✅ Merge-commit merge skipped, stack untouched" diff --git a/update-pr-stack.sh b/update-pr-stack.sh index eb7487b..da3cbc4 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -301,6 +301,12 @@ main() { log_cmd git update-ref SQUASH_COMMIT "$SQUASH_COMMIT" + # A merge-commit merge does not rewrite history; stacked PRs stay valid. + if git rev-parse --verify --quiet SQUASH_COMMIT^2 >/dev/null; then + echo "✓ '$MERGED_BRANCH' was merged with a merge commit, not squashed; nothing to do" + return 0 + fi + # Find all PRs directly targeting the merged PR's head INITIAL_NUMBERS=() INITIAL_TARGETS=() From a28f98069d9a03c17d8191269b0d64b88f75705f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Wed, 10 Jun 2026 11:25:12 +0200 Subject: [PATCH 2/6] Retarget children instead of skipping on a merge-commit merge A full skip left child PRs based on the deleted-but-not-deleted merged branch forever: nothing retargeted them and nothing deleted the branch, the two things the action exists to automate. The children's heads need no rewriting (the merge commit carries the parent's commits into the target branch), so retarget them as-is and delete the branch. Co-Authored-By: Claude Fable 5 --- .github/workflows/tests.yml | 2 +- README.md | 2 +- ...mit_skip.sh => test_merge_commit_merge.sh} | 40 +++++++++++++++---- update-pr-stack.sh | 13 +++++- 4 files changed, 45 insertions(+), 12 deletions(-) rename tests/{test_merge_commit_skip.sh => test_merge_commit_merge.sh} (51%) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8595e92..403a729 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,7 +17,7 @@ jobs: bash tests/test_rebase_workflow.sh bash tests/test_mixed_workflows.sh bash tests/test_conflict_resolution_resume.sh - bash tests/test_merge_commit_skip.sh + bash tests/test_merge_commit_merge.sh e2e-tests: name: E2E Tests diff --git a/README.md b/README.md index 552689b..0c518a5 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ jobs: ### Notes -* Currently only supports squash merges; PRs merged with a merge commit are detected and skipped (history isn't rewritten, so stacked PRs stay valid as-is) +* 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 look like squash merges in the event payload and take the squash path; the result is correct, but a multi-commit rebase can require a manual conflict resolution that a squash would not have. * 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 diff --git a/tests/test_merge_commit_skip.sh b/tests/test_merge_commit_merge.sh similarity index 51% rename from tests/test_merge_commit_skip.sh rename to tests/test_merge_commit_merge.sh index dfc36f8..261ba5a 100755 --- a/tests/test_merge_commit_skip.sh +++ b/tests/test_merge_commit_merge.sh @@ -1,7 +1,9 @@ #!/bin/bash # -# A PR merged with a merge commit (not squashed) must be left alone: history is -# not rewritten, so stacked children stay valid and need no synthetic merge. +# A PR merged with a merge commit (not squashed) keeps its history, so stacked +# children already contain the parent's commits and their heads must not be +# rewritten. The action only retargets the children and deletes the merged +# branch. set -ueo pipefail @@ -55,15 +57,37 @@ OUT=$(env \ echo "$OUT" if ! grep -q "merged with a merge commit" <<<"$OUT"; then - echo "❌ Expected the merge-commit skip message" + echo "❌ Expected the merge-commit message" exit 1 fi -if grep -q "pr edit" <<<"$OUT"; then - echo "❌ No PR must be retargeted" +if [[ "$(git rev-parse feature2)" != "$FEATURE2_BEFORE" ]]; then + echo "❌ feature2's head must not be rewritten" exit 1 fi -if [[ "$(git rev-parse feature2)" != "$FEATURE2_BEFORE" ]]; then - echo "❌ feature2 must not be modified" + +# Children must be retargeted before the merged branch is deleted (deleting a +# PR's base branch closes the PR). +EDIT_LINE=$(grep -n "pr edit 2 --base main" <<<"$OUT" | cut -d: -f1 | head -1 || true) +DELETE_LINE=$(grep -n "push origin :feature1" <<<"$OUT" | cut -d: -f1 | head -1 || true) +if [[ -z "$EDIT_LINE" ]]; then + echo "❌ Child PR must be retargeted onto main" exit 1 fi -echo "✅ Merge-commit merge skipped, stack untouched" +if [[ -z "$DELETE_LINE" ]]; then + echo "❌ Merged branch must be deleted" + exit 1 +fi +if [[ "$EDIT_LINE" -gt "$DELETE_LINE" ]]; then + echo "❌ Retarget must happen before the branch deletion (edit=$EDIT_LINE delete=$DELETE_LINE)" + exit 1 +fi + +# The untouched head keeps a clean diff against the new base +ACTUAL_DIFF=$(git diff main...feature2 | grep '^[+-]' | grep -v '^[+-][+-][+-]') +if [[ "$ACTUAL_DIFF" != "+f2" ]]; then + echo "❌ Diff main...feature2 should show only feature2's change, got:" + echo "$ACTUAL_DIFF" + exit 1 +fi + +echo "✅ Merge-commit merge: children retargeted, heads untouched, branch deleted" diff --git a/update-pr-stack.sh b/update-pr-stack.sh index da3cbc4..6649197 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -301,9 +301,18 @@ main() { log_cmd git update-ref SQUASH_COMMIT "$SQUASH_COMMIT" - # A merge-commit merge does not rewrite history; stacked PRs stay valid. + # A merge-commit merge does not rewrite history: each child's head already + # contains the merged branch's commits, and the merge commit carries them + # into TARGET_BRANCH. The heads need no synthetic merge; just retarget the + # children and delete the merged branch. if git rev-parse --verify --quiet SQUASH_COMMIT^2 >/dev/null; then - echo "✓ '$MERGED_BRANCH' was merged with a merge commit, not squashed; nothing to do" + echo "✓ '$MERGED_BRANCH' was merged with a merge commit, not squashed; retargeting children without touching their heads" + while read -r NUMBER BRANCH; do + [[ -n "$BRANCH" ]] || continue + log_cmd gh pr edit "$NUMBER" --base "$TARGET_BRANCH" + done < <(log_cmd gh pr list --base "$MERGED_BRANCH" --json number,headRefName --jq '.[] | "\(.number) \(.headRefName)"') + # Deleting a PR's base branch closes the PR, so the retargets come first. + log_cmd git push origin ":$MERGED_BRANCH" return 0 fi From f918548b260e5316e6cd8101219fd9789aa77478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Wed, 10 Jun 2026 11:45:58 +0200 Subject: [PATCH 3/6] Detect rebase merges and skip them with a comment on each child GitHub records the merge method nowhere (payload, REST, GraphQL), so detect it: a rebase leaves a patch-equivalent copy of every PR commit on the target, a multi-commit squash does not. Single-commit PRs merge identically either way and keep taking the squash path. Co-Authored-By: Claude Fable 5 --- .github/workflows/tests.yml | 1 + README.md | 2 +- tests/mock_gh.sh | 3 ++ tests/test_rebase_merge_skip.sh | 89 +++++++++++++++++++++++++++++++++ update-pr-stack.sh | 28 +++++++++++ 5 files changed, 122 insertions(+), 1 deletion(-) create mode 100755 tests/test_rebase_merge_skip.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 403a729..856d69c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,6 +18,7 @@ jobs: bash tests/test_mixed_workflows.sh bash tests/test_conflict_resolution_resume.sh bash tests/test_merge_commit_merge.sh + bash tests/test_rebase_merge_skip.sh e2e-tests: name: E2E Tests diff --git a/README.md b/README.md index 0c518a5..6eb1dc3 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,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 look like squash merges in the event payload and take the squash path; the result is correct, but a multi-commit rebase can require a manual conflict resolution that a squash would not have. +* 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 (heuristically, since GitHub does not record the merge method anywhere) and comments on each child PR instead of acting. * 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 diff --git a/tests/mock_gh.sh b/tests/mock_gh.sh index 5a0b1ab..4e74a02 100755 --- a/tests/mock_gh.sh +++ b/tests/mock_gh.sh @@ -23,6 +23,9 @@ if [[ "$1" == "pr" && "$2" == "list" ]]; then elif [[ "$1" == "pr" && "$2" == "edit" ]]; then # Just log the edit command echo "Mock: gh pr edit $3 --base $5" +elif [[ "$1" == "pr" && "$2" == "comment" ]]; then + # Just log the comment command + echo "Mock: gh pr comment $3" else echo "Unknown gh command: $@" >&2 exit 1 diff --git a/tests/test_rebase_merge_skip.sh b/tests/test_rebase_merge_skip.sh new file mode 100755 index 0000000..44969dd --- /dev/null +++ b/tests/test_rebase_merge_skip.sh @@ -0,0 +1,89 @@ +#!/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 (every PR +# commit has a patch-equivalent copy on the target), comment on the children, +# and leave the stack alone. + +set -ueo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../command_utils.sh" + +simulate_push() { + log_cmd git update-ref "refs/remotes/origin/$1" "$1" +} + +TEST_REPO=$(mktemp -d) +cd "$TEST_REPO" +echo "Created test repo at $TEST_REPO" + +log_cmd git init -b main +log_cmd git config user.email "test@example.com" +log_cmd git config user.name "Test User" + +for i in $(seq 1 15); do echo "line $i" >> file.txt; done +log_cmd git add file.txt +log_cmd git commit -m "Initial commit" +simulate_push main + +# feature1: two commits, so the rebase shape differs from a squash +log_cmd git checkout -b feature1 +sed -i '2s/.*/Feature 1 change A/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "feature1: commit A" +sed -i '6s/.*/Feature 1 change B/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "feature1: commit B" +simulate_push feature1 + +log_cmd git checkout -b feature2 +sed -i '14s/.*/Feature 2 content/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "feature2: change" +simulate_push feature2 + +# main advances independently, then feature1 is rebase-merged: GitHub copies +# each PR commit onto the target, which cherry-pick reproduces +log_cmd git checkout main +sed -i '10s/.*/Main hotfix/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "main: hotfix" +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 \ + TARGET_BRANCH=main \ + 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" + exit 1 +fi +if grep -q "pr edit" <<<"$OUT"; then + echo "❌ No PR must be retargeted" + exit 1 +fi +if grep -q "push origin :feature1" <<<"$OUT"; then + echo "❌ The merged branch must be kept" + exit 1 +fi +if [[ "$(git rev-parse feature2)" != "$FEATURE2_BEFORE" ]]; then + echo "❌ feature2's head must not be rewritten" + exit 1 +fi + +echo "✅ Rebase merge detected: children warned, stack left alone" diff --git a/update-pr-stack.sh b/update-pr-stack.sh index 6649197..25b3a06 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -79,6 +79,21 @@ has_squash_commit() { && git merge-base --is-ancestor SQUASH_COMMIT "$BRANCH" } +# Heuristic: the event payload does not say which merge method was used +# (GitHub exposes it nowhere). A rebase merge copies every commit of the PR +# onto the target branch, so each original has a patch-equivalent there and +# git cherry marks it "-". A squash of two or more commits leaves only their +# combined patch on the target, so the originals show as "+". A single-commit +# PR merges identically under rebase and squash, so it takes the squash path. +# A rebase whose copies drifted (context changes) reads as a squash, so the +# heuristic errs toward processing, never toward skipping a real squash. +is_rebase_merge() { + local COMMIT_COUNT + COMMIT_COUNT=$(git rev-list --count --no-merges "origin/$TARGET_BRANCH..origin/$MERGED_BRANCH") + [[ "$COMMIT_COUNT" -ge 2 ]] || return 1 + ! git cherry "origin/$TARGET_BRANCH" "origin/$MERGED_BRANCH" | grep -q '^+' +} + # Args: head branch, base branch, PR number. git commands use the branch; gh # commands use the number, since a head branch can carry several PRs. update_direct_target() { @@ -316,6 +331,19 @@ 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; then + echo "âš ī¸ '$MERGED_BRANCH' looks rebase-merged; rebase merges are not supported, leaving the stack alone" + while read -r NUMBER BRANCH; do + [[ -n "$BRANCH" ]] || continue + 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." + done < <(log_cmd gh pr list --base "$MERGED_BRANCH" --json number,headRefName --jq '.[] | "\(.number) \(.headRefName)"') + return 0 + fi + # Find all PRs directly targeting the merged PR's head INITIAL_NUMBERS=() INITIAL_TARGETS=() From ce57ddd53b7e589bf05cb23435a09ec9723b24bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Wed, 10 Jun 2026 13:06:32 +0200 Subject: [PATCH 4/6] Detect rebase merges via GitHub's commit-PR association Replaces the patch-id heuristic: ask which PR introduced the commit just below the merge sha. An older PR or none means squash; this PR means a rebase copy. The association is computed asynchronously, so an empty answer is only trusted once the merge sha itself is associated. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- tests/mock_gh.sh | 10 ++++++ tests/test_merge_commit_merge.sh | 1 + tests/test_mixed_workflows.sh | 1 + tests/test_rebase_merge_skip.sh | 10 +++--- tests/test_rebase_workflow.sh | 1 + tests/test_update_pr_stack.sh | 1 + update-pr-stack.sh | 56 ++++++++++++++++++++++++-------- 8 files changed, 64 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 6eb1dc3..aebb1a9 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,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 (heuristically, since GitHub does not record the merge method anywhere) and comments on each child PR instead of acting. +* 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. * 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 diff --git a/tests/mock_gh.sh b/tests/mock_gh.sh index 4e74a02..3114109 100755 --- a/tests/mock_gh.sh +++ b/tests/mock_gh.sh @@ -26,6 +26,16 @@ elif [[ "$1" == "pr" && "$2" == "edit" ]]; then elif [[ "$1" == "pr" && "$2" == "comment" ]]; then # 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 diff --git a/tests/test_merge_commit_merge.sh b/tests/test_merge_commit_merge.sh index 261ba5a..af5b57a 100755 --- a/tests/test_merge_commit_merge.sh +++ b/tests/test_merge_commit_merge.sh @@ -50,6 +50,7 @@ FEATURE2_BEFORE=$(git rev-parse feature2) OUT=$(env \ SQUASH_COMMIT="$MERGE_COMMIT" \ MERGED_BRANCH=feature1 \ + PR_NUMBER=1 \ TARGET_BRANCH=main \ GH="$SCRIPT_DIR/mock_gh.sh" \ GIT="$SCRIPT_DIR/mock_git.sh" \ diff --git a/tests/test_mixed_workflows.sh b/tests/test_mixed_workflows.sh index f121a2a..4fb8452 100755 --- a/tests/test_mixed_workflows.sh +++ b/tests/test_mixed_workflows.sh @@ -90,6 +90,7 @@ run_update_pr_stack() { env \ SQUASH_COMMIT=$SQUASH_COMMIT \ MERGED_BRANCH=feature1 \ + PR_NUMBER=1 \ TARGET_BRANCH=main \ GH="$SCRIPT_DIR/mock_gh.sh" \ GIT="$SCRIPT_DIR/mock_git.sh" \ diff --git a/tests/test_rebase_merge_skip.sh b/tests/test_rebase_merge_skip.sh index 44969dd..7af6c50 100755 --- a/tests/test_rebase_merge_skip.sh +++ b/tests/test_rebase_merge_skip.sh @@ -2,9 +2,9 @@ # # 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 (every PR -# commit has a patch-equivalent copy on the target), comment on the children, -# and leave the stack alone. +# 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. set -ueo pipefail @@ -28,7 +28,7 @@ log_cmd git add file.txt log_cmd git commit -m "Initial commit" simulate_push main -# feature1: two commits, so the rebase shape differs from a squash +# feature1: two commits, so the rebase leaves a 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 @@ -59,7 +59,9 @@ 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) diff --git a/tests/test_rebase_workflow.sh b/tests/test_rebase_workflow.sh index 9cbaa77..4b72418 100755 --- a/tests/test_rebase_workflow.sh +++ b/tests/test_rebase_workflow.sh @@ -85,6 +85,7 @@ run_update_pr_stack() { env \ SQUASH_COMMIT=$SQUASH_COMMIT \ MERGED_BRANCH=feature1 \ + PR_NUMBER=1 \ TARGET_BRANCH=main \ GH="$SCRIPT_DIR/mock_gh.sh" \ GIT="$SCRIPT_DIR/mock_git.sh" \ diff --git a/tests/test_update_pr_stack.sh b/tests/test_update_pr_stack.sh index 6c18b28..9127597 100755 --- a/tests/test_update_pr_stack.sh +++ b/tests/test_update_pr_stack.sh @@ -78,6 +78,7 @@ run_update_pr_stack() { env \ SQUASH_COMMIT=$SQUASH_COMMIT \ MERGED_BRANCH=feature1 \ + PR_NUMBER=1 \ TARGET_BRANCH=main \ GH="$SCRIPT_DIR/mock_gh.sh" \ GIT="$SCRIPT_DIR/mock_git.sh" \ diff --git a/update-pr-stack.sh b/update-pr-stack.sh index 25b3a06..f267839 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -79,19 +79,48 @@ has_squash_commit() { && git merge-base --is-ancestor SQUASH_COMMIT "$BRANCH" } -# Heuristic: the event payload does not say which merge method was used -# (GitHub exposes it nowhere). A rebase merge copies every commit of the PR -# onto the target branch, so each original has a patch-equivalent there and -# git cherry marks it "-". A squash of two or more commits leaves only their -# combined patch on the target, so the originals show as "+". A single-commit -# PR merges identically under rebase and squash, so it takes the squash path. -# A rebase whose copies drifted (context changes) reads as a squash, so the -# heuristic errs toward processing, never toward skipping a real squash. +# 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 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 COMMIT_COUNT - COMMIT_COUNT=$(git rev-list --count --no-merges "origin/$TARGET_BRANCH..origin/$MERGED_BRANCH") - [[ "$COMMIT_COUNT" -ge 2 ]] || return 1 - ! git cherry "origin/$TARGET_BRANCH" "origin/$MERGED_BRANCH" | grep -q '^+' + 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 [[ -n "$NUMBERS" ]]; then + grep -qx "$PR_NUMBER" <<<"$NUMBERS" + return + fi + # The association is computed asynchronously, so right after the merge an + # empty answer is ambiguous: "no PR introduced this commit" (a squash on + # top of a direct push) or "not indexed yet" (a rebase copy). SQUASH_COMMIT + # itself always gets associated with this PR, and the commits of one merge + # are indexed together: once it shows up, an empty answer for the parent + # can be trusted. + for _ in $(seq 1 24); do + NUMBERS=$(commit_pull_numbers "$MERGE_SHA") || exit 1 + if grep -qx "$PR_NUMBER" <<<"$NUMBERS"; then + NUMBERS=$(commit_pull_numbers "$PARENT_SHA") || exit 1 + grep -qx "$PR_NUMBER" <<<"$NUMBERS" + return + 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: head branch, base branch, PR number. git commands use the branch; gh @@ -313,6 +342,7 @@ main() { check_env_var "SQUASH_COMMIT" check_env_var "MERGED_BRANCH" check_env_var "TARGET_BRANCH" + check_env_var "PR_NUMBER" log_cmd git update-ref SQUASH_COMMIT "$SQUASH_COMMIT" @@ -335,7 +365,7 @@ main() { # 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; then + if is_rebase_merge "$PR_NUMBER"; then echo "âš ī¸ '$MERGED_BRANCH' looks rebase-merged; rebase merges are not supported, leaving the stack alone" while read -r NUMBER BRANCH; do [[ -n "$BRANCH" ]] || continue From 63ccc55bf24accf28fd1b0d6ea594b0f9ab51290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Wed, 10 Jun 2026 13:44:13 +0200 Subject: [PATCH 5/6] Split the association wait out of is_rebase_merge Co-Authored-By: Claude Fable 5 --- update-pr-stack.sh | 46 +++++++++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/update-pr-stack.sh b/update-pr-stack.sh index f267839..b7ce705 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -94,28 +94,18 @@ commit_pull_numbers() { # 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 [[ -n "$NUMBERS" ]]; then - grep -qx "$PR_NUMBER" <<<"$NUMBERS" - return - fi - # The association is computed asynchronously, so right after the merge an - # empty answer is ambiguous: "no PR introduced this commit" (a squash on - # top of a direct push) or "not indexed yet" (a rebase copy). SQUASH_COMMIT - # itself always gets associated with this PR, and the commits of one merge - # are indexed together: once it shows up, an empty answer for the parent - # can be trusted. +# 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 - NUMBERS=$(commit_pull_numbers "$PARENT_SHA") || exit 1 - grep -qx "$PR_NUMBER" <<<"$NUMBERS" - return + return 0 fi sleep "${ASSOCIATION_POLL_SECONDS:-5}" done @@ -123,6 +113,24 @@ is_rebase_merge() { exit 1 } +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" +} + # Args: head branch, base branch, PR number. git commands use the branch; gh # commands use the number, since a head branch can carry several PRs. update_direct_target() { From 41aa30e77d4403c2f2d1b596eeb8accf699559a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Wed, 10 Jun 2026 16:18:29 +0200 Subject: [PATCH 6/6] Clean up doc comments and factor the child-PR listing Co-Authored-By: Claude Fable 5 --- update-pr-stack.sh | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/update-pr-stack.sh b/update-pr-stack.sh index b7ce705..9a3eb2c 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -6,6 +6,7 @@ # SQUASH_COMMIT - The hash of the squash commit that was merged # 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 # # Required environment variables (conflict-resolved mode): # PR_BRANCH - The head branch of the PR being resumed @@ -86,14 +87,6 @@ commit_pull_numbers() { || { echo "❌ Could not list the pull requests that introduced commit $1" >&2; return 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. # 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 @@ -113,6 +106,14 @@ wait_for_pull_association() { 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 @@ -131,6 +132,11 @@ is_rebase_merge() { grep -qx "$PR_NUMBER" <<<"$NUMBERS" } +# Echoes " " for each open PR based on the merged branch. +list_child_prs() { + log_cmd gh pr list --base "$MERGED_BRANCH" --json number,headRefName --jq '.[] | "\(.number) \(.headRefName)"' +} + # Args: head branch, base branch, PR number. git commands use the branch; gh # commands use the number, since a head branch can carry several PRs. update_direct_target() { @@ -363,7 +369,7 @@ main() { while read -r NUMBER BRANCH; do [[ -n "$BRANCH" ]] || continue log_cmd gh pr edit "$NUMBER" --base "$TARGET_BRANCH" - done < <(log_cmd gh pr list --base "$MERGED_BRANCH" --json number,headRefName --jq '.[] | "\(.number) \(.headRefName)"') + done < <(list_child_prs) # Deleting a PR's base branch closes the PR, so the retargets come first. log_cmd git push origin ":$MERGED_BRANCH" return 0 @@ -378,7 +384,7 @@ main() { while read -r NUMBER BRANCH; do [[ -n "$BRANCH" ]] || continue 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." - done < <(log_cmd gh pr list --base "$MERGED_BRANCH" --json number,headRefName --jq '.[] | "\(.number) \(.headRefName)"') + done < <(list_child_prs) return 0 fi @@ -389,7 +395,7 @@ main() { [[ -n "$BRANCH" ]] || continue INITIAL_NUMBERS+=("$NUMBER") INITIAL_TARGETS+=("$BRANCH") - done < <(log_cmd gh pr list --base "$MERGED_BRANCH" --json number,headRefName --jq '.[] | "\(.number) \(.headRefName)"') + done < <(list_child_prs) # Track successfully updated vs conflicted branches separately UPDATED_TARGETS=()