Skip to content

Commit 562ba64

Browse files
Phlogistiqueclaude
andcommitted
Push the base-branch merge before asking for conflict resolution
Alternative to #32 for the same stuck-PR bug — pick one. After a parent PR is squash-merged, updating a descendant branch runs three merges: the parent branch, the target's pre-squash state, then the squash recorded with `-s ours`. When the pre-squash merge conflicted, the action commented and labeled without pushing anything, so the head was left missing both the base merge and the squash record. The branch never became mergeable, and because GitHub skips `pull_request` workflows on a PR that conflicts with its base, the `synchronize` event that resumes the action never fired again — permanently stuck. This splits the work so the user only resolves the genuine conflict: - When the base-branch merge is clean and only the pre-squash merge conflicts, push the base merge to the branch before commenting and labeling. The head stays a descendant of its base, so the PR stays mergeable and the resume trigger keeps firing. If the base merge itself conflicts there is nothing safe to pre-push, so the existing comment-and-label fallback runs. - After the user resolves and pushes, continue_after_resolution 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). The conflict comment is unchanged: it already lists the genuine conflict. Offline test covers both halves: the squash-merge run pushes the base merge before the unchanged comment, and the follow-up run records the squash so the branch ends up mergeable into the new target. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 908602b commit 562ba64

2 files changed

Lines changed: 349 additions & 4 deletions

