Skip to content

Commit 63f61f8

Browse files
Phlogistiqueclaude
andcommitted
Push the base-branch merge before asking for conflict resolution
When updating a descendant after a parent squash-merge, the parent-branch merge (step 1) is clean far more often than the pre-squash target merge (step 2). When step 1 is clean and only step 2 conflicts, push the step-1 result to the branch before posting the comment and label. The user then resolves only the genuine conflict (and records the squash with -s ours) on top of a branch that already contains the base merge, instead of being handed the whole sequence to redo. Order matters: GitHub silently skips pull_request workflows on a PR that conflicts with its base, so pushing the base merge (which keeps the head a descendant of its base) before adding the label is what keeps the resume trigger alive. If step 1 itself conflicts the action can't pre-merge, so it falls back to handing over the full recipe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 908602b commit 63f61f8

2 files changed

Lines changed: 305 additions & 51 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
#!/bin/bash
2+
#
3+
# Test for the "push the base-branch merge before asking for help" behaviour.
4+
#
5+
# When updating a descendant branch after a parent squash-merge, the parent
6+
# branch is merged first (step 1), then the target's pre-squash state (step 2),
7+
# then the squash is recorded with -s ours (step 0). When step 1 is clean but
8+
# step 2 conflicts, the action pushes the step-1 result to the branch BEFORE
9+
# posting the comment and label. That way:
10+
# - the user resolves on top of the base merge (they can't strand the PR by
11+
# resolving in the wrong order), and
12+
# - the head stays a descendant of its base, so the PR stays mergeable and the
13+
# synchronize event that resumes the action still fires. GitHub does not run
14+
# pull_request workflows on a PR that conflicts with its base.
15+
#
16+
# This test reproduces that topology and asserts the action pushed the base
17+
# merge, that the comment omits the (already-done) base merge, and that
18+
# following the comment leaves the branch mergeable.
19+
#
20+
# Runs fully offline. Unlike the other unit tests, its mock git APPLIES pushes
21+
# to local origin refs, so the simulated user picks up what the action pushed.
22+
23+
set -e
24+
25+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26+
source "$SCRIPT_DIR/../command_utils.sh"
27+
28+
simulate_push() {
29+
log_cmd git update-ref "refs/remotes/origin/$1" "$1"
30+
}
31+
32+
TEST_REPO=$(mktemp -d)
33+
cd "$TEST_REPO"
34+
echo "Created test repo at $TEST_REPO"
35+
36+
# Mock git that applies pushes to local origin refs and passes everything else
37+
# through. This lets the action's internal push actually move origin/feature2.
38+
cat > "$TEST_REPO/mock_git.sh" <<'MOCK_GIT'
39+
#!/bin/bash
40+
if [[ "$1" == "push" ]]; then
41+
shift
42+
remote=""
43+
specs=()
44+
for a in "$@"; do
45+
case "$a" in
46+
-*) ;; # ignore flags like --force-with-lease
47+
*) if [[ -z "$remote" ]]; then remote="$a"; else specs+=("$a"); fi ;;
48+
esac
49+
done
50+
for s in "${specs[@]}"; do
51+
if [[ "$s" == :* ]]; then
52+
git update-ref -d "refs/remotes/origin/${s#:}" 2>/dev/null || true
53+
else
54+
git update-ref "refs/remotes/origin/$s" "$s"
55+
fi
56+
done
57+
printf "Executing (mock push applied):" >&2
58+
printf " %q" git push "$@" >&2
59+
printf "\n" >&2
60+
exit 0
61+
fi
62+
exec git "$@"
63+
MOCK_GIT
64+
chmod +x "$TEST_REPO/mock_git.sh"
65+
66+
# Mock gh that records the conflict comment and otherwise stays quiet.
67+
COMMENT_FILE="$TEST_REPO/conflict_comment.md"
68+
cat > "$TEST_REPO/mock_gh.sh" <<MOCK_GH
69+
#!/bin/bash
70+
if [[ "\$1" == "pr" && "\$2" == "list" ]]; then
71+
base=""
72+
for ((i=1; i<=\$#; i++)); do
73+
if [[ "\${!i}" == "--base" ]]; then next=\$((i+1)); base="\${!next}"; fi
74+
done
75+
[[ "\$base" == "feature1" ]] && echo "feature2"
76+
elif [[ "\$1" == "pr" && "\$2" == "comment" ]]; then
77+
cat > "$COMMENT_FILE"
78+
elif [[ "\$1" == "label" || ( "\$1" == "pr" && "\$2" == "edit" ) ]]; then
79+
: # ignore label creation / edits
80+
else
81+
echo "Unknown gh command: \$@" >&2
82+
exit 1
83+
fi
84+
MOCK_GH
85+
chmod +x "$TEST_REPO/mock_gh.sh"
86+
87+
# Replaying merges may create non-ff merge commits; never open an editor.
88+
export GIT_EDITOR=true
89+
90+
log_cmd git init -b main
91+
log_cmd git config user.email "test@example.com"
92+
log_cmd git config user.name "Test User"
93+
94+
for i in $(seq 1 12); do echo "line $i" >> file.txt; done
95+
log_cmd git add file.txt
96+
log_cmd git commit -m "Initial commit"
97+
simulate_push main
98+
99+
# feature1 (parent PR): first commit on line 2.
100+
log_cmd git checkout -b feature1
101+
sed -i '2s/.*/f1 commit 1/' file.txt
102+
log_cmd git add file.txt
103+
log_cmd git commit -m "f1: commit 1"
104+
simulate_push feature1
105+
106+
# feature2 (child PR): branched off feature1, changes line 9.
107+
log_cmd git checkout -b feature2
108+
sed -i '9s/.*/f2 content line 9/' file.txt
109+
log_cmd git add file.txt
110+
log_cmd git commit -m "f2: change line 9"
111+
simulate_push feature2
112+
ORIG_FEATURE2=$(git rev-parse feature2)
113+
114+
# feature1 advances AFTER feature2 branched (line 4): merging origin/feature1
115+
# into feature2 is clean and substantive.
116+
log_cmd git checkout feature1
117+
sed -i '4s/.*/f1 commit 2/' file.txt
118+
log_cmd git add file.txt
119+
log_cmd git commit -m "f1: commit 2"
120+
simulate_push feature1
121+
122+
# main advances on line 9 so the pre-squash target conflicts with feature2.
123+
log_cmd git checkout main
124+
sed -i '9s/.*/main conflicting line 9/' file.txt
125+
log_cmd git add file.txt
126+
log_cmd git commit -m "main: conflicting change on line 9"
127+
simulate_push main
128+
129+
# Squash-merge feature1 into main.
130+
log_cmd git checkout main
131+
log_cmd git merge --squash feature1
132+
log_cmd git commit -m "Squash merge feature1"
133+
SQUASH_COMMIT=$(git rev-parse HEAD)
134+
simulate_push main
135+
echo "Squash commit: $SQUASH_COMMIT"
136+
137+
# Run the action: clean step-1 merge, conflicting step-2 merge.
138+
log_cmd \
139+
env \
140+
SQUASH_COMMIT="$SQUASH_COMMIT" \
141+
MERGED_BRANCH=feature1 \
142+
TARGET_BRANCH=main \
143+
GH="$TEST_REPO/mock_gh.sh" \
144+
GIT="$TEST_REPO/mock_git.sh" \
145+
"$SCRIPT_DIR/../update-pr-stack.sh"
146+
147+
if [[ ! -f "$COMMENT_FILE" ]]; then
148+
echo "❌ No conflict comment was posted; scenario did not trigger a conflict"
149+
exit 1
150+
fi
151+
152+
echo ""
153+
echo "=== Conflict comment posted to the PR ==="
154+
cat "$COMMENT_FILE"
155+
echo ""
156+
157+
FAILED=0
158+
159+
# The action should have pushed the base-branch merge to origin/feature2.
160+
if log_cmd git merge-base --is-ancestor origin/feature1 origin/feature2; then
161+
echo "✅ origin/feature2 now contains origin/feature1 (action pushed the base merge)"
162+
else
163+
echo "❌ origin/feature2 does not contain origin/feature1; the base merge was not pushed"
164+
FAILED=1
165+
fi
166+
167+
# ...and that push must be a fast-forward on top of the original branch (so the
168+
# PR stays mergeable into its base and the synchronize event still fires).
169+
if log_cmd git merge-base --is-ancestor "$ORIG_FEATURE2" origin/feature2; then
170+
echo "✅ origin/feature2 is a descendant of the original branch (mergeable into its base)"
171+
else
172+
echo "❌ origin/feature2 is not a descendant of the original branch"
173+
FAILED=1
174+
fi
175+
176+
# The comment should NOT ask the user to redo the base merge the action did.
177+
if grep -q '^git merge origin/feature1' "$COMMENT_FILE"; then
178+
echo "❌ Comment still asks the user to merge origin/feature1, which the action already did"
179+
FAILED=1
180+
else
181+
echo "✅ Comment omits the base merge the action already pushed"
182+
fi
183+
184+
[[ "$FAILED" -ne 0 ]] && exit 1
185+
186+
# Simulate the user: start from the branch as it is on the remote (which now
187+
# includes the action's base merge), then run the comment's `git merge` lines,
188+
# resolving any conflict by keeping our side.
189+
log_cmd git checkout feature2
190+
log_cmd git reset --hard origin/feature2
191+
192+
MERGE_CMDS=$(grep -E '^git merge' "$COMMENT_FILE" || true)
193+
if [[ -z "$MERGE_CMDS" ]]; then
194+
echo "❌ Comment lists no 'git merge' commands to follow"
195+
exit 1
196+
fi
197+
198+
while IFS= read -r cmd; do
199+
echo "Human runs: $cmd"
200+
if ! log_cmd bash -c "$cmd"; then
201+
echo "Resolving conflict by keeping our (feature2) side..."
202+
log_cmd git checkout --ours -- file.txt
203+
log_cmd git add file.txt
204+
log_cmd git commit --no-edit
205+
fi
206+
done <<< "$MERGE_CMDS"
207+
208+
simulate_push feature2
209+
210+
echo ""
211+
echo "=== feature2 after following the comment ==="
212+
log_cmd git log --graph --oneline --all
213+
echo ""
214+
215+
# Following the comment must leave the branch mergeable into its new base (main):
216+
# the squash commit and the parent's advanced content are both present.
217+
if log_cmd git merge-base --is-ancestor "$SQUASH_COMMIT" feature2; then
218+
echo "✅ feature2 includes the squash commit (mergeable into main)"
219+
else
220+
echo "❌ feature2 is missing the squash commit after following the comment"
221+
FAILED=1
222+
fi
223+
224+
if log_cmd git merge-base --is-ancestor origin/feature1 feature2; then
225+
echo "✅ feature2 includes the parent branch's advanced content"
226+
else
227+
echo "❌ feature2 is missing origin/feature1's content"
228+
FAILED=1
229+
fi
230+
231+
[[ "$FAILED" -ne 0 ]] && exit 1
232+
233+
echo ""
234+
echo "All push-base-merge-on-conflict tests passed! 🎉"
235+
echo "Test repository remains at: $TEST_REPO for inspection"

update-pr-stack.sh

Lines changed: 70 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,50 @@ 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
48+
# Post the manual-resolution comment and add the conflict label.
49+
#
50+
# $2 ("true"/"false") says whether we already pushed the parent-branch merge.
51+
# When true, the branch the user fetches already contains it, so the recipe
52+
# skips that step and the user only resolves the genuine conflict.
53+
#
54+
# The recipe is otherwise always the same sequence the clean path runs, so we
55+
# print it verbatim rather than tracking which merge failed.
56+
report_conflict() {
57+
local BRANCH="$1"
58+
local BASE_ALREADY_MERGED="$2"
59+
{
60+
echo "### ⚠️ Automatic update blocked by a merge conflict"
61+
echo
62+
if [[ "$BASE_ALREADY_MERGED" == "true" ]]; then
63+
echo "I merged the base branch into this pull request, but hit a conflict bringing in the squashed changes from the target branch. Please finish by hand:"
64+
else
65+
echo "I hit a merge conflict while updating this pull request after its base branch was squash-merged. Please resolve it by hand:"
66+
fi
67+
echo
68+
echo "#### How to resolve"
69+
echo
70+
echo "Run these in order, resolving any conflicts as they come up:"
71+
echo '```bash'
72+
echo "git fetch origin"
73+
echo "git switch $BRANCH"
74+
if [[ "$BASE_ALREADY_MERGED" == "true" ]]; then
75+
echo "git pull --ff-only # pick up the base-branch merge I pushed"
76+
else
77+
echo "git merge origin/$MERGED_BRANCH"
78+
fi
79+
echo "git merge $(git rev-parse SQUASH_COMMIT~)"
80+
echo "# record the squash commit as merged without re-applying its changes:"
81+
echo "git merge -s ours $(git rev-parse SQUASH_COMMIT)"
82+
echo "git push"
83+
echo '```'
84+
echo
85+
echo 'If a merge stops with conflicts, fix them (e.g. with `git mergetool`), `git commit`, then run the next command.'
86+
echo
87+
echo "Once you push, this action will resume and finish updating this pull request."
88+
} | log_cmd gh pr comment "$BRANCH" -F -
89+
# Create the label if it doesn't exist, then add it to the PR
90+
gh label create "$CONFLICT_LABEL" --description "PR needs manual conflict resolution" --color "d73a4a" 2>/dev/null || true
91+
log_cmd gh pr edit "$BRANCH" --add-label "$CONFLICT_LABEL"
5792
}
5893

5994
update_direct_target() {
@@ -72,61 +107,45 @@ update_direct_target() {
72107

73108
echo "Updating direct target $BRANCH (from $MERGED_BRANCH to $BASE_BRANCH)"
74109

75-
CONFLICTS=()
76110
log_cmd git update-ref BEFORE_MERGE HEAD
111+
112+
# Merge the parent branch. If this conflicts we can make no progress without
113+
# the user, so hand off the whole recipe.
77114
if ! log_cmd git merge --no-edit "origin/$MERGED_BRANCH"; then
78-
CONFLICTS+=("origin/$MERGED_BRANCH")
79115
log_cmd git merge --abort
116+
report_conflict "$BRANCH" false
117+
return 1
80118
fi
81-
# Only try merging the pre-squash target state if it's not already
82-
# included in the merged branch — otherwise the first merge covers it.
119+
120+
# The parent-branch merge is clean. Merge the target's pre-squash state
121+
# (skipped when the merged branch already contains it). If THAT conflicts,
122+
# push the clean parent-branch merge first so the user resolves on top of
123+
# it: that keeps the branch a descendant of its base, so the PR stays
124+
# mergeable and the synchronize event that resumes this action still fires.
125+
# GitHub does not run pull_request workflows on a PR that conflicts with its
126+
# base, so leaving the branch in that state would strand it for good.
83127
if ! git merge-base --is-ancestor SQUASH_COMMIT~ "origin/$MERGED_BRANCH"; then
84128
if ! log_cmd git merge --no-edit SQUASH_COMMIT~; then
85-
CONFLICTS+=( "$(git rev-parse SQUASH_COMMIT~)" )
86129
log_cmd git merge --abort
130+
log_cmd git push origin "$BRANCH"
131+
report_conflict "$BRANCH" true
132+
return 1
87133
fi
88134
fi
89135

90-
if [[ "${#CONFLICTS[@]}" -gt 0 ]]; then
91-
{
92-
echo "### ⚠️ Automatic update blocked by merge conflicts"
93-
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."
97-
echo
98-
echo "#### How to resolve"
99-
echo '```bash'
100-
echo "git fetch origin"
101-
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
109-
echo "git push"
110-
echo '```'
111-
echo
112-
echo "Once you push, this action will resume and finish updating this pull request."
113-
} | log_cmd gh pr comment "$BRANCH" -F -
114-
# Create the label if it doesn't exist, then add it to the PR
115-
gh label create "$CONFLICT_LABEL" --description "PR needs manual conflict resolution" --color "d73a4a" 2>/dev/null || true
116-
log_cmd gh pr edit "$BRANCH" --add-label "$CONFLICT_LABEL"
117-
return 1
118-
else
119-
log_cmd git merge --no-edit -s ours SQUASH_COMMIT
120-
log_cmd git update-ref MERGE_RESULT "HEAD^{tree}"
121-
COMMIT_MSG="Merge updates from $BASE_BRANCH and squash commit"
122-
if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then
123-
COMMIT_MSG="$COMMIT_MSG
136+
# Record the squash commit as merged without re-applying its tree, then bake
137+
# the result into a synthetic commit whose parents preserve the branch's
138+
# history.
139+
log_cmd git merge --no-edit -s ours SQUASH_COMMIT
140+
log_cmd git update-ref MERGE_RESULT "HEAD^{tree}"
141+
COMMIT_MSG="Merge updates from $BASE_BRANCH and squash commit"
142+
if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then
143+
COMMIT_MSG="$COMMIT_MSG
124144
125145
See $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
126-
fi
127-
CUSTOM_COMMIT=$(log_cmd git commit-tree MERGE_RESULT -p BEFORE_MERGE -p "origin/$MERGED_BRANCH" -p SQUASH_COMMIT -m "$COMMIT_MSG")
128-
log_cmd git reset --hard "$CUSTOM_COMMIT"
129146
fi
147+
CUSTOM_COMMIT=$(log_cmd git commit-tree MERGE_RESULT -p BEFORE_MERGE -p "origin/$MERGED_BRANCH" -p SQUASH_COMMIT -m "$COMMIT_MSG")
148+
log_cmd git reset --hard "$CUSTOM_COMMIT"
130149

131150
return 0
132151
}

0 commit comments

Comments
 (0)