Skip to content

Commit 513bb65

Browse files
committed
Improve conflict handling: defer base branch change and branch deletion
When a merge conflict occurs during automatic PR stack update: - Don't change the PR's base branch (keeps the diff readable) - Don't delete the merged branch (user can reference it during resolution) After the user resolves conflicts and pushes: - Update the PR's base branch to trunk - Delete the old base branch only if no other conflicted PRs depend on it Changes: - main(): Track UPDATED_TARGETS vs CONFLICTED_TARGETS separately - main(): Only update base and delete branch for successfully updated PRs - Add has_sibling_conflicts() helper to check if other PRs share the base - continue_after_resolution(): Update base branch and conditionally delete - Update E2E tests to verify the new behavior - Add Scenario 3: sibling conflicts test for branch deletion timing
1 parent 1fd05bf commit 513bb65

3 files changed

Lines changed: 348 additions & 20 deletions

File tree

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,17 @@ When a merge conflict occurs during the automatic update:
2929

3030
1. The action posts a comment on the affected PR with instructions for manual resolution
3131
2. Adds a `autorestack-needs-conflict-resolution` label to the PR
32-
3. Updates the PR's base branch (but doesn't push the conflicted state)
32+
3. Keeps the PR's base branch unchanged (so the diff stays readable)
33+
4. Keeps the merged branch around (so you can reference it during resolution)
3334

3435
After you manually resolve the conflict and push:
3536

3637
1. The push triggers the `synchronize` event
3738
2. The action detects the conflict label and removes it
38-
3. Posts a confirmation comment
39-
4. Continues updating any dependent PRs in the stack
39+
3. Updates the PR's base branch to trunk
40+
4. Deletes the old base branch (if no other conflicted PRs still depend on it)
41+
5. Posts a confirmation comment
42+
6. Continues updating any dependent PRs in the stack
4043

4144
---
4245

tests/test_e2e.sh

Lines changed: 265 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,13 @@
5858
# - Does NOT push any conflicted state to the remote
5959
# - Posts a comment on PR3 explaining the conflict
6060
# - Adds a label "autorestack-needs-conflict-resolution" to PR3
61-
# - Updates PR3's base branch to main (even though merge failed)
61+
# - Does NOT update PR3's base branch (keeps it as feature2 for readable diff)
62+
# - Does NOT delete feature2 branch (still referenced by conflicted PR)
6263
# - Exits with success (conflict is handled gracefully, not a failure)
6364
#
6465
# Verifications:
65-
# - feature2 branch is deleted from remote
66-
# - PR3 base branch is updated to main
66+
# - feature2 branch is NOT deleted from remote (still referenced by conflicted PR3)
67+
# - PR3 base branch stays as feature2 (not updated to main)
6768
# - Conflict comment exists on PR3
6869
# - Conflict label "autorestack-needs-conflict-resolution" exists on PR3
6970
# - feature3 branch was NOT updated (still at pre-conflict SHA)
@@ -74,6 +75,8 @@
7475
# - Push the resolved branch
7576
# - The push triggers the 'synchronize' event on PR3
7677
# - The action detects the conflict label and removes it
78+
# - Updates PR3's base branch to main
79+
# - Deletes feature2 branch (no other conflicted PRs depend on it)
7780
# - The continuation workflow updates feature4 (grandchild) recursively
7881
# - Verify the label is removed, resolution comment is posted, and feature4 is updated
7982
#
@@ -676,23 +679,23 @@ fi
676679
# 11. Verification for Conflict Scenario
677680
echo >&2 "11. Verifying the results of the conflict scenario..."
678681
echo >&2 "Fetching latest state from remote..."
679-
log_cmd git fetch origin --prune # Prune deleted branch feature2
682+
log_cmd git fetch origin
680683

681-
# Verify feature2 branch was deleted remotely
684+
# Verify feature2 branch was NOT deleted (still referenced by conflicted PR3)
682685
if git show-ref --verify --quiet refs/remotes/origin/feature2; then
683-
echo >&2 "❌ Verification Failed: Remote branch 'origin/feature2' still exists after merge."
684-
exit 1
686+
echo >&2 "✅ Verification Passed: Remote branch 'origin/feature2' still exists (kept for conflicted PR)."
685687
else
686-
echo >&2 "✅ Verification Passed: Remote branch 'origin/feature2' was deleted."
688+
echo >&2 "❌ Verification Failed: Remote branch 'origin/feature2' was deleted prematurely."
689+
exit 1
687690
fi
688691