File tree

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
#!/bin/bash
2+
#
3+
# Test for the "bot does the clean work, user resolves only the genuine
4+
# conflict" behaviour, in two parts.
5+
#
6+
# When updating a descendant branch after a parent squash-merge, the parent
7+
# branch is merged first (step 1), then the target's pre-squash state (step 2),
8+
# then the squash is recorded with -s ours (step 0).
9+
#
10+
# Part A (squash-merge mode): step 1 is clean but step 2 conflicts. The action
11+
# pushes the step-1 result to the branch BEFORE posting the comment and label,
12+
# so the head stays a descendant of its base. That keeps the PR mergeable and
13+
# lets the synchronize event that resumes the action still fire (GitHub does not
14+
# run pull_request workflows on a PR conflicting with its base). The posted
15+
# comment is unchanged: it lists only the genuine conflict (the pre-squash
16+
# merge), because step 1 was clean and never entered the conflict list.
17+
#
18+
# Part B (conflict-resolved mode): after the user resolves step 2 and pushes,
19+
# continue_after_resolution records the squash with -s ours and pushes BEFORE
20+
# retargeting, so the branch ends up mergeable into the new target. The squash
21+
# commit is reconstructed from the merged parent PR (mergeCommit), since the
22+
# synchronize payload is the child PR and SQUASH_COMMIT is not in the env.
23+
#
24+
# Runs fully offline. Unlike the other unit tests, its mock git APPLIES pushes
25+
# to local origin refs, so the simulated user picks up what the action pushed.
26+
27+
set -e
28+
29+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30+
source "$SCRIPT_DIR/../command_utils.sh"
31+
32+
simulate_push() {
33+
log_cmd git update-ref "refs/remotes/origin/$1" "$1"
34+
}
35+
36+
TEST_REPO=$(mktemp -d)
37+
cd "$TEST_REPO"
38+
echo "Created test repo at $TEST_REPO"
39+
40+
# Mock git that applies pushes to local origin refs and passes everything else
41+
# through. This lets the action's internal push actually move origin/feature2.
42+
cat > "$TEST_REPO/mock_git.sh" <<'MOCK_GIT'
43+
#!/bin/bash
44+
if [[ "$1" == "push" ]]; then
45+
shift
46+
remote=""
47+
specs=()
48+
for a in "$@"; do
49+
case "$a" in
50+
-*) ;; # ignore flags like --force-with-lease
51+
*) if [[ -z "$remote" ]]; then remote="$a"; else specs+=("$a"); fi ;;
52+
esac
53+
done
54+
for s in "${specs[@]}"; do
55+
if [[ "$s" == :* ]]; then
56+
git update-ref -d "refs/remotes/origin/${s#:}" 2>/dev/null || true
57+
else
58+
git update-ref "refs/remotes/origin/$s" "$s"
59+
fi
60+
done
61+
printf "Executing (mock push applied):" >&2
62+
printf " %q" git push "$@" >&2
63+
printf "\n" >&2
64+
exit 0
65+
fi
66+
exec git "$@"
67+
MOCK_GIT
68+
chmod +x "$TEST_REPO/mock_git.sh"
69+
70+
# Mock gh. Records the conflict comment and answers the queries the action makes.
71+
# Reads MOCK_SQUASH from the environment so it can report the parent PR's merge
72+
# commit during conflict-resolved mode.
73+
COMMENT_FILE="$TEST_REPO/conflict_comment.md"
74+
CONFLICT_LABEL="autorestack-needs-conflict-resolution"
75+
cat > "$TEST_REPO/mock_gh.sh" <<MOCK_GH
76+
#!/bin/bash
77+
CONFLICT_LABEL="$CONFLICT_LABEL"
78+
COMMENT_FILE="$COMMENT_FILE"
79+
MOCK_GH
80+
cat >> "$TEST_REPO/mock_gh.sh" <<'MOCK_GH'
81+
# Extract the value following a flag (e.g. --json) from the arg list.
82+
flag_value() {
83+
local want="$1"; shift
84+
for ((i=1; i<=$#; i++)); do
85+
if [[ "${!i}" == "$want" ]]; then local n=$((i+1)); echo "${!n}"; return; fi
86+
done
87+
}
88+
has_flag() {
89+
local want="$1"; shift
90+
for a in "$@"; do [[ "$a" == "$want" ]] && return 0; done
91+
return 1
92+
}
93+
94+
if [[ "$1" == "pr" && "$2" == "list" ]]; then
95+
base=$(flag_value --base "$@")
96+
head=$(flag_value --head "$@")
97+
json=$(flag_value --json "$@")
98+
if [[ -n "$head" ]]; then
99+
# Query about the merged parent PR (head=$OLD_BASE).
100+
case "$json" in
101+
baseRefName) echo "main" ;;
102+
mergeCommit) echo "$MOCK_SQUASH" ;;
103+
esac
104+
elif [[ "$base" == "feature1" ]]; then
105+
# INITIAL_TARGETS and has_sibling_conflicts both query --base feature1.
106+
echo "feature2"
107+
fi
108+
elif [[ "$1" == "pr" && "$2" == "view" ]]; then
109+
json=$(flag_value --json "$@")
110+
case "$json" in
111+
labels) echo "$CONFLICT_LABEL" ;;
112+
baseRefName) echo "feature1" ;;
113+
esac
114+
elif [[ "$1" == "pr" && "$2" == "comment" ]]; then
115+
cat > "$COMMENT_FILE"
116+
elif [[ "$1" == "label" || ( "$1" == "pr" && "$2" == "edit" ) ]]; then
117+
: # ignore label creation / edits
118+
else
119+
echo "Unknown gh command: $@" >&2
120+
exit 1
121+
fi
122+
MOCK_GH
123+
chmod +x "$TEST_REPO/mock_gh.sh"
124+
125+
# Replaying merges may create non-ff merge commits; never open an editor.
126+
export GIT_EDITOR=true
127+
128+
log_cmd git init -b main
129+
log_cmd git config user.email "test@example.com"
130+
log_cmd git config user.name "Test User"
131+
132+
for i in $(seq 1 12); do echo "line $i" >> file.txt; done
133+
log_cmd git add file.txt
134+
log_cmd git commit -m "Initial commit"
135+
simulate_push main
136+
137+
# feature1 (parent PR): first commit on line 2.
138+
log_cmd git checkout -b feature1
139+
sed -i '2s/.*/f1 commit 1/' file.txt
140+
log_cmd git add file.txt
141+
log_cmd git commit -m "f1: commit 1"
142+
simulate_push feature1
143+
144+
# feature2 (child PR): branched off feature1, changes line 9.
145+
log_cmd git checkout -b feature2
146+
sed -i '9s/.*/f2 content line 9/' file.txt
147+
log_cmd git add file.txt
148+
log_cmd git commit -m "f2: change line 9"
149+
simulate_push feature2
150+
ORIG_FEATURE2=$(git rev-parse feature2)
151+
152+
# feature1 advances AFTER feature2 branched (line 4): merging origin/feature1
153+
# into feature2 is clean and substantive.
154+
log_cmd git checkout feature1
155+
sed -i '4s/.*/f1 commit 2/' file.txt
156+
log_cmd git add file.txt
157+
log_cmd git commit -m "f1: commit 2"
158+
simulate_push feature1
159+
160+
# main advances on line 9 so the pre-squash target conflicts with feature2.
161+
log_cmd git checkout main
162+
sed -i '9s/.*/main conflicting line 9/' file.txt
163+
log_cmd git add file.txt
164+
log_cmd git commit -m "main: conflicting change on line 9"
165+
simulate_push main
166+
167+
# Squash-merge feature1 into main.
168+
log_cmd git checkout main
169+
log_cmd git merge --squash feature1
170+
log_cmd git commit -m "Squash merge feature1"
171+
SQUASH_COMMIT=$(git rev-parse HEAD)
172+
simulate_push main
173+
echo "Squash commit: $SQUASH_COMMIT"
174+
175+
############################################################################
176+
# Part A: squash-merge mode — clean step-1 merge, conflicting step-2 merge.
177+
############################################################################
178+
179+
log_cmd \
180+
env \
181+
SQUASH_COMMIT="$SQUASH_COMMIT" \
182+
MERGED_BRANCH=feature1 \
183+
TARGET_BRANCH=main \
184+
GH="$TEST_REPO/mock_gh.sh" \
185+
GIT="$TEST_REPO/mock_git.sh" \
186+
"$SCRIPT_DIR/../update-pr-stack.sh"
187+
188+
if [[ ! -f "$COMMENT_FILE" ]]; then
189+
echo "❌ No conflict comment was posted; scenario did not trigger a conflict"
190+
exit 1
191+
fi
192+
193+
echo ""
194+
echo "=== Conflict comment posted to the PR ==="
195+
cat "$COMMENT_FILE"
196+
echo ""
197+
198+
FAILED=0
199+
200+
# The action should have pushed the base-branch merge to origin/feature2.
201+
if log_cmd git merge-base --is-ancestor origin/feature1 origin/feature2; then
202+
echo "✅ origin/feature2 now contains origin/feature1 (action pushed the base merge)"
203+
else
204+
echo "❌ origin/feature2 does not contain origin/feature1; the base merge was not pushed"
205+
FAILED=1
206+
fi
207+
208+
# ...and that push must be a fast-forward on top of the original branch (so the
209+
# PR stays mergeable into its base and the synchronize event still fires).
210+
if log_cmd git merge-base --is-ancestor "$ORIG_FEATURE2" origin/feature2; then
211+
echo "✅ origin/feature2 is a descendant of the original branch (mergeable into its base)"
212+
else
213+
echo "❌ origin/feature2 is not a descendant of the original branch"
214+
FAILED=1
215+
fi
216+
217+
# The comment must not ask the user to redo the base merge the action did, and
218+
# must list the genuine conflict: the pre-squash target state (SQUASH_COMMIT~).
219+
if grep -q '^git merge origin/feature1' "$COMMENT_FILE"; then
220+
echo "❌ Comment asks the user to merge origin/feature1, which the action already did"
221+
FAILED=1
222+
else
223+
echo "✅ Comment omits the base merge the action already pushed"
224+
fi
225+
226+
if grep -q "^git merge $(git rev-parse "$SQUASH_COMMIT"~)" "$COMMENT_FILE"; then
227+
echo "✅ Comment asks the user to resolve the genuine conflict (pre-squash merge)"
228+
else
229+
echo "❌ Comment does not list the pre-squash merge as the conflict to resolve"
230+
FAILED=1
231+
fi
232+
233+
[[ "$FAILED" -ne 0 ]] && exit 1
234+
235+
############################################################################
236+
# Simulate the user resolving the conflict by following the comment, starting
237+
# from the branch as it is on the remote (which now includes the action's base
238+
# merge), resolving by keeping our (feature2) side.
239+
############################################################################
240+
241+
log_cmd git checkout feature2
242+
log_cmd git reset --hard origin/feature2
243+
244+
MERGE_CMDS=$(grep -E '^git merge' "$COMMENT_FILE" || true)
245+
if [[ -z "$MERGE_CMDS" ]]; then
246+
echo "❌ Comment lists no 'git merge' commands to follow"
247+
exit 1
248+
fi
249+
250+
while IFS= read -r cmd; do
251+
echo "Human runs: $cmd"
252+
if ! log_cmd bash -c "$cmd"; then
253+
echo "Resolving conflict by keeping our (feature2) side..."
254+
log_cmd git checkout --ours -- file.txt
255+
log_cmd git add file.txt
256+
log_cmd git commit --no-edit
257+
fi
258+
done <<< "$MERGE_CMDS"
259+
260+
simulate_push feature2
261+
262+
# The old base branch gets deleted during continuation, so capture its tip now.
263+
FEATURE1_TIP=$(git rev-parse feature1)
264+
265+
############################################################################
266+
# Part B: conflict-resolved mode — record the squash and push, then retarget.
267+
############################################################################
268+
269+
log_cmd \
270+
env \
271+
ACTION_MODE=conflict-resolved \
272+
PR_BRANCH=feature2 \
273+
MOCK_SQUASH="$SQUASH_COMMIT" \
274+
GH="$TEST_REPO/mock_gh.sh" \
275+
GIT="$TEST_REPO/mock_git.sh" \
276+
"$SCRIPT_DIR/../update-pr-stack.sh"
277+
278+
echo ""
279+
echo "=== feature2 after the action recorded the squash ==="
280+
log_cmd git log --graph --oneline --all
281+
echo ""
282+
283+
# The squash must now be recorded on origin/feature2 (-s ours), so the branch is
284+
# mergeable into the new target (main == SQUASH_COMMIT after the squash-merge).
285+
if log_cmd git merge-base --is-ancestor "$SQUASH_COMMIT" origin/feature2; then
286+
echo "✅ origin/feature2 records the squash commit (mergeable into main)"
287+
else
288+
echo "❌ origin/feature2 is missing the squash commit after continuation"
289+
FAILED=1
290+
fi
291+
292+
if log_cmd git merge-base --is-ancestor "$FEATURE1_TIP" origin/feature2; then
293+
echo "✅ origin/feature2 still includes the parent branch's advanced content"
294+
else
295+
echo "❌ origin/feature2 is missing the parent branch's content"
296+
FAILED=1
297+
fi
298+
299+
# main is now an ancestor of feature2, so merging feature2 into main is a clean
300+
# fast-forward: the diff against the new base is exactly feature2's own change.
301+
if log_cmd git merge-base --is-ancestor origin/main origin/feature2; then
302+
echo "✅ origin/main is an ancestor of origin/feature2 (clean diff against new base)"
303+
else
304+
echo "❌ origin/main is not an ancestor of origin/feature2"
305+
FAILED=1
306+
fi
307+
308+
[[ "$FAILED" -ne 0 ]] && exit 1
309+
310+
echo ""
311+
echo "All push-base-merge-on-conflict tests passed! 🎉"
312+
echo "Test repository remains at: $TEST_REPO for inspection"

update-pr-stack.sh

Lines changed: 37 additions & 4 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,17 @@ 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+
if [[ "$BASE_MERGE_CLEAN" == true ]]; then
102+
log_cmd git push origin "$BRANCH"
103+
fi
91104
{
92105
echo "### ⚠️ Automatic update blocked by merge conflicts"
93106
echo
@@ -177,15 +190,35 @@ continue_after_resolution() {
177190
OLD_BASE=$(gh pr view "$PR_BRANCH" --json baseRefName --jq '.baseRefName')
178191
echo "Current base branch: $OLD_BASE"
179192

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

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

190223
# Remove the conflict label
191224
log_cmd gh pr edit "$PR_BRANCH" --remove-label "$CONFLICT_LABEL"

0 commit comments

Comments
 (0)