Skip to content

Commit a75b7da

Browse files
Phlogistiqueclaude
andcommitted
Fix stuck PRs from incomplete conflict-resolution comment
When updating a descendant branch after its parent PR was squash-merged hits a merge conflict, the bot posts a comment telling the author how to finish by hand. That comment listed only the ref that conflicted, so a human following it ran a single `git merge` and ended up with a branch still missing the parent-branch merge and the `-s ours` squash record — never mergeable into its new base, so it stayed stuck. The sequence is the same whichever merge conflicts, so the comment now prints it in full: merge the parent branch, merge the pre-squash target, then record the squash with `-s ours`. This fixes the comment only; the author runs the steps by hand. Diagnosed on sensei #7987. Adds an offline regression test that replays the comment's commands and asserts the branch ends up mergeable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 908602b commit a75b7da

2 files changed

Lines changed: 200 additions & 21 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
#!/bin/bash
2+
#
3+
# Regression test for the conflict-resolution comment.
4+
#
5+
# THE BUG:
6+
# update_direct_target() merges several refs into a descendant branch:
7+
# 1. origin/$MERGED_BRANCH (the parent PR's tip)
8+
# 2. $SQUASH_COMMIT~ (the target branch just before the squash)
9+
# 3. -s ours $SQUASH_COMMIT (record the squash as merged, keep the tree)
10+
# When merge #1 is clean but merge #2 conflicts, only the conflicting ref used
11+
# to be listed in the "How to resolve" steps. A human following the comment
12+
# literally would merge ONLY $SQUASH_COMMIT~, ending up with a branch that is
13+
# missing both the parent's content (merge #1) and the squash ancestry (#3).
14+
# Such a branch is not cleanly mergeable into its new base, so the
15+
# synchronize-triggered continuation never makes progress and the label stays
16+
# stuck on the PR.
17+
#
18+
# This test reproduces exactly that topology, replays the merge commands the
19+
# comment tells the human to run, and asserts the branch ends up mergeable
20+
# (squash commit and parent content are both present).
21+
#
22+
# It runs fully offline using mock gh/git, like the other unit tests.
23+
24+
set -e
25+
26+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
27+
source "$SCRIPT_DIR/../command_utils.sh"
28+
29+
simulate_push() {
30+
log_cmd git update-ref "refs/remotes/origin/$1" "$1"
31+
}
32+
33+
TEST_REPO=$(mktemp -d)
34+
cd "$TEST_REPO"
35+
echo "Created test repo at $TEST_REPO"
36+
37+
# A mock gh that records the conflict comment and otherwise stays quiet.
38+
COMMENT_FILE="$TEST_REPO/conflict_comment.md"
39+
cat > "$TEST_REPO/mock_gh.sh" <<MOCK
40+
#!/bin/bash
41+
if [[ "\$1" == "pr" && "\$2" == "list" ]]; then
42+
# Only feature2 is a direct child of feature1 in this scenario.
43+
base=""
44+
for ((i=1; i<=\$#; i++)); do
45+
if [[ "\${!i}" == "--base" ]]; then next=\$((i+1)); base="\${!next}"; fi
46+
done
47+
[[ "\$base" == "feature1" ]] && echo "feature2"
48+
elif [[ "\$1" == "pr" && "\$2" == "comment" ]]; then
49+
cat > "$COMMENT_FILE"
50+
elif [[ "\$1" == "label" || ( "\$1" == "pr" && "\$2" == "edit" ) ]]; then
51+
: # ignore label creation / edits
52+
else
53+
echo "Unknown gh command: \$@" >&2
54+
exit 1
55+
fi
56+
MOCK
57+
chmod +x "$TEST_REPO/mock_gh.sh"
58+
59+
# Replaying merges may create non-ff merge commits; never open an editor.
60+
export GIT_EDITOR=true
61+
62+
log_cmd git init -b main
63+
log_cmd git config user.email "test@example.com"
64+
log_cmd git config user.name "Test User"
65+
66+
# 12-line file so changes on different lines do not collide.
67+
for i in $(seq 1 12); do echo "line $i" >> file.txt; done
68+
log_cmd git add file.txt
69+
log_cmd git commit -m "Initial commit"
70+
simulate_push main
71+
72+
# feature1 (the parent PR): first commit on line 2.
73+
log_cmd git checkout -b feature1
74+
sed -i '2s/.*/f1 commit 1/' file.txt
75+
log_cmd git add file.txt
76+
log_cmd git commit -m "f1: commit 1"
77+
simulate_push feature1
78+
79+
# feature2 (the child PR): branched off feature1, changes line 9.
80+
log_cmd git checkout -b feature2
81+
sed -i '9s/.*/f2 content line 9/' file.txt
82+
log_cmd git add file.txt
83+
log_cmd git commit -m "f2: change line 9"
84+
simulate_push feature2
85+
86+
# feature1 advances AFTER feature2 branched (line 4). feature2 never picks
87+
# this up, so merging origin/feature1 into feature2 is clean and substantive.
88+
log_cmd git checkout feature1
89+
sed -i '4s/.*/f1 commit 2/' file.txt
90+
log_cmd git add file.txt
91+
log_cmd git commit -m "f1: commit 2"
92+
simulate_push feature1
93+
94+
# main advances on line 9 with content that conflicts with feature2.
95+
log_cmd git checkout main
96+
sed -i '9s/.*/main conflicting line 9/' file.txt
97+
log_cmd git add file.txt
98+
log_cmd git commit -m "main: conflicting change on line 9"
99+
simulate_push main
100+
101+
# Squash-merge feature1 into main. SQUASH_COMMIT~ is now main carrying the
102+
# conflicting line-9 change; merging it into feature2 conflicts.
103+
log_cmd git checkout main
104+
log_cmd git merge --squash feature1
105+
log_cmd git commit -m "Squash merge feature1"
106+
SQUASH_COMMIT=$(git rev-parse HEAD)
107+
simulate_push main
108+
echo "Squash commit: $SQUASH_COMMIT"
109+
110+
echo ""
111+
echo "=== Graph before running the action ==="
112+
log_cmd git log --graph --oneline --all
113+
echo ""
114+
115+
# Run the action. We expect a conflict and a posted comment (exit 1 from
116+
# update_direct_target is swallowed by main, which returns 0).
117+
log_cmd \
118+
env \
119+
SQUASH_COMMIT="$SQUASH_COMMIT" \
120+
MERGED_BRANCH=feature1 \
121+
TARGET_BRANCH=main \
122+
GH="$TEST_REPO/mock_gh.sh" \
123+
GIT="$SCRIPT_DIR/mock_git.sh" \
124+
"$SCRIPT_DIR/../update-pr-stack.sh"
125+
126+
if [[ ! -f "$COMMENT_FILE" ]]; then
127+
echo "❌ No conflict comment was posted; scenario did not trigger a conflict"
128+
exit 1
129+
fi
130+
131+
echo ""
132+
echo "=== Conflict comment posted to the PR ==="
133+
cat "$COMMENT_FILE"
134+
echo ""
135+
136+
# Simulate the human doing EXACTLY what the comment says: start from the
137+
# branch as it exists on the remote (the action never pushes its local merge),
138+
# then run each `git merge` line, resolving any conflict by keeping our side.
139+
log_cmd git checkout feature2
140+
log_cmd git reset --hard origin/feature2
141+
142+
MERGE_CMDS=$(grep -E '^git merge' "$COMMENT_FILE" || true)
143+
if [[ -z "$MERGE_CMDS" ]]; then
144+
echo "❌ Comment lists no 'git merge' commands to follow"
145+
exit 1
146+
fi
147+
148+
while IFS= read -r cmd; do
149+
echo "Human runs: $cmd"
150+
if ! log_cmd bash -c "$cmd"; then
151+
echo "Resolving conflict by keeping our (feature2) side..."
152+
log_cmd git checkout --ours -- file.txt
153+
log_cmd git add file.txt
154+
log_cmd git commit --no-edit
155+
fi
156+
done <<< "$MERGE_CMDS"
157+
158+
simulate_push feature2
159+
160+
echo ""
161+
echo "=== feature2 after following the comment ==="
162+
log_cmd git log --graph --oneline --all
163+
echo ""
164+
165+
# After following the comment, the branch must be cleanly mergeable into its
166+
# new base (main). That requires the squash commit to be an ancestor (so the
167+
# diff against main is clean) and the parent's advanced content to be present.
168+
FAILED=0
169+
170+
if log_cmd git merge-base --is-ancestor "$SQUASH_COMMIT" feature2; then
171+
echo "✅ feature2 includes the squash commit (mergeable into main)"
172+
else
173+
echo "❌ BUG: feature2 is missing the squash commit after following the comment."
174+
echo " The PR cannot be cleanly merged into its new base; the continuation"
175+
echo " workflow will never make progress and the conflict label stays stuck."
176+
FAILED=1
177+
fi
178+
179+
if log_cmd git merge-base --is-ancestor origin/feature1 feature2; then
180+
echo "✅ feature2 includes the parent branch's advanced content"
181+
else
182+
echo "❌ BUG: feature2 is missing origin/feature1's content (merge #1 was"
183+
echo " omitted from the comment)."
184+
FAILED=1
185+
fi
186+
187+
if [[ "$FAILED" -ne 0 ]]; then
188+
exit 1
189+
fi
190+
191+
echo ""
192+
echo "All conflict-comment-completeness tests passed! 🎉"
193+
echo "Test repository remains at: $TEST_REPO for inspection"

update-pr-stack.sh

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,6 @@ has_squash_commit() {
4545
&& git merge-base --is-ancestor SQUASH_COMMIT "$BRANCH"
4646
}
4747

48-
format_branch_list_for_text() {
49-
for ((i=1; i<=$#; i++)); do
50-
case $i in
51-
1) format='`%s`';;
52-
$#) format=', and `%s`';;
53-
*) format=', `%s`';;
54-
esac
55-
printf "$format" "${!i}"
56-
done
57-
}
58-
5948
update_direct_target() {
6049
local BRANCH="$1"
6150
local BASE_BRANCH="$2"
@@ -91,24 +80,21 @@ update_direct_target() {
9180
{
9281
echo "### ⚠️ Automatic update blocked by merge conflicts"
9382
echo
94-
echo -n "I tried to merge "
95-
format_branch_list_for_text "${CONFLICTS[@]}"
96-
echo " into this branch while updating the pull request stack and hit conflicts."
83+
echo "I hit a merge conflict while updating this pull request after its base branch was squash-merged. Please resolve it by hand by running these in order:"
9784
echo
9885
echo "#### How to resolve"
9986
echo '```bash'
10087
echo "git fetch origin"
10188
echo "git switch $BRANCH"
102-
for conflict in "${CONFLICTS[@]}"; do
103-
echo "git merge $conflict"
104-
echo "# ..."
105-
echo '# fix conflicts, for instance with `git mergetool`'
106-
echo "# ..."
107-
echo "git commit"
108-
done
89+
echo "git merge origin/$MERGED_BRANCH"
90+
echo "git merge $(git rev-parse SQUASH_COMMIT~)"
91+
echo "# record the squash commit as merged without re-applying its changes:"
92+
echo "git merge -s ours $(git rev-parse SQUASH_COMMIT)"
10993
echo "git push"
11094
echo '```'
11195
echo
96+
echo 'If a merge stops with conflicts, fix them (for instance with `git mergetool`), `git commit`, then run the next command.'
97+
echo
11298
echo "Once you push, this action will resume and finish updating this pull request."
11399
} | log_cmd gh pr comment "$BRANCH" -F -
114100
# Create the label if it doesn't exist, then add it to the PR

0 commit comments

Comments
 (0)