689-
# Verify PR3 base branch was updated to main (action updates base even on conflict)
692+
# Verify PR3 base branch was NOT updated (stays as feature2 for readable diff)
690693
echo >&2 "Checking PR #$PR3_NUM base branch..."
691694
PR3_BASE_AFTER_CONFLICT=$(log_cmd gh pr view "$PR3_NUM" --repo "$REPO_FULL_NAME" --json baseRefName --jq .baseRefName)
692-
if [[ "$PR3_BASE_AFTER_CONFLICT" == "main" ]]; then
693-
echo >&2 "✅ Verification Passed: PR #$PR3_NUM base branch updated to 'main'."
695+
if [[ "$PR3_BASE_AFTER_CONFLICT" == "feature2" ]]; then
696+
echo >&2 "✅ Verification Passed: PR #$PR3_NUM base branch stays as 'feature2' (not updated during conflict)."
694697
else
695-
echo >&2 "❌ Verification Failed: PR #$PR3_NUM base branch is '$PR3_BASE_AFTER_CONFLICT', expected 'main'."
698+
echo >&2 "❌ Verification Failed: PR #$PR3_NUM base branch is '$PR3_BASE_AFTER_CONFLICT', expected 'feature2'."
696699
exit 1
697700
fi
698701

@@ -789,6 +792,26 @@ else
789792
exit 1
790793
fi
791794

795+
# Verify PR3 base branch was updated to main after resolution
796+
echo >&2 "Checking PR #$PR3_NUM base branch after resolution..."
797+
PR3_BASE_AFTER_RESOLUTION=$(log_cmd gh pr view "$PR3_NUM" --repo "$REPO_FULL_NAME" --json baseRefName --jq .baseRefName)
798+
if [[ "$PR3_BASE_AFTER_RESOLUTION" == "main" ]]; then
799+
echo >&2 "✅ Verification Passed: PR #$PR3_NUM base branch updated to 'main' after resolution."
800+
else
801+
echo >&2 "❌ Verification Failed: PR #$PR3_NUM base branch is '$PR3_BASE_AFTER_RESOLUTION', expected 'main'."
802+
exit 1
803+
fi
804+
805+
# Verify feature2 was deleted after resolution (no other conflicted PRs depend on it)
806+
echo >&2 "Checking that feature2 branch was deleted after resolution..."
807+
log_cmd git fetch origin --prune
808+
if git show-ref --verify --quiet refs/remotes/origin/feature2; then
809+
echo >&2 "❌ Verification Failed: Remote branch 'origin/feature2' still exists after resolution."
810+
exit 1
811+
else
812+
echo >&2 "✅ Verification Passed: Remote branch 'origin/feature2' was deleted after resolution."
813+
fi
814+
792815
# Verify resolution acknowledgement comment exists on PR3
793816
echo >&2 "Checking for resolution acknowledgement comment on PR #$PR3_NUM..."
794817
RESOLUTION_COMMENT=$(log_cmd gh pr view "$PR3_URL" --repo "$REPO_FULL_NAME" --json comments --jq '.comments[] | select(.body | contains("Conflict resolved")) | .body')
@@ -863,6 +886,236 @@ fi
863886
echo >&2 "--- Conflict Scenario Test Completed Successfully ---"
864887

865888

