diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 44d3915..856d69c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,6 +17,8 @@ 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_merge.sh + bash tests/test_rebase_merge_skip.sh e2e-tests: name: E2E Tests diff --git a/README.md b/README.md index f2bac95..8607321 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ jobs: ### Notes -* Currently only supports squash merges +* 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 5a0b1ab..3114109 100755 --- a/tests/mock_gh.sh +++ b/tests/mock_gh.sh @@ -23,6 +23,19 @@ 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" +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 new file mode 100755 index 0000000..af5b57a --- /dev/null +++ b/tests/test_merge_commit_merge.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# +# 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 + +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 \ + PR_NUMBER=1 \ + 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 message" + exit 1 +fi +if [[ "$(git rev-parse feature2)" != "$FEATURE2_BEFORE" ]]; then + echo "❌ feature2's head must not be rewritten" + exit 1 +fi + +# 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 +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/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 new file mode 100755 index 0000000..7af6c50 --- /dev/null +++ b/tests/test_rebase_merge_skip.sh @@ -0,0 +1,91 @@ +#!/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. + +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 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 +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 \ + 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" + 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/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 eb7487b..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 @@ -79,6 +80,63 @@ 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 " " 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() { @@ -298,9 +356,38 @@ 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" + # 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; 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 < <(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 + 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" + 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 < <(list_child_prs) + return 0 + fi + # Find all PRs directly targeting the merged PR's head INITIAL_NUMBERS=() INITIAL_TARGETS=() @@ -308,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=()