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 9b6094e..6eb1dc3 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,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 (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_merge_commit_merge.sh b/tests/test_merge_commit_merge.sh new file mode 100755 index 0000000..261ba5a --- /dev/null +++ b/tests/test_merge_commit_merge.sh @@ -0,0 +1,93 @@ +#!/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 \ + 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_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 eb7487b..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() { @@ -301,6 +316,34 @@ main() { 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 < <(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 + + # 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=()