diff --git a/README.md b/README.md index 9e91fc4..72eb228 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,12 @@ This action tries to fix that in a transparent way. Install it, and hopefully th ### How it works 1. Triggers when a PR is squash merged -2. Finds PRs that were based on the merged branch -3. For direct children: creates a synthetic merge commit with three parents (child tip, deleted branch tip, squash commit) to preserve history without re-introducing code -4. For indirect descendants: merges the updated parent branch -5. Updates the direct child PRs to base on trunk now that the bottom change has landed; higher PRs stay based on their parent -6. Force-pushes updated branches and deletes the merged branch +2. Finds PRs that were based on the merged branch (direct children only) +3. Creates a synthetic merge commit with three parents (child tip, deleted branch tip, squash commit) to preserve history without re-introducing code +4. Updates the direct child PRs to base on trunk now that the bottom change has landed +5. Force-pushes updated branches and deletes the merged branch + +**Note:** Indirect descendants (grandchildren, etc.) are intentionally not modified. Their PR diffs remain correct because the merge-base calculation still works—the synthetic merge commit includes the original parent commit as an ancestor. When their direct parent is eventually merged, they become direct children and get updated at that point. ### Conflict handling @@ -38,7 +39,6 @@ After you manually resolve the conflict and push: 2. The action detects the conflict label and removes it 3. Updates the PR's base branch to trunk 4. Deletes the old base branch (if no other conflicted PRs still depend on it) -5. Continues updating any dependent PRs in the stack --- diff --git a/tests/mock_gh.sh b/tests/mock_gh.sh index 636ba4a..74bbfd3 100755 --- a/tests/mock_gh.sh +++ b/tests/mock_gh.sh @@ -1,31 +1,24 @@ #!/bin/bash +# Mock gh CLI for unit tests. +# Only direct children are queried now (no recursive updates of indirect children). + if [[ "$1" == "pr" && "$2" == "list" ]]; then # Parse the --base argument to determine which PRs to return base="" - jq_flag="" for ((i=1; i<=$#; i++)); do if [[ "${!i}" == "--base" ]]; then next=$((i+1)) base="${!next}" fi - if [[ "${!i}" == "--jq" ]]; then - next=$((i+1)) - jq_flag="${!next}" - fi done - if [[ "$base" == "main" ]]; then - : # No PRs target main in our test - elif [[ "$base" == "feature1" ]]; then + if [[ "$base" == "feature1" ]]; then + # feature2 is a direct child of feature1 echo 'feature2' - elif [[ "$base" == "feature2" ]]; then - echo feature3 - elif [[ "$base" == "feature3" ]]; then - : else - echo "Unknown base branch: $@" >&2 - exit 1 + # No other bases have direct children in our test scenario + : fi elif [[ "$1" == "pr" && "$2" == "edit" ]]; then # Just log the edit command diff --git a/tests/test_e2e.sh b/tests/test_e2e.sh index 56ae9ba..9be874d 100755 --- a/tests/test_e2e.sh +++ b/tests/test_e2e.sh @@ -47,14 +47,15 @@ # - The action should detect that PR2 (feature2) was based on feature1 # - Update PR2's base branch from feature1 to main # - Merge main into feature2 to incorporate the squash commit -# - Propagate the merge to feature3 and feature4 as well # - Delete the merged branch (feature1) +# - NOTE: Indirect children (feature3, feature4) are NOT updated - their diffs +# remain correct because the merge-base calculation works correctly # # Verifications: # - feature1 branch is deleted from remote # - PR2 base branch is updated from feature1 to main -# - PR3 base branch remains feature2 (only direct children are updated) -# - feature2, feature3, and feature4 branches contain the squash merge commit +# - PR3 base branch remains feature2 (only direct children's base is updated) +# - feature2 contains the squash merge commit (feature3/feature4 do NOT) # - PR diffs are IDENTICAL before and after (action preserves incremental diffs) # # SCENARIO 2: Conflict Handling (Steps 8-13) @@ -94,13 +95,7 @@ # - The action detects the conflict label and removes it # - Updates PR3's base branch to main # - Deletes feature2 branch (no other conflicted PRs depend on it) -# - The continuation workflow updates feature4 (grandchild) recursively -# - Verify the label is removed, base updated, branch deleted, and feature4 is updated -# -# Grandchild Update (feature4): -# - Tests that update_branch_recursive properly handles grandchildren -# - Even when SQUASH_COMMIT is undefined (in conflict-resolved mode) -# - The skip_if_clean guard must handle the missing SQUASH_COMMIT ref +# - NOTE: feature4 is NOT updated (indirect children are not modified) # # ============================================================================= set -e # Exit immediately if a command exits with a non-zero status. @@ -654,14 +649,14 @@ PR3_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature2 --head f PR3_NUM=$(echo "$PR3_URL" | awk -F'/' '{print $NF}') echo >&2 "Created PR #$PR3_NUM: $PR3_URL" -# Branch feature4 (base: feature3) - tests grandchildren in conflict resolution +# Branch feature4 (base: feature3) - tests that indirect children's diffs remain correct log_cmd git checkout -b feature4 feature3 sed -i '2s/.*/Feature 4 content line 2/' file.txt # Edit line 2 (shared) sed -i '5s/.*/Feature 4 content line 5/' file.txt # Edit line 5 (unique) log_cmd git add file.txt log_cmd git commit -m "Add feature 4" log_cmd git push origin feature4 -PR4_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature3 --head feature4 --title "Feature 4" --body "This is PR 4, based on PR 3 (grandchild for conflict resolution test)") +PR4_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature3 --head feature4 --title "Feature 4" --body "This is PR 4, based on PR 3 (indirect child, tests diff preservation)") PR4_NUM=$(echo "$PR4_URL" | awk -F'/' '{print $NF}') echo >&2 "Created PR #$PR4_NUM: $PR4_URL" @@ -718,16 +713,10 @@ else echo >&2 "❌ Verification Failed: PR #$PR3_NUM base branch is '$PR3_BASE', expected 'feature2'." exit 1 fi -# Verify local branches are updated to include the squash commit -echo >&2 "Checking if branches incorporate the squash commit..." -log_cmd git checkout feature2 # Checkout local branch first -log_cmd git pull origin feature2 # Pull updates pushed by the action -log_cmd git checkout feature3 -log_cmd git pull origin feature3 -log_cmd git checkout feature4 -log_cmd git pull origin feature4 - -# Check ancestry +# Verify feature2 (direct child) is updated to include the squash commit +echo >&2 "Checking if feature2 incorporates the squash commit..." +log_cmd git checkout feature2 +log_cmd git pull origin feature2 if log_cmd git merge-base --is-ancestor "$MERGE_COMMIT_SHA1" feature2; then echo >&2 "✅ Verification Passed: feature2 correctly incorporates the squash commit $MERGE_COMMIT_SHA1." else @@ -735,20 +724,9 @@ else log_cmd git log --graph --oneline feature2 main exit 1 fi -if log_cmd git merge-base --is-ancestor "$MERGE_COMMIT_SHA1" feature3; then - echo >&2 "✅ Verification Passed: feature3 correctly incorporates the squash commit $MERGE_COMMIT_SHA1." -else - echo >&2 "❌ Verification Failed: feature3 does not include the squash commit $MERGE_COMMIT_SHA1." - log_cmd git log --graph --oneline feature3 main - exit 1 -fi -if log_cmd git merge-base --is-ancestor "$MERGE_COMMIT_SHA1" feature4; then - echo >&2 "✅ Verification Passed: feature4 correctly incorporates the squash commit $MERGE_COMMIT_SHA1." -else - echo >&2 "❌ Verification Failed: feature4 does not include the squash commit $MERGE_COMMIT_SHA1." - log_cmd git log --graph --oneline feature4 main - exit 1 -fi +# Note: feature3 and feature4 are NOT updated (indirect children are not modified). +# Their diffs remain correct because the merge-base calculation still works. +echo >&2 "✅ feature3 and feature4 intentionally not updated (indirect children)" # Verify diffs are preserved (identical to initial) echo >&2 "Verifying diffs are preserved after action..." PR2_DIFF_AFTER=$(get_pr_diff "$PR2_URL") @@ -950,8 +928,6 @@ echo >&2 "15. Verifying conflict resolution content..." log_cmd git fetch origin log_cmd git checkout feature3 log_cmd git pull origin feature3 -log_cmd git checkout feature4 -log_cmd git pull origin feature4 # Verify feature3 now incorporates main (including PR2 merge commit and main's conflict commit) if log_cmd git merge-base --is-ancestor origin/main feature3; then @@ -962,15 +938,9 @@ else exit 1 fi -# Verify feature4 (grandchild) was updated by continuation workflow -# This tests that update_branch_recursive properly handles grandchildren even when SQUASH_COMMIT is undefined -if log_cmd git merge-base --is-ancestor origin/feature3 feature4; then - echo >&2 "✅ Verification Passed: feature4 (grandchild) correctly incorporates resolved feature3." -else - echo >&2 "❌ Verification Failed: feature4 does not include the resolved feature3." - log_cmd git log --graph --oneline feature4 feature3 - exit 1 -fi +# Note: feature4 is NOT updated (indirect children are not modified). +# It will be updated when feature3 is merged (becoming a direct child at that point). +echo >&2 "✅ feature4 intentionally not updated (indirect child of resolved PR)" # Verify the final content of file.txt on feature3 # Line 1: Original base diff --git a/tests/test_update_pr_stack.sh b/tests/test_update_pr_stack.sh index ab50bb2..4317f5e 100755 --- a/tests/test_update_pr_stack.sh +++ b/tests/test_update_pr_stack.sh @@ -16,12 +16,6 @@ simulate_push() { log_cmd git update-ref "refs/remotes/origin/$branch_name" "$branch_name" } -# Helper function to simulate 'git push origin :' -simulate_delete_remote_branch() { - local branch_name="$1" - log_cmd git update-ref -d "refs/remotes/origin/$branch_name" -} - # Create a temporary directory for the test repository TEST_REPO=$(mktemp -d) cd "$TEST_REPO" @@ -101,22 +95,21 @@ else exit 1 fi -# Test if the squash commit is incorporated into feature3 -if log_cmd git merge-base --is-ancestor "$SQUASH_COMMIT" feature3; then - echo "✅ feature3 includes the squash commit" +# Verify feature3 is NOT modified (indirect children are not updated) +# We stored the original SHA before running the script, now verify it hasn't changed +FEATURE3_AFTER=$(log_cmd git rev-parse feature3) +FEATURE3_BEFORE=$(log_cmd git rev-parse origin/feature3) +if [[ "$FEATURE3_AFTER" == "$FEATURE3_BEFORE" ]]; then + echo "✅ feature3 remains unchanged (indirect children not updated)" else - echo "❌ feature3 does not include the squash commit" - log_cmd git log --graph --oneline --all + echo "❌ feature3 was modified unexpectedly" exit 1 fi -# Show the contents of feature2 and feature3 to verify they contain all changes +# Show the contents of feature2 to verify it contains the expected changes echo -e "\nContent of feature2 branch:" log_cmd git show feature2:file.txt -echo -e "\nContent of feature3 branch:" -log_cmd git show feature3:file.txt - # Test triple dot diff on feature2 # After rebase, the diff should only contain the changes unique to feature2 # In this conflict scenario, feature2's change should overwrite feature1's change @@ -150,22 +143,24 @@ else fi -# Test triple dot diff on feature3 -# After rebase, the diff should only contain the changes unique to feature3 relative to main +# Test triple dot diff on feature3 relative to feature2 (simulates PR diff) +# Even though feature3 was NOT updated, its diff vs feature2 should remain correct +# because the merge-base calculation still works (feature2's synthetic merge has +# the original feature2 commit as a parent via BEFORE_MERGE) EXPECTED_DIFF3=$(cat </dev/null || true - log_cmd gh pr edit "$CHILD_BRANCH" --add-label "$CONFLICT_LABEL" - { - echo "### ⚠️ Automatic update blocked by merge conflicts" - echo - echo "I tried to merge \`origin/$PR_BRANCH\` into this branch while continuing the PR stack update and hit conflicts." - echo - echo "#### How to resolve" - echo '```bash' - echo "git fetch origin" - echo "git switch $CHILD_BRANCH" - echo "git merge origin/$PR_BRANCH" - echo "# ..." - echo "# fix conflicts, for instance with \`git mergetool\`" - echo "# ..." - echo "git commit" - echo "git push" - echo '```' - } | log_cmd gh pr comment "$CHILD_BRANCH" -F - - continue - fi - ALL_CHILDREN+=("$CHILD_BRANCH") - update_branch_recursive "$CHILD_BRANCH" - done - - # Push all updated branches - if [[ "${#ALL_CHILDREN[@]}" -gt 0 ]]; then - log_cmd git push origin "${ALL_CHILDREN[@]}" - fi } main() { @@ -292,10 +211,8 @@ main() { for BRANCH in "${INITIAL_TARGETS[@]}"; do if update_direct_target "$BRANCH" "$TARGET_BRANCH"; then UPDATED_TARGETS+=("$BRANCH") - update_branch_recursive "$BRANCH" else CONFLICTED_TARGETS+=("$BRANCH") - echo "⚠️ Skipping descendants of $BRANCH until conflicts are resolved" fi done @@ -307,11 +224,11 @@ main() { # Push updated branches; only delete merged branch if no conflicts if [[ "${#CONFLICTED_TARGETS[@]}" -eq 0 ]]; then # No conflicts - safe to delete merged branch - log_cmd git push origin ":$MERGED_BRANCH" "${UPDATED_TARGETS[@]}" "${ALL_CHILDREN[@]}" + log_cmd git push origin ":$MERGED_BRANCH" "${UPDATED_TARGETS[@]}" else # Some conflicts - keep merged branch for reference during manual resolution - if [[ "${#UPDATED_TARGETS[@]}" -gt 0 || "${#ALL_CHILDREN[@]}" -gt 0 ]]; then - log_cmd git push origin "${UPDATED_TARGETS[@]}" "${ALL_CHILDREN[@]}" + if [[ "${#UPDATED_TARGETS[@]}" -gt 0 ]]; then + log_cmd git push origin "${UPDATED_TARGETS[@]}" fi echo "⚠️ Keeping branch '$MERGED_BRANCH' - still referenced by conflicted PRs: ${CONFLICTED_TARGETS[*]}" fi