889+
# --- SCENARIO 3: Sibling Conflicts (Multiple PRs from same base, both conflict) ---
890+
# ===================================================================================
891+
# Tests that the old base branch is kept until ALL sibling PRs resolve their conflicts.
892+
#
893+
# Setup:
894+
# - Create a new stack: main <- feature5 <- (feature6, feature7) parallel children
895+
# - Both feature6 and feature7 will conflict when feature5 is merged
896+
#
897+
# Expected Behavior:
898+
# - After merging feature5, both feature6 and feature7 have conflicts
899+
# - feature5 branch is kept (referenced by both conflicted PRs)
900+
# - After resolving feature6, feature5 is still kept (feature7 still conflicted)
901+
# - After resolving feature7, feature5 is deleted (no more conflicted siblings)
902+
# ===================================================================================
903+
904+
echo >&2 "--- Testing Sibling Conflicts Scenario ---"
905+
906+
# 16. Create new stack for sibling conflict test
907+
echo >&2 "16. Creating new stack for sibling conflict test..."
908+
log_cmd git checkout main
909+
log_cmd git pull origin main
910+
911+
# Create feature5 based on main
912+
log_cmd git checkout -b feature5 main
913+
echo "Feature 5 change on line 2" > file2.txt
914+
echo "Feature 5 base content line 2" >> file2.txt
915+
echo "Line 3" >> file2.txt
916+
echo "Line 4" >> file2.txt
917+
echo "Line 5" >> file2.txt
918+
log_cmd git add file2.txt
919+
log_cmd git commit -m "Add feature 5 with file2.txt"
920+
log_cmd git push origin feature5
921+
PR5_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base main --head feature5 --title "Feature 5" --body "This is PR 5")
922+
PR5_NUM=$(echo "$PR5_URL" | awk -F'/' '{print $NF}')
923+
echo >&2 "Created PR #$PR5_NUM: $PR5_URL"
924+
925+
# Create feature6 based on feature5 (will conflict)
926+
log_cmd git checkout -b feature6 feature5
927+
sed -i '2s/.*/Feature 6 conflicting content line 2/' file2.txt
928+
log_cmd git add file2.txt
929+
log_cmd git commit -m "Add feature 6 (conflicts on line 2)"
930+
log_cmd git push origin feature6
931+
PR6_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature5 --head feature6 --title "Feature 6" --body "This is PR 6, sibling of PR 7")
932+
PR6_NUM=$(echo "$PR6_URL" | awk -F'/' '{print $NF}')
933+
echo >&2 "Created PR #$PR6_NUM: $PR6_URL"
934+
935+
# Create feature7 based on feature5 (will also conflict)
936+
log_cmd git checkout feature5
937+
log_cmd git checkout -b feature7
938+
sed -i '2s/.*/Feature 7 conflicting content line 2/' file2.txt
939+
log_cmd git add file2.txt
940+
log_cmd git commit -m "Add feature 7 (also conflicts on line 2)"
941+
log_cmd git push origin feature7
942+
PR7_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature5 --head feature7 --title "Feature 7" --body "This is PR 7, sibling of PR 6")
943+
PR7_NUM=$(echo "$PR7_URL" | awk -F'/' '{print $NF}')
944+
echo >&2 "Created PR #$PR7_NUM: $PR7_URL"
945+
946+
# Introduce conflicting change on main for both siblings
947+
log_cmd git checkout main
948+
sed -i '2s/.*/Main conflicting content line 2/' file2.txt 2>/dev/null || {
949+
# file2.txt doesn't exist on main yet, create it with conflict
950+
echo "Main change on line 1" > file2.txt
951+
echo "Main conflicting content line 2" >> file2.txt
952+
echo "Line 3" >> file2.txt
953+
echo "Line 4" >> file2.txt
954+
echo "Line 5" >> file2.txt
955+
}
956+
log_cmd git add file2.txt
957+
log_cmd git commit -m "Add conflicting change on main for file2.txt"
958+
log_cmd git push origin main
959+
960+
# 17. Merge feature5 to trigger conflicts on both siblings
961+
echo >&2 "17. Squash merging PR #$PR5_NUM (feature5) to trigger sibling conflicts..."
962+
merge_pr_with_retry "$PR5_URL"
963+
MERGE_COMMIT_SHA5=$(gh pr view "$PR5_URL" --repo "$REPO_FULL_NAME" --json mergeCommit -q .mergeCommit.oid)
964+
echo >&2 "PR #$PR5_NUM merged. Squash commit SHA: $MERGE_COMMIT_SHA5"
965+
966+
# Wait for workflow
967+
echo >&2 "Waiting for workflow..."
968+
if ! wait_for_workflow "$PR5_NUM" "feature5" "$MERGE_COMMIT_SHA5" "success"; then
969+
echo >&2 "Workflow for PR5 merge did not complete successfully."
970+
exit 1
971+
fi
972+
973+
# 18. Verify both siblings have conflicts and feature5 is kept
974+
echo >&2 "18. Verifying sibling conflict state..."
975+
log_cmd git fetch origin
976+
977+
# Verify feature5 branch was NOT deleted (both siblings conflicted)
978+
if git show-ref --verify --quiet refs/remotes/origin/feature5; then
979+
echo >&2 "✅ Verification Passed: Remote branch 'origin/feature5' still exists (kept for conflicted siblings)."
980+
else
981+
echo >&2 "❌ Verification Failed: Remote branch 'origin/feature5' was deleted prematurely."
982+
exit 1
983+
fi
984+
985+
# Verify both PRs have conflict labels
986+
PR6_HAS_LABEL=$(gh pr view "$PR6_URL" --repo "$REPO_FULL_NAME" --json labels --jq '.labels[] | select(.name == "autorestack-needs-conflict-resolution") | .name')
987+
PR7_HAS_LABEL=$(gh pr view "$PR7_URL" --repo "$REPO_FULL_NAME" --json labels --jq '.labels[] | select(.name == "autorestack-needs-conflict-resolution") | .name')
988+
989+
if [[ "$PR6_HAS_LABEL" == "autorestack-needs-conflict-resolution" ]]; then
990+
echo >&2 "✅ Verification Passed: PR #$PR6_NUM has conflict label."
991+
else
992+
echo >&2 "❌ Verification Failed: PR #$PR6_NUM does not have conflict label."
993+
exit 1
994+
fi
995+
996+
if [[ "$PR7_HAS_LABEL" == "autorestack-needs-conflict-resolution" ]]; then
997+
echo >&2 "✅ Verification Passed: PR #$PR7_NUM has conflict label."
998+
else
999+
echo >&2 "❌ Verification Failed: PR #$PR7_NUM does not have conflict label."
1000+
exit 1
1001+
fi
1002+
1003+
# Verify both PRs still have feature5 as base
1004+
PR6_BASE=$(gh pr view "$PR6_NUM" --repo "$REPO_FULL_NAME" --json baseRefName --jq .baseRefName)
1005+
PR7_BASE=$(gh pr view "$PR7_NUM" --repo "$REPO_FULL_NAME" --json baseRefName --jq .baseRefName)
1006+
1007+
if [[ "$PR6_BASE" == "feature5" && "$PR7_BASE" == "feature5" ]]; then
1008+
echo >&2 "✅ Verification Passed: Both sibling PRs still have 'feature5' as base."
1009+
else
1010+
echo >&2 "❌ Verification Failed: PR6 base is '$PR6_BASE', PR7 base is '$PR7_BASE', expected both to be 'feature5'."
1011+
exit 1
1012+
fi
1013+
1014+
# 19. Resolve first sibling (feature6) - feature5 should still be kept
1015+
echo >&2 "19. Resolving first sibling (feature6)..."
1016+
log_cmd git checkout feature6
1017+
log_cmd git fetch origin
1018+
if git merge origin/main; then
1019+
echo >&2 "Merge succeeded unexpectedly (no conflict?)"
1020+
else
1021+
echo >&2 "Resolving conflict on feature6..."
1022+
log_cmd git checkout --ours file2.txt
1023+
log_cmd git add file2.txt
1024+
log_cmd git commit --no-edit
1025+
fi
1026+
log_cmd git push origin feature6
1027+
1028+
# Wait for continuation workflow
1029+
echo >&2 "Waiting for continuation workflow for feature6..."
1030+
if ! wait_for_synchronize_workflow "$PR6_NUM" "feature6" "success"; then
1031+
echo >&2 "Continuation workflow for feature6 did not complete successfully."
1032+
exit 1
1033+
fi
1034+
1035+
# 20. Verify feature5 is still kept (feature7 still conflicted)
1036+
echo >&2 "20. Verifying feature5 is still kept after first sibling resolution..."
1037+
log_cmd git fetch origin
1038+
1039+
# feature5 should still exist
1040+
if git show-ref --verify --quiet refs/remotes/origin/feature5; then
1041+
echo >&2 "✅ Verification Passed: Remote branch 'origin/feature5' still exists (feature7 still conflicted)."
1042+
else
1043+
echo >&2 "❌ Verification Failed: Remote branch 'origin/feature5' was deleted prematurely (feature7 still needs it)."
1044+
exit 1
1045+
fi
1046+
1047+
# PR6 base should now be main
1048+
PR6_BASE_AFTER=$(gh pr view "$PR6_NUM" --repo "$REPO_FULL_NAME" --json baseRefName --jq .baseRefName)
1049+
if [[ "$PR6_BASE_AFTER" == "main" ]]; then
1050+
echo >&2 "✅ Verification Passed: PR #$PR6_NUM base updated to 'main' after resolution."
1051+
else
1052+
echo >&2 "❌ Verification Failed: PR #$PR6_NUM base is '$PR6_BASE_AFTER', expected 'main'."
1053+
exit 1
1054+
fi
1055+
1056+
# PR6 should no longer have conflict label
1057+
PR6_LABEL_AFTER=$(gh pr view "$PR6_URL" --repo "$REPO_FULL_NAME" --json labels --jq '.labels[] | select(.name == "autorestack-needs-conflict-resolution") | .name')
1058+
if [[ -z "$PR6_LABEL_AFTER" ]]; then
1059+
echo >&2 "✅ Verification Passed: PR #$PR6_NUM conflict label removed."
1060+
else
1061+
echo >&2 "❌ Verification Failed: PR #$PR6_NUM still has conflict label."
1062+
exit 1
1063+
fi
1064+
1065+
# PR7 should still have conflict label and feature5 as base
1066+
PR7_LABEL_STILL=$(gh pr view "$PR7_URL" --repo "$REPO_FULL_NAME" --json labels --jq '.labels[] | select(.name == "autorestack-needs-conflict-resolution") | .name')
1067+
PR7_BASE_STILL=$(gh pr view "$PR7_NUM" --repo "$REPO_FULL_NAME" --json baseRefName --jq .baseRefName)
1068+
if [[ "$PR7_LABEL_STILL" == "autorestack-needs-conflict-resolution" && "$PR7_BASE_STILL" == "feature5" ]]; then
1069+
echo >&2 "✅ Verification Passed: PR #$PR7_NUM still has conflict label and 'feature5' base."
1070+
else
1071+
echo >&2 "❌ Verification Failed: PR7 label='$PR7_LABEL_STILL', base='$PR7_BASE_STILL'."
1072+
exit 1
1073+
fi
1074+
1075+
# 21. Resolve second sibling (feature7) - now feature5 should be deleted
1076+
echo >&2 "21. Resolving second sibling (feature7)..."
1077+
log_cmd git checkout feature7
1078+
log_cmd git fetch origin
1079+
if git merge origin/main; then
1080+
echo >&2 "Merge succeeded unexpectedly (no conflict?)"
1081+
else
1082+
echo >&2 "Resolving conflict on feature7..."
1083+
log_cmd git checkout --ours file2.txt
1084+
log_cmd git add file2.txt
1085+
log_cmd git commit --no-edit
1086+
fi
1087+
log_cmd git push origin feature7
1088+
1089+
# Wait for continuation workflow
1090+
echo >&2 "Waiting for continuation workflow for feature7..."
1091+
if ! wait_for_synchronize_workflow "$PR7_NUM" "feature7" "success"; then
1092+
echo >&2 "Continuation workflow for feature7 did not complete successfully."
1093+
exit 1
1094+
fi
1095+
1096+
# 22. Verify feature5 is now deleted (all siblings resolved)
1097+
echo >&2 "22. Verifying feature5 is deleted after all siblings resolved..."
1098+
log_cmd git fetch origin --prune
1099+
1100+
if git show-ref --verify --quiet refs/remotes/origin/feature5; then
1101+
echo >&2 "❌ Verification Failed: Remote branch 'origin/feature5' still exists after all siblings resolved."
1102+
exit 1
1103+
else
1104+
echo >&2 "✅ Verification Passed: Remote branch 'origin/feature5' was deleted after all siblings resolved."
1105+
fi
1106+
1107+
# PR7 base should now be main
1108+
PR7_BASE_FINAL=$(gh pr view "$PR7_NUM" --repo "$REPO_FULL_NAME" --json baseRefName --jq .baseRefName)
1109+
if [[ "$PR7_BASE_FINAL" == "main" ]]; then
1110+
echo >&2 "✅ Verification Passed: PR #$PR7_NUM base updated to 'main' after resolution."
1111+
else
1112+
echo >&2 "❌ Verification Failed: PR #$PR7_NUM base is '$PR7_BASE_FINAL', expected 'main'."
1113+
exit 1
1114+
fi
1115+
1116+
echo >&2 "--- Sibling Conflicts Scenario Test Completed Successfully ---"
1117+
1118+
8661119
# --- Test Succeeded ---
8671120
echo >&2 "--- E2E Test Completed Successfully! ---"
8681121

0 commit comments

Comments
 (0)