Skip to content

Commit a168db2

Browse files
Phlogistiqueclaude
andauthored
Push the base-branch merge before asking for conflict resolution (#33)
Alternative to #32 for the same stuck-PR bug. Pick one. Background: when a parent PR is squash-merged, the action brings each child branch up to date with three merges: the parent branch (the base-branch merge), the trunk as it stood just before the squash landed (`SQUASH_COMMIT~`), and the squash itself recorded with `-s ours` so the child's diff against its new base stays clean. When the pre-squash trunk merge conflicts, the action asks the user to resolve, and a follow-up run triggered by the user's push finishes the job. Two separate bugs left a conflicting child PR broken: 1. **The follow-up run never finished the job.** It removed the conflict label, retargeted the PR, and deleted the old base branch, but it never recorded the squash. The synthetic merge commit that keeps the child's diff against its new base clean was never created, and because the label was gone the action would never revisit the PR. 2. **Even with that fixed, the recipe could push the user into an unrecoverable state.** The conflict comment asked the user to merge only the conflicting ref, starting from a head that did not contain the base-branch merge. When that merge is clean the head is clean against its base going in, so the only thing that can make it conflict with the base is the user's own resolution. If resolving the `SQUASH_COMMIT~` conflict produced content that conflicts with the parent branch, the user's push left the head conflicting with its base. GitHub does not run `pull_request` workflows on a PR that conflicts with its base, so the follow-up run never fired and the PR was stuck for good. This PR fixes both: - Push the base-branch merge before commenting, when it is clean. The head then always contains it, so the parent branch is an ancestor and no resolution can make the head conflict with its base. The PR stays mergeable and the synchronize trigger keeps working. If the base merge itself conflicts there is nothing safe to pre-push, so the existing comment-and-label fallback runs. - `continue_after_resolution` now records the squash with `-s ours` and pushes before retargeting. The synchronize payload is the child PR, so the squash commit and new target are reconstructed from the merged parent PR (its merge commit and base). Pushing the base merge leaves the user's local branch behind origin, so the conflict comment now starts the recipe with `git pull --ff-only origin <branch>`. Without it the user resolves on a stale head and the final push is rejected as non-fast-forward. The comment still lists only the genuine conflict to merge. The e2e conflict scenario already triggers this case (clean base merge, conflicting `SQUASH_COMMIT~` merge), so coverage lives there: it checks the action pushed the base merge on top of the pre-conflict head and that the comment asks only for the genuine conflict, then resolves by following the comment verbatim (re-sync included) and checks `continue_after_resolution` records the squash so the branch ends up mergeable into the new target. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 03d8c05 commit a168db2

2 files changed

Lines changed: 163 additions & 76 deletions

File tree

tests/test_e2e.sh

Lines changed: 122 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,10 @@
7171
# - Squash merge PR2 (feature2) into main
7272
#
7373
# Expected Behavior:
74-
# - The action attempts to merge main into feature3
75-
# - Detects a merge conflict (both modified line 7 differently)
76-
# - Does NOT push any conflicted state to the remote
74+
# - The action merges feature2 into feature3 if that clean base merge is safe
75+
# - Detects a merge conflict with the pre-squash target state (both modified
76+
# line 7 differently)
77+
# - Pushes only the clean base merge, never any conflicted state
7778
# - Posts a comment on PR3 explaining the conflict
7879
# - Adds a label "autorestack-needs-conflict-resolution" to PR3
7980
# - Does NOT update PR3's base branch (keeps it as feature2 for readable diff)
@@ -85,11 +86,11 @@
8586
# - PR3 base branch stays as feature2 (not updated to main)
8687
# - Conflict comment exists on PR3
8788
# - Conflict label "autorestack-needs-conflict-resolution" exists on PR3
88-
# - feature3 branch was NOT updated (still at pre-conflict SHA)
89+
# - feature3 branch advanced only by the clean base merge
8990
#
9091
# Manual Conflict Resolution (Steps 12-15):
9192
# - Test simulates user resolving the conflict manually
92-
# - Merge main into feature3, resolve conflict (keep feature3's changes)
93+
# - Follow the posted comment, resolve conflict (keep feature3's changes)
9394
# - Push the resolved branch
9495
# - The push triggers the 'synchronize' event on PR3
9596
# - The action detects the conflict label and removes it
@@ -206,6 +207,62 @@ compare_diffs() {
206207
fi
207208
}
208209

210+
get_conflict_comment() {
211+
local pr_url=$1
212+
local pr_number=$2
213+
local comment
214+
215+
comment=$(log_cmd gh pr view "$pr_url" --repo "$REPO_FULL_NAME" --json comments --jq '.comments[] | select(.body | contains("Automatic update blocked by merge conflicts")) | .body')
216+
if [[ -n "$comment" ]]; then
217+
echo >&2 "✅ Verification Passed: Conflict comment found on PR #$pr_number."
218+
echo "$comment" >&2
219+
else
220+
echo >&2 "❌ Verification Failed: Conflict comment not found on PR #$pr_number."
221+
echo >&2 "--- Comments on PR #$pr_number ---"
222+
gh pr view "$pr_url" --repo "$REPO_FULL_NAME" --json comments --jq '.comments[].body' || echo "Failed to get comments"
223+
echo >&2 "-----------------------------"
224+
exit 1
225+
fi
226+
227+
printf '%s\n' "$comment"
228+
}
229+
230+
follow_conflict_comment() {
231+
local comment=$1
232+
local branch=$2
233+
local conflict_file=$3
234+
local resolution_description=$4
235+
local comment_merges
236+
local hit_conflict=false
237+
238+
log_cmd git fetch origin
239+
log_cmd git checkout "$branch"
240+
log_cmd git pull origin "$branch"
241+
242+
comment_merges=$(echo "$comment" | grep -E '^git merge' || true)
243+
if [[ -z "$comment_merges" ]]; then
244+
echo >&2 "❌ Verification Failed: conflict comment lists no 'git merge' commands to follow."
245+
echo >&2 "$comment"
246+
exit 1
247+
fi
248+
249+
while IFS= read -r cmd; do
250+
echo >&2 "Following comment: $cmd"
251+
if ! log_cmd bash -c "$cmd"; then
252+
echo >&2 "Conflict during '$cmd'; resolving by keeping $resolution_description..."
253+
log_cmd git checkout --ours "$conflict_file"
254+
log_cmd git add "$conflict_file"
255+
log_cmd git commit --no-edit
256+
hit_conflict=true
257+
fi
258+
done <<< "$comment_merges"
259+
260+
if [[ "$hit_conflict" != "true" ]]; then
261+
echo >&2 "❌ Verification Failed: following the comment hit no conflict; scenario expected one."
262+
exit 1
263+
fi
264+
}
265+
209266
# Wait for a PR's base branch to change to the expected value.
210267
# Uses retry loop instead of arbitrary sleep.
211268
wait_for_pr_base_change() {
@@ -840,17 +897,7 @@ fi
840897
echo >&2 "Checking for conflict comment on PR #$PR3_NUM..."
841898
# Give GitHub some time to process the comment
842899
sleep 5
843-
CONFLICT_COMMENT=$(log_cmd gh pr view "$PR3_URL" --repo "$REPO_FULL_NAME" --json comments --jq '.comments[] | select(.body | contains("Automatic update blocked by merge conflicts")) | .body')
844-
if [[ -n "$CONFLICT_COMMENT" ]]; then
845-
echo >&2 "✅ Verification Passed: Conflict comment found on PR #$PR3_NUM."
846-
echo "$CONFLICT_COMMENT" # Log the comment
847-
else
848-
echo >&2 "❌ Verification Failed: Conflict comment not found on PR #$PR3_NUM."
849-
echo >&2 "--- Comments on PR #$PR3_NUM ---"
850-
gh pr view "$PR3_URL" --repo "$REPO_FULL_NAME" --json comments --jq '.comments[].body' || echo "Failed to get comments"
851-
echo >&2 "-----------------------------"
852-
exit 1
853-
fi
900+
CONFLICT_COMMENT=$(get_conflict_comment "$PR3_URL" "$PR3_NUM")
854901

855902
# Verify conflict label exists on PR3
856903
echo >&2 "Checking for conflict label on PR #$PR3_NUM..."
@@ -865,45 +912,50 @@ else
865912
exit 1
866913
fi
867914

868-
# Verify feature3 branch was NOT pushed with conflicts (check its head SHA)
915+
# The base-branch merge (feature2 into feature3) is clean here; only the
916+
# pre-squash merge conflicts. The action pushes that clean base merge before
917+
# commenting, so feature3 stays a descendant of its base (mergeable, so the
918+
# synchronize event that resumes the action keeps firing). Verify the push
919+
# happened and that it only fast-forwarded on top of the pre-conflict head.
869920
REMOTE_FEATURE3_SHA_BEFORE_RESOLVE=$(log_cmd git rev-parse "refs/remotes/origin/feature3")
870-
# The action failed the merge locally, so it shouldn't have pushed feature3.
871-
# The remote SHA should still be the one from step 8 ("Conflict: Modify line 3 on feature3").
872-
EXPECTED_FEATURE3_SHA_BEFORE_RESOLVE=$FEATURE3_CONFLICT_COMMIT_SHA
873-
if [[ "$REMOTE_FEATURE3_SHA_BEFORE_RESOLVE" == "$EXPECTED_FEATURE3_SHA_BEFORE_RESOLVE" ]]; then
874-
echo >&2 "✅ Verification Passed: Remote feature3 branch was not updated by the action due to conflict."
921+
if log_cmd git merge-base --is-ancestor "$FEATURE3_CONFLICT_COMMIT_SHA" "refs/remotes/origin/feature3" \
922+
&& [[ "$REMOTE_FEATURE3_SHA_BEFORE_RESOLVE" != "$FEATURE3_CONFLICT_COMMIT_SHA" ]]; then
923+
echo >&2 "✅ Verification Passed: action pushed the clean base merge on top of feature3 (still a descendant of its pre-conflict head)."
875924
else
876-
echo >&2 "❌ Verification Failed: Remote feature3 branch SHA ($REMOTE_FEATURE3_SHA_BEFORE_RESOLVE) differs from expected SHA before conflict resolution ($EXPECTED_FEATURE3_SHA_BEFORE_RESOLVE)."
877-
exit 1
925+
echo >&2 "❌ Verification Failed: expected origin/feature3 to advance from $FEATURE3_CONFLICT_COMMIT_SHA with the pushed base merge, got $REMOTE_FEATURE3_SHA_BEFORE_RESOLVE."
926+
log_cmd git log --graph --oneline origin/feature3 origin/feature2
927+
exit 1
878928
fi
879-
880-
881-
# 12. Resolve conflict manually
882-
echo >&2 "12. Resolving conflict manually on feature3..."
883-
log_cmd git checkout feature3
884-
# Ensure we have the latest main which includes the PR2 merge commit AND the conflicting change on main
885-
log_cmd git fetch origin
886-
# Now, perform the merge that the action tried and failed
887-
echo >&2 "Attempting merge of origin/main into feature3..."
888-
if git merge origin/main; then
889-
echo >&2 "❌ Conflict Resolution Failed: Merge of main into feature3 succeeded unexpectedly (no conflict?)"
890-
log_cmd git status
891-
log_cmd git log --graph --oneline --all
929+
# The base merge brought feature2's updated state into feature3...
930+
if log_cmd git merge-base --is-ancestor "refs/remotes/origin/feature2" "refs/remotes/origin/feature3"; then
931+
echo >&2 "✅ Verification Passed: origin/feature3 contains origin/feature2 (the pushed base merge)."
932+
else
933+
echo >&2 "❌ Verification Failed: origin/feature3 does not contain origin/feature2 after the push."
934+
exit 1
935+
fi
936+
# ...and the comment must ask only for the genuine conflict (the pre-squash
937+
# merge), not the base merge the action already did and pushed.
938+
if echo "$CONFLICT_COMMENT" | grep -q "^git merge origin/feature2"; then
939+
echo >&2 "❌ Verification Failed: comment asks the user to merge origin/feature2, which the action already pushed."
940+
echo >&2 "$CONFLICT_COMMENT"
892941
exit 1
893942
else
894-
echo >&2 "Merge conflict occurred as expected. Resolving..."
895-
# Check status to confirm conflict
896-
log_cmd git status
897-
# Resolve conflict - keep feature3's version (ours) of the conflicting file
898-
# This preserves both line 2 (Feature 3 content) and line 7 (Feature 3 conflicting change)
899-
log_cmd git checkout --ours file.txt
900-
echo "Resolved file.txt content:"
901-
cat file.txt
902-
log_cmd git add file.txt
903-
# Use 'git commit' without '-m' to use the default merge commit message
904-
log_cmd git commit --no-edit
905-
echo >&2 "Conflict resolved and committed."
943+
echo >&2 "✅ Verification Passed: comment omits the base merge the action already pushed."
906944
fi
945+
946+
947+
# 12. Resolve the conflict by following the comment the action posted.
948+
echo >&2 "12. Resolving conflict on feature3 by following the posted comment..."
949+
# The action pushed the clean base merge to feature3, so the local branch is now
950+
# behind origin. The comment tells the user to fast-forward to it before merging;
951+
# skipping that would leave the resolution on a stale head and the final push
952+
# would be rejected as non-fast-forward. Verify the comment carries that step,
953+
# then run it. Following the comment must leave feature3 cleanly mergeable into
954+
# its new base, or the synchronize-triggered continuation can never make progress
955+
# and the conflict label stays stuck.
956+
follow_conflict_comment "$CONFLICT_COMMENT" feature3 file.txt "feature3's side"
957+
echo >&2 "Resolved file.txt content:"
958+
cat file.txt
907959
log_cmd git push origin feature3
908960
echo >&2 "Pushed resolved feature3."
909961

@@ -1117,18 +1169,26 @@ else
11171169
exit 1
11181170
fi
11191171

1120-
# 19. Resolve first sibling (feature6) - feature5 should still be kept
1121-
echo >&2 "19. Resolving first sibling (feature6)..."
1122-
log_cmd git checkout feature6
1123-
log_cmd git fetch origin
1124-
if git merge origin/main; then
1125-
echo >&2 "Merge succeeded unexpectedly (no conflict?)"
1126-
else
1127-
echo >&2 "Resolving conflict on feature6..."
1128-
log_cmd git checkout --ours file.txt
1129-
log_cmd git add file.txt
1130-
log_cmd git commit --no-edit
1172+
# Verify both conflict comments exist and ask only for the genuine conflict.
1173+
echo >&2 "Checking for conflict comments on PR #$PR6_NUM and PR #$PR7_NUM..."
1174+
sleep 5
1175+
PR6_CONFLICT_COMMENT=$(get_conflict_comment "$PR6_URL" "$PR6_NUM")
1176+
PR7_CONFLICT_COMMENT=$(get_conflict_comment "$PR7_URL" "$PR7_NUM")
1177+
if echo "$PR6_CONFLICT_COMMENT" | grep -q "^git merge origin/feature5"; then
1178+
echo >&2 "❌ Verification Failed: PR #$PR6_NUM comment asks the user to merge origin/feature5, which the action already pushed or already had."
1179+
echo >&2 "$PR6_CONFLICT_COMMENT"
1180+
exit 1
1181+
fi
1182+
if echo "$PR7_CONFLICT_COMMENT" | grep -q "^git merge origin/feature5"; then
1183+
echo >&2 "❌ Verification Failed: PR #$PR7_NUM comment asks the user to merge origin/feature5, which the action already pushed or already had."
1184+
echo >&2 "$PR7_CONFLICT_COMMENT"
1185+
exit 1
11311186
fi
1187+
echo >&2 "✅ Verification Passed: sibling conflict comments omit the base merge."
1188+
1189+
# 19. Resolve first sibling (feature6) - feature5 should still be kept
1190+
echo >&2 "19. Resolving first sibling (feature6) by following the posted comment..."
1191+
follow_conflict_comment "$PR6_CONFLICT_COMMENT" feature6 file.txt "feature6's side"
11321192
log_cmd git push origin feature6
11331193

11341194
# Wait for continuation workflow
@@ -1179,17 +1239,8 @@ else
11791239
fi
11801240

11811241
# 21. Resolve second sibling (feature7) - now feature5 should be deleted
1182-
echo >&2 "21. Resolving second sibling (feature7)..."
1183-
log_cmd git checkout feature7
1184-
log_cmd git fetch origin
1185-
if git merge origin/main; then
1186-
echo >&2 "Merge succeeded unexpectedly (no conflict?)"
1187-
else
1188-
echo >&2 "Resolving conflict on feature7..."
1189-
log_cmd git checkout --ours file.txt
1190-
log_cmd git add file.txt
1191-
log_cmd git commit --no-edit
1192-
fi
1242+
echo >&2 "21. Resolving second sibling (feature7) by following the posted comment..."
1243+
follow_conflict_comment "$PR7_CONFLICT_COMMENT" feature7 file.txt "feature7's side"
11931244
log_cmd git push origin feature7
11941245

11951246
# Wait for continuation workflow

update-pr-stack.sh

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,11 @@ update_direct_target() {
7373
echo "Updating direct target $BRANCH (from $MERGED_BRANCH to $BASE_BRANCH)"
7474

7575
CONFLICTS=()
76+
local BASE_MERGE_CLEAN=true
7677
log_cmd git update-ref BEFORE_MERGE HEAD
7778
if ! log_cmd git merge --no-edit "origin/$MERGED_BRANCH"; then
7879
CONFLICTS+=("origin/$MERGED_BRANCH")
80+
BASE_MERGE_CLEAN=false
7981
log_cmd git merge --abort
8082
fi
8183
# Only try merging the pre-squash target state if it's not already
@@ -88,6 +90,19 @@ update_direct_target() {
8890
fi
8991

9092
if [[ "${#CONFLICTS[@]}" -gt 0 ]]; then
93+
# When the base-branch merge was clean, HEAD now holds it (the
94+
# conflicting pre-squash merge was aborted back to it). Push it before
95+
# asking for help: the user resolves on top of it, and the head stays a
96+
# descendant of its base so the PR stays mergeable and the synchronize
97+
# event that resumes this action still fires. GitHub does not run
98+
# pull_request workflows on a PR conflicting with its base, which would
99+
# otherwise strand the branch for good. If the base merge itself
100+
# conflicted we have nothing safe to pre-push, so we just ask for help.
101+
# Note: ordering is important here: if we label before pushing, we
102+
# re-trigger ourselves immediately.
103+
if [[ "$BASE_MERGE_CLEAN" == true ]]; then
104+
log_cmd git push origin "$BRANCH"
105+
fi
91106
{
92107
echo "### ⚠️ Automatic update blocked by merge conflicts"
93108
echo
@@ -99,6 +114,7 @@ update_direct_target() {
99114
echo '```bash'
100115
echo "git fetch origin"
101116
echo "git switch $BRANCH"
117+
echo "git pull origin $BRANCH"
102118
for conflict in "${CONFLICTS[@]}"; do
103119
echo "git merge $conflict"
104120
echo "# ..."
@@ -177,15 +193,35 @@ continue_after_resolution() {
177193
OLD_BASE=$(gh pr view "$PR_BRANCH" --json baseRefName --jq '.baseRefName')
178194
echo "Current base branch: $OLD_BASE"
179195

180-
# Find where the old base was merged to (the new target)
181-
local NEW_TARGET
182-
NEW_TARGET=$(gh pr list --head "$OLD_BASE" --state merged --json baseRefName --jq '.[0].baseRefName')
196+
# The synchronize payload is the child PR, so SQUASH_COMMIT / MERGED_BRANCH /
197+
# TARGET_BRANCH from the original squash-merge run are not in the environment.
198+
# Reconstruct them from the merged parent PR: OLD_BASE is the parent branch,
199+
# and the merged PR whose head is OLD_BASE gives the new target (its base) and
200+
# the squash commit (its merge commit).
201+
local NEW_TARGET SQUASH_HASH
202+
read -r NEW_TARGET SQUASH_HASH < <(gh pr list --head "$OLD_BASE" --state merged \
203+
--json baseRefName,mergeCommit --jq '.[0] | "\(.baseRefName // "") \(.mergeCommit.oid // "")"')
183204

184-
if [[ -z "$NEW_TARGET" ]]; then
205+
if [[ -z "$NEW_TARGET" || -z "$SQUASH_HASH" ]]; then
185206
echo "⚠️ Could not find where '$OLD_BASE' was merged to; skipping base branch and deletion updates"
186207
# Don't update base or delete old branch - leave things as they are
187208
else
188-
echo "Old base '$OLD_BASE' was merged to '$NEW_TARGET'"
209+
echo "Old base '$OLD_BASE' was merged to '$NEW_TARGET' as $SQUASH_HASH"
210+
211+
# The squash-merge run pushed the base merge and asked the user to resolve
212+
# the pre-squash merge, but it never recorded the squash itself. Finish
213+
# that now: re-run the same merge sequence as the squash-merge path. With
214+
# the user's resolution in place the base merge and pre-squash merge are
215+
# no-ops; only the "-s ours" squash record gets applied, keeping the diff
216+
# against the new base clean. has_squash_commit makes this idempotent.
217+
log_cmd git update-ref SQUASH_COMMIT "$SQUASH_HASH"
218+
MERGED_BRANCH="$OLD_BASE"
219+
TARGET_BRANCH="$NEW_TARGET"
220+
if ! update_direct_target "$PR_BRANCH" "$NEW_TARGET"; then
221+
echo "⚠️ '$PR_BRANCH' still conflicts; re-posted the conflict comment, will retry on next push"
222+
return 1
223+
fi
224+
log_cmd git push origin "$PR_BRANCH"
189225

190226
# Remove the conflict label
191227
log_cmd gh pr edit "$PR_BRANCH" --remove-label "$CONFLICT_LABEL"

0 commit comments

Comments
 (0)