From 562ba648d01f08897b378785c81d748abb645cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Fri, 29 May 2026 16:44:32 +0200 Subject: [PATCH 01/17] Push the base-branch merge before asking for conflict resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- tests/test_push_base_merge_on_conflict.sh | 312 ++++++++++++++++++++++ update-pr-stack.sh | 41 ++- 2 files changed, 349 insertions(+), 4 deletions(-) create mode 100755 tests/test_push_base_merge_on_conflict.sh diff --git a/tests/test_push_base_merge_on_conflict.sh b/tests/test_push_base_merge_on_conflict.sh new file mode 100755 index 0000000..cb8725f --- /dev/null +++ b/tests/test_push_base_merge_on_conflict.sh @@ -0,0 +1,312 @@ +#!/bin/bash +# +# Test for the "bot does the clean work, user resolves only the genuine +# conflict" behaviour, in two parts. +# +# When updating a descendant branch after a parent squash-merge, the parent +# branch is merged first (step 1), then the target's pre-squash state (step 2), +# then the squash is recorded with -s ours (step 0). +# +# Part A (squash-merge mode): step 1 is clean but step 2 conflicts. The action +# pushes the step-1 result to the branch BEFORE posting the comment and label, +# so the head stays a descendant of its base. That keeps the PR mergeable and +# lets the synchronize event that resumes the action still fire (GitHub does not +# run pull_request workflows on a PR conflicting with its base). The posted +# comment is unchanged: it lists only the genuine conflict (the pre-squash +# merge), because step 1 was clean and never entered the conflict list. +# +# Part B (conflict-resolved mode): after the user resolves step 2 and pushes, +# continue_after_resolution records the squash with -s ours and pushes BEFORE +# retargeting, so the branch ends up mergeable into the new target. The squash +# commit is reconstructed from the merged parent PR (mergeCommit), since the +# synchronize payload is the child PR and SQUASH_COMMIT is not in the env. +# +# Runs fully offline. Unlike the other unit tests, its mock git APPLIES pushes +# to local origin refs, so the simulated user picks up what the action pushed. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../command_utils.sh" + +simulate_push() { + log_cmd git update-ref "refs/remotes/origin/$1" "$1" +} + +TEST_REPO=$(mktemp -d) +cd "$TEST_REPO" +echo "Created test repo at $TEST_REPO" + +# Mock git that applies pushes to local origin refs and passes everything else +# through. This lets the action's internal push actually move origin/feature2. +cat > "$TEST_REPO/mock_git.sh" <<'MOCK_GIT' +#!/bin/bash +if [[ "$1" == "push" ]]; then + shift + remote="" + specs=() + for a in "$@"; do + case "$a" in + -*) ;; # ignore flags like --force-with-lease + *) if [[ -z "$remote" ]]; then remote="$a"; else specs+=("$a"); fi ;; + esac + done + for s in "${specs[@]}"; do + if [[ "$s" == :* ]]; then + git update-ref -d "refs/remotes/origin/${s#:}" 2>/dev/null || true + else + git update-ref "refs/remotes/origin/$s" "$s" + fi + done + printf "Executing (mock push applied):" >&2 + printf " %q" git push "$@" >&2 + printf "\n" >&2 + exit 0 +fi +exec git "$@" +MOCK_GIT +chmod +x "$TEST_REPO/mock_git.sh" + +# Mock gh. Records the conflict comment and answers the queries the action makes. +# Reads MOCK_SQUASH from the environment so it can report the parent PR's merge +# commit during conflict-resolved mode. +COMMENT_FILE="$TEST_REPO/conflict_comment.md" +CONFLICT_LABEL="autorestack-needs-conflict-resolution" +cat > "$TEST_REPO/mock_gh.sh" <> "$TEST_REPO/mock_gh.sh" <<'MOCK_GH' +# Extract the value following a flag (e.g. --json) from the arg list. +flag_value() { + local want="$1"; shift + for ((i=1; i<=$#; i++)); do + if [[ "${!i}" == "$want" ]]; then local n=$((i+1)); echo "${!n}"; return; fi + done +} +has_flag() { + local want="$1"; shift + for a in "$@"; do [[ "$a" == "$want" ]] && return 0; done + return 1 +} + +if [[ "$1" == "pr" && "$2" == "list" ]]; then + base=$(flag_value --base "$@") + head=$(flag_value --head "$@") + json=$(flag_value --json "$@") + if [[ -n "$head" ]]; then + # Query about the merged parent PR (head=$OLD_BASE). + case "$json" in + baseRefName) echo "main" ;; + mergeCommit) echo "$MOCK_SQUASH" ;; + esac + elif [[ "$base" == "feature1" ]]; then + # INITIAL_TARGETS and has_sibling_conflicts both query --base feature1. + echo "feature2" + fi +elif [[ "$1" == "pr" && "$2" == "view" ]]; then + json=$(flag_value --json "$@") + case "$json" in + labels) echo "$CONFLICT_LABEL" ;; + baseRefName) echo "feature1" ;; + esac +elif [[ "$1" == "pr" && "$2" == "comment" ]]; then + cat > "$COMMENT_FILE" +elif [[ "$1" == "label" || ( "$1" == "pr" && "$2" == "edit" ) ]]; then + : # ignore label creation / edits +else + echo "Unknown gh command: $@" >&2 + exit 1 +fi +MOCK_GH +chmod +x "$TEST_REPO/mock_gh.sh" + +# Replaying merges may create non-ff merge commits; never open an editor. +export GIT_EDITOR=true + +log_cmd git init -b main +log_cmd git config user.email "test@example.com" +log_cmd git config user.name "Test User" + +for i in $(seq 1 12); do echo "line $i" >> file.txt; done +log_cmd git add file.txt +log_cmd git commit -m "Initial commit" +simulate_push main + +# feature1 (parent PR): first commit on line 2. +log_cmd git checkout -b feature1 +sed -i '2s/.*/f1 commit 1/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "f1: commit 1" +simulate_push feature1 + +# feature2 (child PR): branched off feature1, changes line 9. +log_cmd git checkout -b feature2 +sed -i '9s/.*/f2 content line 9/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "f2: change line 9" +simulate_push feature2 +ORIG_FEATURE2=$(git rev-parse feature2) + +# feature1 advances AFTER feature2 branched (line 4): merging origin/feature1 +# into feature2 is clean and substantive. +log_cmd git checkout feature1 +sed -i '4s/.*/f1 commit 2/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "f1: commit 2" +simulate_push feature1 + +# main advances on line 9 so the pre-squash target conflicts with feature2. +log_cmd git checkout main +sed -i '9s/.*/main conflicting line 9/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "main: conflicting change on line 9" +simulate_push main + +# Squash-merge feature1 into main. +log_cmd git checkout main +log_cmd git merge --squash feature1 +log_cmd git commit -m "Squash merge feature1" +SQUASH_COMMIT=$(git rev-parse HEAD) +simulate_push main +echo "Squash commit: $SQUASH_COMMIT" + +############################################################################ +# Part A: squash-merge mode — clean step-1 merge, conflicting step-2 merge. +############################################################################ + +log_cmd \ + env \ + SQUASH_COMMIT="$SQUASH_COMMIT" \ + MERGED_BRANCH=feature1 \ + TARGET_BRANCH=main \ + GH="$TEST_REPO/mock_gh.sh" \ + GIT="$TEST_REPO/mock_git.sh" \ + "$SCRIPT_DIR/../update-pr-stack.sh" + +if [[ ! -f "$COMMENT_FILE" ]]; then + echo "❌ No conflict comment was posted; scenario did not trigger a conflict" + exit 1 +fi + +echo "" +echo "=== Conflict comment posted to the PR ===" +cat "$COMMENT_FILE" +echo "" + +FAILED=0 + +# The action should have pushed the base-branch merge to origin/feature2. +if log_cmd git merge-base --is-ancestor origin/feature1 origin/feature2; then + echo "✅ origin/feature2 now contains origin/feature1 (action pushed the base merge)" +else + echo "❌ origin/feature2 does not contain origin/feature1; the base merge was not pushed" + FAILED=1 +fi + +# ...and that push must be a fast-forward on top of the original branch (so the +# PR stays mergeable into its base and the synchronize event still fires). +if log_cmd git merge-base --is-ancestor "$ORIG_FEATURE2" origin/feature2; then + echo "✅ origin/feature2 is a descendant of the original branch (mergeable into its base)" +else + echo "❌ origin/feature2 is not a descendant of the original branch" + FAILED=1 +fi + +# The comment must not ask the user to redo the base merge the action did, and +# must list the genuine conflict: the pre-squash target state (SQUASH_COMMIT~). +if grep -q '^git merge origin/feature1' "$COMMENT_FILE"; then + echo "❌ Comment asks the user to merge origin/feature1, which the action already did" + FAILED=1 +else + echo "✅ Comment omits the base merge the action already pushed" +fi + +if grep -q "^git merge $(git rev-parse "$SQUASH_COMMIT"~)" "$COMMENT_FILE"; then + echo "✅ Comment asks the user to resolve the genuine conflict (pre-squash merge)" +else + echo "❌ Comment does not list the pre-squash merge as the conflict to resolve" + FAILED=1 +fi + +[[ "$FAILED" -ne 0 ]] && exit 1 + +############################################################################ +# Simulate the user resolving the conflict by following the comment, starting +# from the branch as it is on the remote (which now includes the action's base +# merge), resolving by keeping our (feature2) side. +############################################################################ + +log_cmd git checkout feature2 +log_cmd git reset --hard origin/feature2 + +MERGE_CMDS=$(grep -E '^git merge' "$COMMENT_FILE" || true) +if [[ -z "$MERGE_CMDS" ]]; then + echo "❌ Comment lists no 'git merge' commands to follow" + exit 1 +fi + +while IFS= read -r cmd; do + echo "Human runs: $cmd" + if ! log_cmd bash -c "$cmd"; then + echo "Resolving conflict by keeping our (feature2) side..." + log_cmd git checkout --ours -- file.txt + log_cmd git add file.txt + log_cmd git commit --no-edit + fi +done <<< "$MERGE_CMDS" + +simulate_push feature2 + +# The old base branch gets deleted during continuation, so capture its tip now. +FEATURE1_TIP=$(git rev-parse feature1) + +############################################################################ +# Part B: conflict-resolved mode — record the squash and push, then retarget. +############################################################################ + +log_cmd \ + env \ + ACTION_MODE=conflict-resolved \ + PR_BRANCH=feature2 \ + MOCK_SQUASH="$SQUASH_COMMIT" \ + GH="$TEST_REPO/mock_gh.sh" \ + GIT="$TEST_REPO/mock_git.sh" \ + "$SCRIPT_DIR/../update-pr-stack.sh" + +echo "" +echo "=== feature2 after the action recorded the squash ===" +log_cmd git log --graph --oneline --all +echo "" + +# The squash must now be recorded on origin/feature2 (-s ours), so the branch is +# mergeable into the new target (main == SQUASH_COMMIT after the squash-merge). +if log_cmd git merge-base --is-ancestor "$SQUASH_COMMIT" origin/feature2; then + echo "✅ origin/feature2 records the squash commit (mergeable into main)" +else + echo "❌ origin/feature2 is missing the squash commit after continuation" + FAILED=1 +fi + +if log_cmd git merge-base --is-ancestor "$FEATURE1_TIP" origin/feature2; then + echo "✅ origin/feature2 still includes the parent branch's advanced content" +else + echo "❌ origin/feature2 is missing the parent branch's content" + FAILED=1 +fi + +# main is now an ancestor of feature2, so merging feature2 into main is a clean +# fast-forward: the diff against the new base is exactly feature2's own change. +if log_cmd git merge-base --is-ancestor origin/main origin/feature2; then + echo "✅ origin/main is an ancestor of origin/feature2 (clean diff against new base)" +else + echo "❌ origin/main is not an ancestor of origin/feature2" + FAILED=1 +fi + +[[ "$FAILED" -ne 0 ]] && exit 1 + +echo "" +echo "All push-base-merge-on-conflict tests passed! 🎉" +echo "Test repository remains at: $TEST_REPO for inspection" diff --git a/update-pr-stack.sh b/update-pr-stack.sh index ab4d39a..ba7f36f 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -73,9 +73,11 @@ update_direct_target() { echo "Updating direct target $BRANCH (from $MERGED_BRANCH to $BASE_BRANCH)" CONFLICTS=() + local BASE_MERGE_CLEAN=true log_cmd git update-ref BEFORE_MERGE HEAD if ! log_cmd git merge --no-edit "origin/$MERGED_BRANCH"; then CONFLICTS+=("origin/$MERGED_BRANCH") + BASE_MERGE_CLEAN=false log_cmd git merge --abort fi # Only try merging the pre-squash target state if it's not already @@ -88,6 +90,17 @@ update_direct_target() { fi if [[ "${#CONFLICTS[@]}" -gt 0 ]]; then + # When the base-branch merge was clean, HEAD now holds it (the + # conflicting pre-squash merge was aborted back to it). Push it before + # asking for help: the user resolves on top of it, and the head stays a + # descendant of its base so the PR stays mergeable and the synchronize + # event that resumes this action still fires. GitHub does not run + # pull_request workflows on a PR conflicting with its base, which would + # otherwise strand the branch for good. If the base merge itself + # conflicted we have nothing safe to pre-push, so we just ask for help. + if [[ "$BASE_MERGE_CLEAN" == true ]]; then + log_cmd git push origin "$BRANCH" + fi { echo "### ⚠️ Automatic update blocked by merge conflicts" echo @@ -177,15 +190,35 @@ continue_after_resolution() { OLD_BASE=$(gh pr view "$PR_BRANCH" --json baseRefName --jq '.baseRefName') echo "Current base branch: $OLD_BASE" - # Find where the old base was merged to (the new target) - local NEW_TARGET + # The synchronize payload is the child PR, so SQUASH_COMMIT / MERGED_BRANCH / + # TARGET_BRANCH from the original squash-merge run are not in the environment. + # Reconstruct them from the merged parent PR: OLD_BASE is the parent branch, + # and the merged PR whose head is OLD_BASE gives the new target (its base) and + # the squash commit (its merge commit). + local NEW_TARGET SQUASH_HASH NEW_TARGET=$(gh pr list --head "$OLD_BASE" --state merged --json baseRefName --jq '.[0].baseRefName') + SQUASH_HASH=$(gh pr list --head "$OLD_BASE" --state merged --json mergeCommit --jq '.[0].mergeCommit.oid') - if [[ -z "$NEW_TARGET" ]]; then + if [[ -z "$NEW_TARGET" || -z "$SQUASH_HASH" ]]; then echo "⚠️ Could not find where '$OLD_BASE' was merged to; skipping base branch and deletion updates" # Don't update base or delete old branch - leave things as they are else - echo "Old base '$OLD_BASE' was merged to '$NEW_TARGET'" + echo "Old base '$OLD_BASE' was merged to '$NEW_TARGET' as $SQUASH_HASH" + + # The squash-merge run pushed the base merge and asked the user to resolve + # the pre-squash merge, but it never recorded the squash itself. Finish + # that now: re-run the same merge sequence as the squash-merge path. With + # the user's resolution in place the base merge and pre-squash merge are + # no-ops; only the "-s ours" squash record gets applied, keeping the diff + # against the new base clean. has_squash_commit makes this idempotent. + log_cmd git update-ref SQUASH_COMMIT "$SQUASH_HASH" + MERGED_BRANCH="$OLD_BASE" + TARGET_BRANCH="$NEW_TARGET" + if ! update_direct_target "$PR_BRANCH" "$NEW_TARGET"; then + echo "⚠️ Unexpected conflict while recording the squash on '$PR_BRANCH'; leaving it for manual handling" + return 1 + fi + log_cmd git push origin "$PR_BRANCH" # Remove the conflict label log_cmd gh pr edit "$PR_BRANCH" --remove-label "$CONFLICT_LABEL" From 0d7933f8b2fad487438269c43fb9350cc1959913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Fri, 29 May 2026 17:59:01 +0200 Subject: [PATCH 02/17] Re-sync the local branch in the conflict comment; fold coverage into e2e Pushing the base merge before commenting leaves the user's local branch behind origin, so following the comment verbatim resolved on a stale head and the final push was rejected as non-fast-forward. The recipe now fast-forwards with `git pull --ff-only origin ` first. The standalone offline test is dropped. The existing e2e conflict scenario already triggers the clean-base-merge / conflicting-pre-squash case, so it now asserts 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 the squash gets recorded so the branch ends up mergeable into the new target. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_e2e.sh | 96 ++++--- tests/test_push_base_merge_on_conflict.sh | 312 ---------------------- update-pr-stack.sh | 9 + 3 files changed, 74 insertions(+), 343 deletions(-) delete mode 100755 tests/test_push_base_merge_on_conflict.sh diff --git a/tests/test_e2e.sh b/tests/test_e2e.sh index 94e515d..f177a3e 100755 --- a/tests/test_e2e.sh +++ b/tests/test_e2e.sh @@ -865,45 +865,79 @@ else exit 1 fi -# Verify feature3 branch was NOT pushed with conflicts (check its head SHA) +# The base-branch merge (feature2 into feature3) is clean here; only the +# pre-squash merge conflicts. The action pushes that clean base merge before +# commenting, so feature3 stays a descendant of its base (mergeable, so the +# synchronize event that resumes the action keeps firing). Verify the push +# happened and that it only fast-forwarded on top of the pre-conflict head. REMOTE_FEATURE3_SHA_BEFORE_RESOLVE=$(log_cmd git rev-parse "refs/remotes/origin/feature3") -# The action failed the merge locally, so it shouldn't have pushed feature3. -# The remote SHA should still be the one from step 8 ("Conflict: Modify line 3 on feature3"). -EXPECTED_FEATURE3_SHA_BEFORE_RESOLVE=$FEATURE3_CONFLICT_COMMIT_SHA -if [[ "$REMOTE_FEATURE3_SHA_BEFORE_RESOLVE" == "$EXPECTED_FEATURE3_SHA_BEFORE_RESOLVE" ]]; then - echo >&2 "✅ Verification Passed: Remote feature3 branch was not updated by the action due to conflict." +if log_cmd git merge-base --is-ancestor "$FEATURE3_CONFLICT_COMMIT_SHA" "refs/remotes/origin/feature3" \ + && [[ "$REMOTE_FEATURE3_SHA_BEFORE_RESOLVE" != "$FEATURE3_CONFLICT_COMMIT_SHA" ]]; then + echo >&2 "✅ Verification Passed: action pushed the clean base merge on top of feature3 (still a descendant of its pre-conflict head)." else - 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)." - exit 1 + 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." + log_cmd git log --graph --oneline origin/feature3 origin/feature2 + exit 1 +fi +# The base merge brought feature2's updated state into feature3... +if log_cmd git merge-base --is-ancestor "refs/remotes/origin/feature2" "refs/remotes/origin/feature3"; then + echo >&2 "✅ Verification Passed: origin/feature3 contains origin/feature2 (the pushed base merge)." +else + echo >&2 "❌ Verification Failed: origin/feature3 does not contain origin/feature2 after the push." + exit 1 +fi +# ...and the comment must ask only for the genuine conflict (the pre-squash +# merge), not the base merge the action already did and pushed. +if echo "$CONFLICT_COMMENT" | grep -q "^git merge origin/feature2"; then + echo >&2 "❌ Verification Failed: comment asks the user to merge origin/feature2, which the action already pushed." + echo >&2 "$CONFLICT_COMMENT" + exit 1 +else + echo >&2 "✅ Verification Passed: comment omits the base merge the action already pushed." fi -# 12. Resolve conflict manually -echo >&2 "12. Resolving conflict manually on feature3..." -log_cmd git checkout feature3 -# Ensure we have the latest main which includes the PR2 merge commit AND the conflicting change on main +# 12. Resolve the conflict by following the comment the action posted. +echo >&2 "12. Resolving conflict on feature3 by following the posted comment..." log_cmd git fetch origin -# Now, perform the merge that the action tried and failed -echo >&2 "Attempting merge of origin/main into feature3..." -if git merge origin/main; then - echo >&2 "❌ Conflict Resolution Failed: Merge of main into feature3 succeeded unexpectedly (no conflict?)" - log_cmd git status - log_cmd git log --graph --oneline --all +log_cmd git checkout feature3 +# The action pushed the clean base merge to feature3, so the local branch is now +# behind origin. The comment tells the user to fast-forward to it before merging; +# skipping that would leave the resolution on a stale head and the final push +# would be rejected as non-fast-forward. Verify the comment carries that step, +# then run it. Following the comment must leave feature3 cleanly mergeable into +# its new base, or the synchronize-triggered continuation can never make progress +# and the conflict label stays stuck. +if ! echo "$CONFLICT_COMMENT" | grep -q "^git pull --ff-only origin feature3"; then + echo >&2 "❌ Verification Failed: comment does not tell the user to re-sync (git pull --ff-only origin feature3)." + echo >&2 "$CONFLICT_COMMENT" + exit 1 +fi +log_cmd git pull --ff-only origin feature3 +COMMENT_MERGES=$(echo "$CONFLICT_COMMENT" | grep -E '^git merge' || true) +if [[ -z "$COMMENT_MERGES" ]]; then + echo >&2 "❌ Verification Failed: conflict comment lists no 'git merge' commands to follow." + echo >&2 "$CONFLICT_COMMENT" + exit 1 +fi +HIT_CONFLICT=false +while IFS= read -r cmd; do + echo >&2 "Following comment: $cmd" + if ! log_cmd bash -c "$cmd"; then + echo >&2 "Conflict during '$cmd'; resolving by keeping feature3's side..." + # Keep feature3's line 2 and line 7 conflicting change. + log_cmd git checkout --ours file.txt + log_cmd git add file.txt + log_cmd git commit --no-edit + HIT_CONFLICT=true + fi +done <<< "$COMMENT_MERGES" +if [[ "$HIT_CONFLICT" != "true" ]]; then + echo >&2 "❌ Verification Failed: following the comment hit no conflict; scenario expected one." exit 1 -else - echo >&2 "Merge conflict occurred as expected. Resolving..." - # Check status to confirm conflict - log_cmd git status - # Resolve conflict - keep feature3's version (ours) of the conflicting file - # This preserves both line 2 (Feature 3 content) and line 7 (Feature 3 conflicting change) - log_cmd git checkout --ours file.txt - echo "Resolved file.txt content:" - cat file.txt - log_cmd git add file.txt - # Use 'git commit' without '-m' to use the default merge commit message - log_cmd git commit --no-edit - echo >&2 "Conflict resolved and committed." fi +echo >&2 "Resolved file.txt content:" +cat file.txt log_cmd git push origin feature3 echo >&2 "Pushed resolved feature3." diff --git a/tests/test_push_base_merge_on_conflict.sh b/tests/test_push_base_merge_on_conflict.sh deleted file mode 100755 index cb8725f..0000000 --- a/tests/test_push_base_merge_on_conflict.sh +++ /dev/null @@ -1,312 +0,0 @@ -#!/bin/bash -# -# Test for the "bot does the clean work, user resolves only the genuine -# conflict" behaviour, in two parts. -# -# When updating a descendant branch after a parent squash-merge, the parent -# branch is merged first (step 1), then the target's pre-squash state (step 2), -# then the squash is recorded with -s ours (step 0). -# -# Part A (squash-merge mode): step 1 is clean but step 2 conflicts. The action -# pushes the step-1 result to the branch BEFORE posting the comment and label, -# so the head stays a descendant of its base. That keeps the PR mergeable and -# lets the synchronize event that resumes the action still fire (GitHub does not -# run pull_request workflows on a PR conflicting with its base). The posted -# comment is unchanged: it lists only the genuine conflict (the pre-squash -# merge), because step 1 was clean and never entered the conflict list. -# -# Part B (conflict-resolved mode): after the user resolves step 2 and pushes, -# continue_after_resolution records the squash with -s ours and pushes BEFORE -# retargeting, so the branch ends up mergeable into the new target. The squash -# commit is reconstructed from the merged parent PR (mergeCommit), since the -# synchronize payload is the child PR and SQUASH_COMMIT is not in the env. -# -# Runs fully offline. Unlike the other unit tests, its mock git APPLIES pushes -# to local origin refs, so the simulated user picks up what the action pushed. - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/../command_utils.sh" - -simulate_push() { - log_cmd git update-ref "refs/remotes/origin/$1" "$1" -} - -TEST_REPO=$(mktemp -d) -cd "$TEST_REPO" -echo "Created test repo at $TEST_REPO" - -# Mock git that applies pushes to local origin refs and passes everything else -# through. This lets the action's internal push actually move origin/feature2. -cat > "$TEST_REPO/mock_git.sh" <<'MOCK_GIT' -#!/bin/bash -if [[ "$1" == "push" ]]; then - shift - remote="" - specs=() - for a in "$@"; do - case "$a" in - -*) ;; # ignore flags like --force-with-lease - *) if [[ -z "$remote" ]]; then remote="$a"; else specs+=("$a"); fi ;; - esac - done - for s in "${specs[@]}"; do - if [[ "$s" == :* ]]; then - git update-ref -d "refs/remotes/origin/${s#:}" 2>/dev/null || true - else - git update-ref "refs/remotes/origin/$s" "$s" - fi - done - printf "Executing (mock push applied):" >&2 - printf " %q" git push "$@" >&2 - printf "\n" >&2 - exit 0 -fi -exec git "$@" -MOCK_GIT -chmod +x "$TEST_REPO/mock_git.sh" - -# Mock gh. Records the conflict comment and answers the queries the action makes. -# Reads MOCK_SQUASH from the environment so it can report the parent PR's merge -# commit during conflict-resolved mode. -COMMENT_FILE="$TEST_REPO/conflict_comment.md" -CONFLICT_LABEL="autorestack-needs-conflict-resolution" -cat > "$TEST_REPO/mock_gh.sh" <> "$TEST_REPO/mock_gh.sh" <<'MOCK_GH' -# Extract the value following a flag (e.g. --json) from the arg list. -flag_value() { - local want="$1"; shift - for ((i=1; i<=$#; i++)); do - if [[ "${!i}" == "$want" ]]; then local n=$((i+1)); echo "${!n}"; return; fi - done -} -has_flag() { - local want="$1"; shift - for a in "$@"; do [[ "$a" == "$want" ]] && return 0; done - return 1 -} - -if [[ "$1" == "pr" && "$2" == "list" ]]; then - base=$(flag_value --base "$@") - head=$(flag_value --head "$@") - json=$(flag_value --json "$@") - if [[ -n "$head" ]]; then - # Query about the merged parent PR (head=$OLD_BASE). - case "$json" in - baseRefName) echo "main" ;; - mergeCommit) echo "$MOCK_SQUASH" ;; - esac - elif [[ "$base" == "feature1" ]]; then - # INITIAL_TARGETS and has_sibling_conflicts both query --base feature1. - echo "feature2" - fi -elif [[ "$1" == "pr" && "$2" == "view" ]]; then - json=$(flag_value --json "$@") - case "$json" in - labels) echo "$CONFLICT_LABEL" ;; - baseRefName) echo "feature1" ;; - esac -elif [[ "$1" == "pr" && "$2" == "comment" ]]; then - cat > "$COMMENT_FILE" -elif [[ "$1" == "label" || ( "$1" == "pr" && "$2" == "edit" ) ]]; then - : # ignore label creation / edits -else - echo "Unknown gh command: $@" >&2 - exit 1 -fi -MOCK_GH -chmod +x "$TEST_REPO/mock_gh.sh" - -# Replaying merges may create non-ff merge commits; never open an editor. -export GIT_EDITOR=true - -log_cmd git init -b main -log_cmd git config user.email "test@example.com" -log_cmd git config user.name "Test User" - -for i in $(seq 1 12); do echo "line $i" >> file.txt; done -log_cmd git add file.txt -log_cmd git commit -m "Initial commit" -simulate_push main - -# feature1 (parent PR): first commit on line 2. -log_cmd git checkout -b feature1 -sed -i '2s/.*/f1 commit 1/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "f1: commit 1" -simulate_push feature1 - -# feature2 (child PR): branched off feature1, changes line 9. -log_cmd git checkout -b feature2 -sed -i '9s/.*/f2 content line 9/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "f2: change line 9" -simulate_push feature2 -ORIG_FEATURE2=$(git rev-parse feature2) - -# feature1 advances AFTER feature2 branched (line 4): merging origin/feature1 -# into feature2 is clean and substantive. -log_cmd git checkout feature1 -sed -i '4s/.*/f1 commit 2/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "f1: commit 2" -simulate_push feature1 - -# main advances on line 9 so the pre-squash target conflicts with feature2. -log_cmd git checkout main -sed -i '9s/.*/main conflicting line 9/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "main: conflicting change on line 9" -simulate_push main - -# Squash-merge feature1 into main. -log_cmd git checkout main -log_cmd git merge --squash feature1 -log_cmd git commit -m "Squash merge feature1" -SQUASH_COMMIT=$(git rev-parse HEAD) -simulate_push main -echo "Squash commit: $SQUASH_COMMIT" - -############################################################################ -# Part A: squash-merge mode — clean step-1 merge, conflicting step-2 merge. -############################################################################ - -log_cmd \ - env \ - SQUASH_COMMIT="$SQUASH_COMMIT" \ - MERGED_BRANCH=feature1 \ - TARGET_BRANCH=main \ - GH="$TEST_REPO/mock_gh.sh" \ - GIT="$TEST_REPO/mock_git.sh" \ - "$SCRIPT_DIR/../update-pr-stack.sh" - -if [[ ! -f "$COMMENT_FILE" ]]; then - echo "❌ No conflict comment was posted; scenario did not trigger a conflict" - exit 1 -fi - -echo "" -echo "=== Conflict comment posted to the PR ===" -cat "$COMMENT_FILE" -echo "" - -FAILED=0 - -# The action should have pushed the base-branch merge to origin/feature2. -if log_cmd git merge-base --is-ancestor origin/feature1 origin/feature2; then - echo "✅ origin/feature2 now contains origin/feature1 (action pushed the base merge)" -else - echo "❌ origin/feature2 does not contain origin/feature1; the base merge was not pushed" - FAILED=1 -fi - -# ...and that push must be a fast-forward on top of the original branch (so the -# PR stays mergeable into its base and the synchronize event still fires). -if log_cmd git merge-base --is-ancestor "$ORIG_FEATURE2" origin/feature2; then - echo "✅ origin/feature2 is a descendant of the original branch (mergeable into its base)" -else - echo "❌ origin/feature2 is not a descendant of the original branch" - FAILED=1 -fi - -# The comment must not ask the user to redo the base merge the action did, and -# must list the genuine conflict: the pre-squash target state (SQUASH_COMMIT~). -if grep -q '^git merge origin/feature1' "$COMMENT_FILE"; then - echo "❌ Comment asks the user to merge origin/feature1, which the action already did" - FAILED=1 -else - echo "✅ Comment omits the base merge the action already pushed" -fi - -if grep -q "^git merge $(git rev-parse "$SQUASH_COMMIT"~)" "$COMMENT_FILE"; then - echo "✅ Comment asks the user to resolve the genuine conflict (pre-squash merge)" -else - echo "❌ Comment does not list the pre-squash merge as the conflict to resolve" - FAILED=1 -fi - -[[ "$FAILED" -ne 0 ]] && exit 1 - -############################################################################ -# Simulate the user resolving the conflict by following the comment, starting -# from the branch as it is on the remote (which now includes the action's base -# merge), resolving by keeping our (feature2) side. -############################################################################ - -log_cmd git checkout feature2 -log_cmd git reset --hard origin/feature2 - -MERGE_CMDS=$(grep -E '^git merge' "$COMMENT_FILE" || true) -if [[ -z "$MERGE_CMDS" ]]; then - echo "❌ Comment lists no 'git merge' commands to follow" - exit 1 -fi - -while IFS= read -r cmd; do - echo "Human runs: $cmd" - if ! log_cmd bash -c "$cmd"; then - echo "Resolving conflict by keeping our (feature2) side..." - log_cmd git checkout --ours -- file.txt - log_cmd git add file.txt - log_cmd git commit --no-edit - fi -done <<< "$MERGE_CMDS" - -simulate_push feature2 - -# The old base branch gets deleted during continuation, so capture its tip now. -FEATURE1_TIP=$(git rev-parse feature1) - -############################################################################ -# Part B: conflict-resolved mode — record the squash and push, then retarget. -############################################################################ - -log_cmd \ - env \ - ACTION_MODE=conflict-resolved \ - PR_BRANCH=feature2 \ - MOCK_SQUASH="$SQUASH_COMMIT" \ - GH="$TEST_REPO/mock_gh.sh" \ - GIT="$TEST_REPO/mock_git.sh" \ - "$SCRIPT_DIR/../update-pr-stack.sh" - -echo "" -echo "=== feature2 after the action recorded the squash ===" -log_cmd git log --graph --oneline --all -echo "" - -# The squash must now be recorded on origin/feature2 (-s ours), so the branch is -# mergeable into the new target (main == SQUASH_COMMIT after the squash-merge). -if log_cmd git merge-base --is-ancestor "$SQUASH_COMMIT" origin/feature2; then - echo "✅ origin/feature2 records the squash commit (mergeable into main)" -else - echo "❌ origin/feature2 is missing the squash commit after continuation" - FAILED=1 -fi - -if log_cmd git merge-base --is-ancestor "$FEATURE1_TIP" origin/feature2; then - echo "✅ origin/feature2 still includes the parent branch's advanced content" -else - echo "❌ origin/feature2 is missing the parent branch's content" - FAILED=1 -fi - -# main is now an ancestor of feature2, so merging feature2 into main is a clean -# fast-forward: the diff against the new base is exactly feature2's own change. -if log_cmd git merge-base --is-ancestor origin/main origin/feature2; then - echo "✅ origin/main is an ancestor of origin/feature2 (clean diff against new base)" -else - echo "❌ origin/main is not an ancestor of origin/feature2" - FAILED=1 -fi - -[[ "$FAILED" -ne 0 ]] && exit 1 - -echo "" -echo "All push-base-merge-on-conflict tests passed! 🎉" -echo "Test repository remains at: $TEST_REPO for inspection" diff --git a/update-pr-stack.sh b/update-pr-stack.sh index ba7f36f..5bac9f0 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -112,6 +112,15 @@ update_direct_target() { echo '```bash' echo "git fetch origin" echo "git switch $BRANCH" + # When the base merge was clean we already pushed it to this branch, + # so the local branch is now behind origin. Fast-forward to it before + # resolving, otherwise the final push is rejected as non-fast-forward. + # The line is a harmless no-op on the fallback path (nothing pushed). + if [[ "$BASE_MERGE_CLEAN" == true ]]; then + echo "git pull --ff-only origin $BRANCH # pick up the base merge this action already pushed" + else + echo "git pull --ff-only origin $BRANCH" + fi for conflict in "${CONFLICTS[@]}"; do echo "git merge $conflict" echo "# ..." From 8dba2576a036d805a8cacbd4d2fa9bf849f38d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Mon, 1 Jun 2026 09:20:40 +0200 Subject: [PATCH 03/17] Exercise conflict comments for sibling e2e resolutions The sibling conflict scenario still resolved branches with a hardcoded merge against main, so it could pass even if the generated instructions regressed. Reusing the comment-following path makes PR6 and PR7 validate the same workflow users run, while keeping the PR3 flow on the shared helper. --- tests/test_e2e.sh | 159 ++++++++++++++++++++++++++-------------------- 1 file changed, 91 insertions(+), 68 deletions(-) diff --git a/tests/test_e2e.sh b/tests/test_e2e.sh index f177a3e..e46081a 100755 --- a/tests/test_e2e.sh +++ b/tests/test_e2e.sh @@ -71,9 +71,10 @@ # - Squash merge PR2 (feature2) into main # # Expected Behavior: -# - The action attempts to merge main into feature3 -# - Detects a merge conflict (both modified line 7 differently) -# - Does NOT push any conflicted state to the remote +# - The action merges feature2 into feature3 if that clean base merge is safe +# - Detects a merge conflict with the pre-squash target state (both modified +# line 7 differently) +# - Pushes only the clean base merge, never any conflicted state # - Posts a comment on PR3 explaining the conflict # - Adds a label "autorestack-needs-conflict-resolution" to PR3 # - Does NOT update PR3's base branch (keeps it as feature2 for readable diff) @@ -85,11 +86,11 @@ # - PR3 base branch stays as feature2 (not updated to main) # - Conflict comment exists on PR3 # - Conflict label "autorestack-needs-conflict-resolution" exists on PR3 -# - feature3 branch was NOT updated (still at pre-conflict SHA) +# - feature3 branch advanced only by the clean base merge # # Manual Conflict Resolution (Steps 12-15): # - Test simulates user resolving the conflict manually -# - Merge main into feature3, resolve conflict (keep feature3's changes) +# - Follow the posted comment, resolve conflict (keep feature3's changes) # - Push the resolved branch # - The push triggers the 'synchronize' event on PR3 # - The action detects the conflict label and removes it @@ -206,6 +207,68 @@ compare_diffs() { fi } +get_conflict_comment() { + local pr_url=$1 + local pr_number=$2 + local comment + + 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') + if [[ -n "$comment" ]]; then + echo >&2 "✅ Verification Passed: Conflict comment found on PR #$pr_number." + echo "$comment" >&2 + else + echo >&2 "❌ Verification Failed: Conflict comment not found on PR #$pr_number." + echo >&2 "--- Comments on PR #$pr_number ---" + gh pr view "$pr_url" --repo "$REPO_FULL_NAME" --json comments --jq '.comments[].body' || echo "Failed to get comments" + echo >&2 "-----------------------------" + exit 1 + fi + + printf '%s\n' "$comment" +} + +follow_conflict_comment() { + local comment=$1 + local branch=$2 + local conflict_file=$3 + local resolution_description=$4 + local comment_merges + local hit_conflict=false + + log_cmd git fetch origin + log_cmd git checkout "$branch" + + if ! echo "$comment" | grep -q "^git pull --ff-only origin $branch"; then + echo >&2 "❌ Verification Failed: comment does not tell the user to re-sync (git pull --ff-only origin $branch)." + echo >&2 "$comment" + exit 1 + fi + log_cmd git pull --ff-only origin "$branch" + + comment_merges=$(echo "$comment" | grep -E '^git merge' || true) + if [[ -z "$comment_merges" ]]; then + echo >&2 "❌ Verification Failed: conflict comment lists no 'git merge' commands to follow." + echo >&2 "$comment" + exit 1 + fi + + while IFS= read -r cmd; do + echo >&2 "Following comment: $cmd" + if ! log_cmd bash -c "$cmd"; then + echo >&2 "Conflict during '$cmd'; resolving by keeping $resolution_description..." + log_cmd git checkout --ours "$conflict_file" + log_cmd git add "$conflict_file" + log_cmd git commit --no-edit + hit_conflict=true + fi + done <<< "$comment_merges" + + if [[ "$hit_conflict" != "true" ]]; then + echo >&2 "❌ Verification Failed: following the comment hit no conflict; scenario expected one." + exit 1 + fi +} + # Wait for a PR's base branch to change to the expected value. # Uses retry loop instead of arbitrary sleep. wait_for_pr_base_change() { @@ -840,17 +903,7 @@ fi echo >&2 "Checking for conflict comment on PR #$PR3_NUM..." # Give GitHub some time to process the comment sleep 5 -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') -if [[ -n "$CONFLICT_COMMENT" ]]; then - echo >&2 "✅ Verification Passed: Conflict comment found on PR #$PR3_NUM." - echo "$CONFLICT_COMMENT" # Log the comment -else - echo >&2 "❌ Verification Failed: Conflict comment not found on PR #$PR3_NUM." - echo >&2 "--- Comments on PR #$PR3_NUM ---" - gh pr view "$PR3_URL" --repo "$REPO_FULL_NAME" --json comments --jq '.comments[].body' || echo "Failed to get comments" - echo >&2 "-----------------------------" - exit 1 -fi +CONFLICT_COMMENT=$(get_conflict_comment "$PR3_URL" "$PR3_NUM") # Verify conflict label exists on PR3 echo >&2 "Checking for conflict label on PR #$PR3_NUM..." @@ -899,8 +952,6 @@ fi # 12. Resolve the conflict by following the comment the action posted. echo >&2 "12. Resolving conflict on feature3 by following the posted comment..." -log_cmd git fetch origin -log_cmd git checkout feature3 # The action pushed the clean base merge to feature3, so the local branch is now # behind origin. The comment tells the user to fast-forward to it before merging; # skipping that would leave the resolution on a stale head and the final push @@ -908,34 +959,7 @@ log_cmd git checkout feature3 # then run it. Following the comment must leave feature3 cleanly mergeable into # its new base, or the synchronize-triggered continuation can never make progress # and the conflict label stays stuck. -if ! echo "$CONFLICT_COMMENT" | grep -q "^git pull --ff-only origin feature3"; then - echo >&2 "❌ Verification Failed: comment does not tell the user to re-sync (git pull --ff-only origin feature3)." - echo >&2 "$CONFLICT_COMMENT" - exit 1 -fi -log_cmd git pull --ff-only origin feature3 -COMMENT_MERGES=$(echo "$CONFLICT_COMMENT" | grep -E '^git merge' || true) -if [[ -z "$COMMENT_MERGES" ]]; then - echo >&2 "❌ Verification Failed: conflict comment lists no 'git merge' commands to follow." - echo >&2 "$CONFLICT_COMMENT" - exit 1 -fi -HIT_CONFLICT=false -while IFS= read -r cmd; do - echo >&2 "Following comment: $cmd" - if ! log_cmd bash -c "$cmd"; then - echo >&2 "Conflict during '$cmd'; resolving by keeping feature3's side..." - # Keep feature3's line 2 and line 7 conflicting change. - log_cmd git checkout --ours file.txt - log_cmd git add file.txt - log_cmd git commit --no-edit - HIT_CONFLICT=true - fi -done <<< "$COMMENT_MERGES" -if [[ "$HIT_CONFLICT" != "true" ]]; then - echo >&2 "❌ Verification Failed: following the comment hit no conflict; scenario expected one." - exit 1 -fi +follow_conflict_comment "$CONFLICT_COMMENT" feature3 file.txt "feature3's side" echo >&2 "Resolved file.txt content:" cat file.txt log_cmd git push origin feature3 @@ -1151,18 +1175,26 @@ else exit 1 fi -# 19. Resolve first sibling (feature6) - feature5 should still be kept -echo >&2 "19. Resolving first sibling (feature6)..." -log_cmd git checkout feature6 -log_cmd git fetch origin -if git merge origin/main; then - echo >&2 "Merge succeeded unexpectedly (no conflict?)" -else - echo >&2 "Resolving conflict on feature6..." - log_cmd git checkout --ours file.txt - log_cmd git add file.txt - log_cmd git commit --no-edit +# Verify both conflict comments exist and ask only for the genuine conflict. +echo >&2 "Checking for conflict comments on PR #$PR6_NUM and PR #$PR7_NUM..." +sleep 5 +PR6_CONFLICT_COMMENT=$(get_conflict_comment "$PR6_URL" "$PR6_NUM") +PR7_CONFLICT_COMMENT=$(get_conflict_comment "$PR7_URL" "$PR7_NUM") +if echo "$PR6_CONFLICT_COMMENT" | grep -q "^git merge origin/feature5"; then + echo >&2 "❌ Verification Failed: PR #$PR6_NUM comment asks the user to merge origin/feature5, which the action already pushed or already had." + echo >&2 "$PR6_CONFLICT_COMMENT" + exit 1 +fi +if echo "$PR7_CONFLICT_COMMENT" | grep -q "^git merge origin/feature5"; then + echo >&2 "❌ Verification Failed: PR #$PR7_NUM comment asks the user to merge origin/feature5, which the action already pushed or already had." + echo >&2 "$PR7_CONFLICT_COMMENT" + exit 1 fi +echo >&2 "✅ Verification Passed: sibling conflict comments omit the base merge." + +# 19. Resolve first sibling (feature6) - feature5 should still be kept +echo >&2 "19. Resolving first sibling (feature6) by following the posted comment..." +follow_conflict_comment "$PR6_CONFLICT_COMMENT" feature6 file.txt "feature6's side" log_cmd git push origin feature6 # Wait for continuation workflow @@ -1213,17 +1245,8 @@ else fi # 21. Resolve second sibling (feature7) - now feature5 should be deleted -echo >&2 "21. Resolving second sibling (feature7)..." -log_cmd git checkout feature7 -log_cmd git fetch origin -if git merge origin/main; then - echo >&2 "Merge succeeded unexpectedly (no conflict?)" -else - echo >&2 "Resolving conflict on feature7..." - log_cmd git checkout --ours file.txt - log_cmd git add file.txt - log_cmd git commit --no-edit -fi +echo >&2 "21. Resolving second sibling (feature7) by following the posted comment..." +follow_conflict_comment "$PR7_CONFLICT_COMMENT" feature7 file.txt "feature7's side" log_cmd git push origin feature7 # Wait for continuation workflow From 5c728ebfd3aa7869b5d11408eaadaf707de30f22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Mon, 1 Jun 2026 09:24:28 +0200 Subject: [PATCH 04/17] Address review: ordering note, recipe and lookup cleanups - Note that the base-merge push must precede the label, or the synchronize it emits re-triggers this action against an unresolved branch. - Collapse the duplicated `git pull --ff-only` recipe line to one echo plus a conditional trailing comment. - Fold the new-target and squash-commit lookups into a single `gh pr list` call; `// ""` keeps the absent-PR guard firing. - Reword the continuation conflict message to say it re-posted the comment and will retry, since the path is self-healing. Co-Authored-By: Claude Opus 4.8 --- update-pr-stack.sh | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/update-pr-stack.sh b/update-pr-stack.sh index 5bac9f0..7ea1051 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -98,6 +98,8 @@ update_direct_target() { # pull_request workflows on a PR conflicting with its base, which would # otherwise strand the branch for good. If the base merge itself # conflicted we have nothing safe to pre-push, so we just ask for help. + # Note: ordering is important here: if we label before pushing, we + # re-trigger ourselves immediately. if [[ "$BASE_MERGE_CLEAN" == true ]]; then log_cmd git push origin "$BRANCH" fi @@ -116,10 +118,11 @@ update_direct_target() { # so the local branch is now behind origin. Fast-forward to it before # resolving, otherwise the final push is rejected as non-fast-forward. # The line is a harmless no-op on the fallback path (nothing pushed). + echo -n "git pull --ff-only origin $BRANCH" if [[ "$BASE_MERGE_CLEAN" == true ]]; then - echo "git pull --ff-only origin $BRANCH # pick up the base merge this action already pushed" + echo " # pick up the base merge this action already pushed" else - echo "git pull --ff-only origin $BRANCH" + echo fi for conflict in "${CONFLICTS[@]}"; do echo "git merge $conflict" @@ -205,8 +208,8 @@ continue_after_resolution() { # and the merged PR whose head is OLD_BASE gives the new target (its base) and # the squash commit (its merge commit). local NEW_TARGET SQUASH_HASH - NEW_TARGET=$(gh pr list --head "$OLD_BASE" --state merged --json baseRefName --jq '.[0].baseRefName') - SQUASH_HASH=$(gh pr list --head "$OLD_BASE" --state merged --json mergeCommit --jq '.[0].mergeCommit.oid') + read -r NEW_TARGET SQUASH_HASH < <(gh pr list --head "$OLD_BASE" --state merged \ + --json baseRefName,mergeCommit --jq '.[0] | "\(.baseRefName // "") \(.mergeCommit.oid // "")"') if [[ -z "$NEW_TARGET" || -z "$SQUASH_HASH" ]]; then echo "⚠️ Could not find where '$OLD_BASE' was merged to; skipping base branch and deletion updates" @@ -224,7 +227,7 @@ continue_after_resolution() { MERGED_BRANCH="$OLD_BASE" TARGET_BRANCH="$NEW_TARGET" if ! update_direct_target "$PR_BRANCH" "$NEW_TARGET"; then - echo "⚠️ Unexpected conflict while recording the squash on '$PR_BRANCH'; leaving it for manual handling" + echo "⚠️ '$PR_BRANCH' still conflicts; re-posted the conflict comment, will retry on next push" return 1 fi log_cmd git push origin "$PR_BRANCH" From 5212f4e8f3d21c876ddffd9b8c2d1dd8e07305ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Mon, 1 Jun 2026 09:55:50 +0200 Subject: [PATCH 05/17] Apply suggestion from @Phlogistique --- update-pr-stack.sh | 4 ---- 1 file changed, 4 deletions(-) diff --git a/update-pr-stack.sh b/update-pr-stack.sh index 7ea1051..e2b4a88 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -114,10 +114,6 @@ update_direct_target() { echo '```bash' echo "git fetch origin" echo "git switch $BRANCH" - # When the base merge was clean we already pushed it to this branch, - # so the local branch is now behind origin. Fast-forward to it before - # resolving, otherwise the final push is rejected as non-fast-forward. - # The line is a harmless no-op on the fallback path (nothing pushed). echo -n "git pull --ff-only origin $BRANCH" if [[ "$BASE_MERGE_CLEAN" == true ]]; then echo " # pick up the base merge this action already pushed" From 9e62e7314f45ba4b8f0daf335e89fa7244ce97e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Mon, 1 Jun 2026 09:56:32 +0200 Subject: [PATCH 06/17] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Noé Rubinstein --- update-pr-stack.sh | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/update-pr-stack.sh b/update-pr-stack.sh index e2b4a88..7e04c38 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -114,12 +114,7 @@ update_direct_target() { echo '```bash' echo "git fetch origin" echo "git switch $BRANCH" - echo -n "git pull --ff-only origin $BRANCH" - if [[ "$BASE_MERGE_CLEAN" == true ]]; then - echo " # pick up the base merge this action already pushed" - else - echo - fi + echo "git pull --ff-only origin $BRANCH" for conflict in "${CONFLICTS[@]}"; do echo "git merge $conflict" echo "# ..." From 0a62415cbb8ac6a2e202f28df93c3b90a316f4cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Mon, 1 Jun 2026 10:03:09 +0200 Subject: [PATCH 07/17] Cover conflict resolution matrix in e2e The conflict flow now has distinct behavior for base-branch conflicts, trunk conflicts, and follow-up conflicts after a manual resolution. The e2e test exercises those paths explicitly and follows the generated bash blocks rather than a parallel hardcoded recipe, so regressions in the posted instructions are caught by the same workflow users run. --- tests/test_e2e.sh | 391 +++++++++++++++++++++++++++++++++++++++------ update-pr-stack.sh | 43 ++--- 2 files changed, 369 insertions(+), 65 deletions(-) diff --git a/tests/test_e2e.sh b/tests/test_e2e.sh index e46081a..3e5040d 100755 --- a/tests/test_e2e.sh +++ b/tests/test_e2e.sh @@ -114,6 +114,11 @@ # Tests that merging a PR with no children simply deletes the branch # and the action completes successfully. # +# SCENARIO 7: Conflict Matrix Edge Cases (Steps 32-34) +# ---------------------------------------------------- +# Tests base-branch conflicts, simultaneous base/trunk conflicts, and a +# follow-up trunk conflict discovered after a base conflict is resolved. +# # ============================================================================= set -e # Exit immediately if a command exits with a non-zero status. # set -x # Debugging: print commands as they are executed @@ -210,9 +215,20 @@ compare_diffs() { get_conflict_comment() { local pr_url=$1 local pr_number=$2 + local expected_count=${3:-} + local comments local comment + local count + + comments=$(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]') + count=$(echo "$comments" | jq 'length') + comment=$(echo "$comments" | jq -r '.[-1] // ""') + if [[ -n "$expected_count" && "$count" != "$expected_count" ]]; then + echo >&2 "❌ Verification Failed: PR #$pr_number has $count conflict comments, expected $expected_count." + echo >&2 "$comments" + exit 1 + fi - 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') if [[ -n "$comment" ]]; then echo >&2 "✅ Verification Passed: Conflict comment found on PR #$pr_number." echo "$comment" >&2 @@ -227,44 +243,107 @@ get_conflict_comment() { printf '%s\n' "$comment" } -follow_conflict_comment() { +assert_conflict_comment_merges() { local comment=$1 - local branch=$2 - local conflict_file=$3 - local resolution_description=$4 - local comment_merges - local hit_conflict=false + shift + local expected="" + local actual - log_cmd git fetch origin - log_cmd git checkout "$branch" + for conflict in "$@"; do + expected+="git merge $conflict"$'\n' + done + expected=${expected%$'\n'} + actual=$(echo "$comment" | grep -E '^git merge' || true) - if ! echo "$comment" | grep -q "^git pull --ff-only origin $branch"; then - echo >&2 "❌ Verification Failed: comment does not tell the user to re-sync (git pull --ff-only origin $branch)." + if [[ "$actual" == "$expected" ]]; then + echo >&2 "✅ Verification Passed: conflict comment lists expected merge command(s)." + else + echo >&2 "❌ Verification Failed: conflict comment merge commands differ." + echo >&2 "--- Expected ---" + echo >&2 "$expected" + echo >&2 "--- Actual ---" + echo >&2 "$actual" + echo >&2 "--- Full comment ---" echo >&2 "$comment" exit 1 fi - log_cmd git pull --ff-only origin "$branch" +} - comment_merges=$(echo "$comment" | grep -E '^git merge' || true) - if [[ -z "$comment_merges" ]]; then - echo >&2 "❌ Verification Failed: conflict comment lists no 'git merge' commands to follow." +follow_conflict_comment() { + local comment=$1 + local conflict_file=$2 + local resolution_description=$3 + local expected_conflicts=$4 + local before_push_hook=${5:-} + local in_block=false + local block="" + local blocks=() + local hit_conflicts=0 + + while IFS= read -r line; do + if [[ "$line" == '```bash' ]]; then + in_block=true + block="" + continue + fi + if [[ "$line" == '```' && "$in_block" == "true" ]]; then + blocks+=("${block%$'\n'}") + in_block=false + continue + fi + if [[ "$in_block" == "true" ]]; then + block+="$line"$'\n' + fi + done <<< "$comment" + + if [[ "${#blocks[@]}" -eq 0 ]]; then + echo >&2 "❌ Verification Failed: conflict comment contains no bash code blocks to follow." echo >&2 "$comment" exit 1 fi - while IFS= read -r cmd; do - echo >&2 "Following comment: $cmd" - if ! log_cmd bash -c "$cmd"; then - echo >&2 "Conflict during '$cmd'; resolving by keeping $resolution_description..." - log_cmd git checkout --ours "$conflict_file" - log_cmd git add "$conflict_file" - log_cmd git commit --no-edit - hit_conflict=true + for block in "${blocks[@]}"; do + echo >&2 "Following comment block:" + echo >&2 "$block" + if [[ "$block" == git\ push* && -n "$before_push_hook" ]]; then + "$before_push_hook" + fi + if ! log_cmd bash -e -c "$block"; then + if git diff --name-only --diff-filter=U | grep -qx "$conflict_file"; then + echo >&2 "Conflict during comment block; resolving by keeping $resolution_description..." + log_cmd git checkout --ours "$conflict_file" + log_cmd git add "$conflict_file" + log_cmd git commit --no-edit + hit_conflicts=$((hit_conflicts + 1)) + else + echo >&2 "❌ Verification Failed: comment block failed without an unresolved $conflict_file conflict." + log_cmd git status + exit 1 + fi fi - done <<< "$comment_merges" + done - if [[ "$hit_conflict" != "true" ]]; then - echo >&2 "❌ Verification Failed: following the comment hit no conflict; scenario expected one." + if [[ "$hit_conflicts" -ne "$expected_conflicts" ]]; then + echo >&2 "❌ Verification Failed: following the comment hit $hit_conflicts conflicts; expected $expected_conflicts." + exit 1 + fi +} + +assert_pr_changed_lines() { + local pr_url=$1 + local context=$2 + local expected=$3 + local actual + + actual=$(get_pr_diff "$pr_url" | grep '^[+-]' | grep -v '^[+-][+-][+-]') + if [[ "$actual" == "$expected" ]]; then + echo >&2 "✅ Verification Passed: $context" + else + echo >&2 "❌ Verification Failed: $context" + echo >&2 "--- Expected changed lines ---" + echo >&2 "$expected" + echo >&2 "--- Actual changed lines ---" + echo >&2 "$actual" exit 1 fi } @@ -903,7 +982,9 @@ fi echo >&2 "Checking for conflict comment on PR #$PR3_NUM..." # Give GitHub some time to process the comment sleep 5 -CONFLICT_COMMENT=$(get_conflict_comment "$PR3_URL" "$PR3_NUM") +CONFLICT_COMMENT=$(get_conflict_comment "$PR3_URL" "$PR3_NUM" 1) +PRE_SQUASH_COMMIT2=$(git rev-parse "$MERGE_COMMIT_SHA2~") +assert_conflict_comment_merges "$CONFLICT_COMMENT" "$PRE_SQUASH_COMMIT2" # Verify conflict label exists on PR3 echo >&2 "Checking for conflict label on PR #$PR3_NUM..." @@ -959,10 +1040,9 @@ echo >&2 "12. Resolving conflict on feature3 by following the posted comment..." # then run it. Following the comment must leave feature3 cleanly mergeable into # its new base, or the synchronize-triggered continuation can never make progress # and the conflict label stays stuck. -follow_conflict_comment "$CONFLICT_COMMENT" feature3 file.txt "feature3's side" +follow_conflict_comment "$CONFLICT_COMMENT" file.txt "feature3's side" 1 echo >&2 "Resolved file.txt content:" cat file.txt -log_cmd git push origin feature3 echo >&2 "Pushed resolved feature3." # 13. Wait for continuation workflow triggered by push @@ -1178,24 +1258,15 @@ fi # Verify both conflict comments exist and ask only for the genuine conflict. echo >&2 "Checking for conflict comments on PR #$PR6_NUM and PR #$PR7_NUM..." sleep 5 -PR6_CONFLICT_COMMENT=$(get_conflict_comment "$PR6_URL" "$PR6_NUM") -PR7_CONFLICT_COMMENT=$(get_conflict_comment "$PR7_URL" "$PR7_NUM") -if echo "$PR6_CONFLICT_COMMENT" | grep -q "^git merge origin/feature5"; then - echo >&2 "❌ Verification Failed: PR #$PR6_NUM comment asks the user to merge origin/feature5, which the action already pushed or already had." - echo >&2 "$PR6_CONFLICT_COMMENT" - exit 1 -fi -if echo "$PR7_CONFLICT_COMMENT" | grep -q "^git merge origin/feature5"; then - echo >&2 "❌ Verification Failed: PR #$PR7_NUM comment asks the user to merge origin/feature5, which the action already pushed or already had." - echo >&2 "$PR7_CONFLICT_COMMENT" - exit 1 -fi -echo >&2 "✅ Verification Passed: sibling conflict comments omit the base merge." +PR6_CONFLICT_COMMENT=$(get_conflict_comment "$PR6_URL" "$PR6_NUM" 1) +PR7_CONFLICT_COMMENT=$(get_conflict_comment "$PR7_URL" "$PR7_NUM" 1) +PRE_SQUASH_COMMIT5=$(git rev-parse "$MERGE_COMMIT_SHA5~") +assert_conflict_comment_merges "$PR6_CONFLICT_COMMENT" "$PRE_SQUASH_COMMIT5" +assert_conflict_comment_merges "$PR7_CONFLICT_COMMENT" "$PRE_SQUASH_COMMIT5" # 19. Resolve first sibling (feature6) - feature5 should still be kept echo >&2 "19. Resolving first sibling (feature6) by following the posted comment..." -follow_conflict_comment "$PR6_CONFLICT_COMMENT" feature6 file.txt "feature6's side" -log_cmd git push origin feature6 +follow_conflict_comment "$PR6_CONFLICT_COMMENT" file.txt "feature6's side" 1 # Wait for continuation workflow echo >&2 "Waiting for continuation workflow for feature6..." @@ -1233,6 +1304,11 @@ else echo >&2 "❌ Verification Failed: PR #$PR6_NUM still has conflict label." exit 1 fi +assert_pr_changed_lines "$PR6_URL" "PR6 diff contains only its resolved conflict" "$(cat <<'EOF' +-Main conflicting content line 5 ++Feature 6 conflicting content line 5 +EOF +)" # PR7 should still have conflict label and feature5 as base PR7_LABEL_STILL=$(gh pr view "$PR7_URL" --repo "$REPO_FULL_NAME" --json labels --jq '.labels[] | select(.name == "autorestack-needs-conflict-resolution") | .name') @@ -1246,8 +1322,7 @@ fi # 21. Resolve second sibling (feature7) - now feature5 should be deleted echo >&2 "21. Resolving second sibling (feature7) by following the posted comment..." -follow_conflict_comment "$PR7_CONFLICT_COMMENT" feature7 file.txt "feature7's side" -log_cmd git push origin feature7 +follow_conflict_comment "$PR7_CONFLICT_COMMENT" file.txt "feature7's side" 1 # Wait for continuation workflow echo >&2 "Waiting for continuation workflow for feature7..." @@ -1275,6 +1350,11 @@ else echo >&2 "❌ Verification Failed: PR #$PR7_NUM base is '$PR7_BASE_FINAL', expected 'main'." exit 1 fi +assert_pr_changed_lines "$PR7_URL" "PR7 diff contains only its resolved conflict" "$(cat <<'EOF' +-Main conflicting content line 5 ++Feature 7 conflicting content line 5 +EOF +)" echo >&2 "--- Sibling Conflicts Scenario Test Completed Successfully ---" @@ -1577,6 +1657,225 @@ fi echo >&2 "--- No Children Scenario Test Completed Successfully ---" +# --- SCENARIO 7: Conflict Matrix Edge Cases --- +# =================================================================================== +# Covers conflict paths not exercised by the main trunk-conflict scenario: +# - base branch conflicts +# - base and trunk branch both conflict in the first run +# - base branch conflicts, then the pushed fix exposes a trunk conflict +# =================================================================================== + +echo >&2 "--- Testing Conflict Matrix Edge Cases ---" + +# 32. Base branch conflict only +echo >&2 "32. Testing base-branch conflict..." +log_cmd git checkout main +log_cmd git pull origin main + +log_cmd git checkout -b feature15 main +sed -i '8s/.*/Feature 15 content line 8/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "Add feature 15" +log_cmd git push origin feature15 +PR15_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base main --head feature15 --title "Feature 15" --body "Base conflict parent") +PR15_NUM=$(echo "$PR15_URL" | awk -F'/' '{print $NF}') + +log_cmd git checkout -b feature16 feature15 +sed -i '9s/.*/Feature 16 base conflict line 9/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "Add feature 16" +FEATURE16_BEFORE_CONFLICT=$(git rev-parse HEAD) +log_cmd git push origin feature16 +PR16_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature15 --head feature16 --title "Feature 16" --body "Base conflict child") +PR16_NUM=$(echo "$PR16_URL" | awk -F'/' '{print $NF}') + +log_cmd git checkout feature15 +sed -i '9s/.*/Feature 15 base conflict line 9/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "Create base conflict for feature16" +log_cmd git push origin feature15 + +merge_pr_with_retry "$PR15_URL" +MERGE_COMMIT_SHA15=$(gh pr view "$PR15_URL" --repo "$REPO_FULL_NAME" --json mergeCommit -q .mergeCommit.oid) +if ! wait_for_workflow "$PR15_NUM" "feature15" "$MERGE_COMMIT_SHA15" "success"; then + echo >&2 "Workflow for PR15 merge did not complete successfully." + exit 1 +fi + +log_cmd git fetch origin --prune +if [[ "$(git rev-parse refs/remotes/origin/feature16)" == "$FEATURE16_BEFORE_CONFLICT" ]]; then + echo >&2 "✅ Verification Passed: base-conflicted feature16 was not pre-pushed." +else + echo >&2 "❌ Verification Failed: feature16 advanced even though the base merge conflicted." + exit 1 +fi +PR16_CONFLICT_COMMENT=$(get_conflict_comment "$PR16_URL" "$PR16_NUM" 1) +assert_conflict_comment_merges "$PR16_CONFLICT_COMMENT" "origin/feature15" +follow_conflict_comment "$PR16_CONFLICT_COMMENT" file.txt "feature16's side" 1 +if ! wait_for_synchronize_workflow "$PR16_NUM" "feature16" "success"; then + echo >&2 "Continuation workflow for feature16 did not complete successfully." + exit 1 +fi +PR16_LABEL_AFTER=$(gh pr view "$PR16_URL" --repo "$REPO_FULL_NAME" --json labels --jq '.labels[] | select(.name == "autorestack-needs-conflict-resolution") | .name') +PR16_BASE_AFTER=$(gh pr view "$PR16_NUM" --repo "$REPO_FULL_NAME" --json baseRefName --jq .baseRefName) +if [[ -z "$PR16_LABEL_AFTER" && "$PR16_BASE_AFTER" == "main" ]]; then + echo >&2 "✅ Verification Passed: feature16 conflict label removed and base updated." +else + echo >&2 "❌ Verification Failed: feature16 label='$PR16_LABEL_AFTER', base='$PR16_BASE_AFTER'." + exit 1 +fi +assert_pr_changed_lines "$PR16_URL" "PR16 diff contains only its resolved base conflict" "$(cat <<'EOF' +-Feature 15 base conflict line 9 ++Feature 16 base conflict line 9 +EOF +)" + +# 33. Base and trunk branch both conflict in the first run +echo >&2 "33. Testing base-and-trunk conflict in one comment..." +log_cmd git checkout main +log_cmd git pull origin main + +log_cmd git checkout -b feature17 main +sed -i '8s/.*/Feature 17 content line 8/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "Add feature 17" +log_cmd git push origin feature17 +PR17_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base main --head feature17 --title "Feature 17" --body "Base and trunk conflict parent") +PR17_NUM=$(echo "$PR17_URL" | awk -F'/' '{print $NF}') + +log_cmd git checkout -b feature18 feature17 +sed -i '9s/.*/Feature 18 base conflict line 9/' file.txt +sed -i '13s/.*/Feature 18 trunk conflict line 13/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "Add feature 18" +FEATURE18_BEFORE_CONFLICT=$(git rev-parse HEAD) +log_cmd git push origin feature18 +PR18_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature17 --head feature18 --title "Feature 18" --body "Base and trunk conflict child") +PR18_NUM=$(echo "$PR18_URL" | awk -F'/' '{print $NF}') + +log_cmd git checkout feature17 +sed -i '9s/.*/Feature 17 base conflict line 9/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "Create base conflict for feature18" +log_cmd git push origin feature17 + +log_cmd git checkout main +sed -i '13s/.*/Main trunk conflict line 13/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "Create trunk conflict for feature18" +log_cmd git push origin main + +merge_pr_with_retry "$PR17_URL" +MERGE_COMMIT_SHA17=$(gh pr view "$PR17_URL" --repo "$REPO_FULL_NAME" --json mergeCommit -q .mergeCommit.oid) +if ! wait_for_workflow "$PR17_NUM" "feature17" "$MERGE_COMMIT_SHA17" "success"; then + echo >&2 "Workflow for PR17 merge did not complete successfully." + exit 1 +fi + +log_cmd git fetch origin --prune +if [[ "$(git rev-parse refs/remotes/origin/feature18)" == "$FEATURE18_BEFORE_CONFLICT" ]]; then + echo >&2 "✅ Verification Passed: base-and-trunk-conflicted feature18 was not pre-pushed." +else + echo >&2 "❌ Verification Failed: feature18 advanced even though the base merge conflicted." + exit 1 +fi +PR18_CONFLICT_COMMENT=$(get_conflict_comment "$PR18_URL" "$PR18_NUM" 1) +PRE_SQUASH_COMMIT17=$(git rev-parse "$MERGE_COMMIT_SHA17~") +assert_conflict_comment_merges "$PR18_CONFLICT_COMMENT" "origin/feature17" "$PRE_SQUASH_COMMIT17" +follow_conflict_comment "$PR18_CONFLICT_COMMENT" file.txt "feature18's side" 2 +if ! wait_for_synchronize_workflow "$PR18_NUM" "feature18" "success"; then + echo >&2 "Continuation workflow for feature18 did not complete successfully." + exit 1 +fi +assert_pr_changed_lines "$PR18_URL" "PR18 diff contains only its two resolved conflicts" "$(cat <<'EOF' +-Feature 17 base conflict line 9 ++Feature 18 base conflict line 9 +-Main trunk conflict line 13 ++Feature 18 trunk conflict line 13 +EOF +)" + +# 34. Base branch conflict, followed by trunk conflict after the user pushes a fix +echo >&2 "34. Testing base conflict followed by trunk conflict on continuation..." +log_cmd git checkout main +log_cmd git pull origin main + +log_cmd git checkout -b feature19 main +sed -i '8s/.*/Feature 19 content line 8/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "Add feature 19" +log_cmd git push origin feature19 +PR19_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base main --head feature19 --title "Feature 19" --body "Follow-up trunk conflict parent") +PR19_NUM=$(echo "$PR19_URL" | awk -F'/' '{print $NF}') + +log_cmd git checkout -b feature20 feature19 +sed -i '9s/.*/Feature 20 base conflict line 9/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "Add feature 20" +FEATURE20_BEFORE_CONFLICT=$(git rev-parse HEAD) +log_cmd git push origin feature20 +PR20_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature19 --head feature20 --title "Feature 20" --body "Follow-up trunk conflict child") +PR20_NUM=$(echo "$PR20_URL" | awk -F'/' '{print $NF}') + +log_cmd git checkout feature19 +sed -i '9s/.*/Feature 19 base conflict line 9/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "Create base conflict for feature20" +log_cmd git push origin feature19 + +log_cmd git checkout main +sed -i '13s/.*/Main follow-up trunk conflict line 13/' file.txt +log_cmd git add file.txt +log_cmd git commit -m "Create follow-up trunk conflict for feature20" +log_cmd git push origin main + +merge_pr_with_retry "$PR19_URL" +MERGE_COMMIT_SHA19=$(gh pr view "$PR19_URL" --repo "$REPO_FULL_NAME" --json mergeCommit -q .mergeCommit.oid) +if ! wait_for_workflow "$PR19_NUM" "feature19" "$MERGE_COMMIT_SHA19" "success"; then + echo >&2 "Workflow for PR19 merge did not complete successfully." + exit 1 +fi + +log_cmd git fetch origin --prune +if [[ "$(git rev-parse refs/remotes/origin/feature20)" == "$FEATURE20_BEFORE_CONFLICT" ]]; then + echo >&2 "✅ Verification Passed: base-conflicted feature20 was not pre-pushed." +else + echo >&2 "❌ Verification Failed: feature20 advanced even though the base merge conflicted." + exit 1 +fi +PR20_FIRST_CONFLICT_COMMENT=$(get_conflict_comment "$PR20_URL" "$PR20_NUM" 1) +assert_conflict_comment_merges "$PR20_FIRST_CONFLICT_COMMENT" "origin/feature19" + +introduce_feature20_trunk_conflict_before_push() { + sed -i '13s/.*/Feature 20 follow-up trunk conflict line 13/' file.txt + log_cmd git add file.txt + log_cmd git commit -m "Introduce follow-up trunk conflict" +} + +follow_conflict_comment "$PR20_FIRST_CONFLICT_COMMENT" file.txt "feature20's side" 1 introduce_feature20_trunk_conflict_before_push +if ! wait_for_synchronize_workflow "$PR20_NUM" "feature20" "failure"; then + echo >&2 "Expected continuation workflow for feature20 to fail with a new trunk conflict." + exit 1 +fi +PR20_SECOND_CONFLICT_COMMENT=$(get_conflict_comment "$PR20_URL" "$PR20_NUM" 2) +PRE_SQUASH_COMMIT19=$(git rev-parse "$MERGE_COMMIT_SHA19~") +assert_conflict_comment_merges "$PR20_SECOND_CONFLICT_COMMENT" "$PRE_SQUASH_COMMIT19" +follow_conflict_comment "$PR20_SECOND_CONFLICT_COMMENT" file.txt "feature20's side" 1 +if ! wait_for_synchronize_workflow "$PR20_NUM" "feature20" "success"; then + echo >&2 "Continuation workflow for feature20 did not complete successfully after trunk conflict resolution." + exit 1 +fi +assert_pr_changed_lines "$PR20_URL" "PR20 diff contains only the base and follow-up trunk resolutions" "$(cat <<'EOF' +-Feature 19 base conflict line 9 ++Feature 20 base conflict line 9 +-Main follow-up trunk conflict line 13 ++Feature 20 follow-up trunk conflict line 13 +EOF +)" + +echo >&2 "--- Conflict Matrix Edge Cases Completed Successfully ---" + + # --- Test Succeeded --- echo >&2 "--- E2E Test Completed Successfully! ---" diff --git a/update-pr-stack.sh b/update-pr-stack.sh index 7ea1051..18a62b4 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -111,27 +111,32 @@ update_direct_target() { echo " into this branch while updating the pull request stack and hit conflicts." echo echo "#### How to resolve" - echo '```bash' - echo "git fetch origin" - echo "git switch $BRANCH" - # When the base merge was clean we already pushed it to this branch, - # so the local branch is now behind origin. Fast-forward to it before - # resolving, otherwise the final push is rejected as non-fast-forward. - # The line is a harmless no-op on the fallback path (nothing pushed). - echo -n "git pull --ff-only origin $BRANCH" - if [[ "$BASE_MERGE_CLEAN" == true ]]; then - echo " # pick up the base merge this action already pushed" - else + for i in "${!CONFLICTS[@]}"; do + if [[ "$i" -eq 0 ]]; then + echo '```bash' + echo "git fetch origin" + echo "git switch $BRANCH" + # When the base merge was clean we already pushed it to this branch, + # so the local branch is now behind origin. Fast-forward to it before + # resolving, otherwise the final push is rejected as non-fast-forward. + # The line is a harmless no-op on the fallback path (nothing pushed). + echo -n "git pull --ff-only origin $BRANCH" + if [[ "$BASE_MERGE_CLEAN" == true ]]; then + echo " # pick up the base merge this action already pushed" + else + echo + fi + else + echo '```bash' + fi + echo "git merge ${CONFLICTS[$i]}" + echo '```' + echo + echo 'If this stops with conflicts, fix them (for instance with `git mergetool`), then run `git commit` before continuing.' echo - fi - for conflict in "${CONFLICTS[@]}"; do - echo "git merge $conflict" - echo "# ..." - echo '# fix conflicts, for instance with `git mergetool`' - echo "# ..." - echo "git commit" done - echo "git push" + echo '```bash' + echo "git push origin $BRANCH" echo '```' echo echo "Once you push, this action will resume and finish updating this pull request." From 67f8664ac5a01deb377a7101db4deed584ead51e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Mon, 1 Jun 2026 10:31:01 +0200 Subject: [PATCH 08/17] Match e2e workflow runs by pull request action The conflict matrix creates several pull_request runs on the same head branch. Matching only the branch can pick a stale synchronize run when the test is waiting for the closed run from a merge. Name the installed workflow with the pull_request action and PR number, then have the e2e waiters ignore runs that existed before the trigger and match the expected action-specific run name. --- .github/workflows/update-pr-stack.yml | 1 + tests/test_e2e.sh | 56 +++++++++++++++++++++------ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/.github/workflows/update-pr-stack.yml b/.github/workflows/update-pr-stack.yml index 660ca9c..f35dcf2 100644 --- a/.github/workflows/update-pr-stack.yml +++ b/.github/workflows/update-pr-stack.yml @@ -1,4 +1,5 @@ name: Update PR Stack +run-name: ${{ github.event.action }} PR #${{ github.event.pull_request.number }} - ${{ github.event.pull_request.title }} on: pull_request: diff --git a/tests/test_e2e.sh b/tests/test_e2e.sh index 3e5040d..b9b1d3d 100755 --- a/tests/test_e2e.sh +++ b/tests/test_e2e.sh @@ -143,6 +143,7 @@ source "$PROJECT_ROOT/command_utils.sh" # Workflow file name WORKFLOW_FILE="update-pr-stack.yml" +WORKFLOW_RUN_IDS_BEFORE_TRIGGER="" # --- Helper Functions --- cleanup() { @@ -308,6 +309,9 @@ follow_conflict_comment() { if [[ "$block" == git\ push* && -n "$before_push_hook" ]]; then "$before_push_hook" fi + if [[ "$block" == git\ push* ]]; then + record_existing_workflow_runs + fi if ! log_cmd bash -e -c "$block"; then if git diff --name-only --diff-filter=U | grep -qx "$conflict_file"; then echo >&2 "Conflict during comment block; resolving by keeping $resolution_description..." @@ -348,6 +352,21 @@ assert_pr_changed_lines() { fi } +record_existing_workflow_runs() { + WORKFLOW_RUN_IDS_BEFORE_TRIGGER=$(gh run list \ + --repo "$REPO_FULL_NAME" \ + --workflow "$WORKFLOW_FILE" \ + --event pull_request \ + --limit 30 \ + --json databaseId --jq '.[].databaseId' 2>/dev/null || true) +} + +is_recorded_workflow_run() { + local run_id=$1 + + grep -qx "$run_id" <<< "$WORKFLOW_RUN_IDS_BEFORE_TRIGGER" +} + # Wait for a PR's base branch to change to the expected value. # Uses retry loop instead of arbitrary sleep. wait_for_pr_base_change() { @@ -385,6 +404,8 @@ merge_pr_with_retry() { local max_attempts=5 local attempt=0 + record_existing_workflow_runs + while [[ $attempt -lt $max_attempts ]]; do attempt=$((attempt + 1)) echo >&2 "Merge attempt $attempt/$max_attempts for $pr_url..." @@ -412,7 +433,7 @@ wait_for_synchronize_workflow() { local max_attempts=20 # ~7 mins max wait local attempt=0 local target_run_id="" - local start_time=$(date +%s) + local expected_run_name="synchronize PR #$pr_number" echo >&2 "Waiting for workflow '$WORKFLOW_FILE' triggered by synchronize event on PR #$pr_number (branch $branch_name)..." @@ -428,7 +449,7 @@ wait_for_synchronize_workflow() { --workflow "$WORKFLOW_FILE" \ --event pull_request \ --limit 15 \ - --json databaseId,createdAt --jq '.[] | select(.createdAt >= "'$(date -u -d "@$start_time" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -r $start_time +%Y-%m-%dT%H:%M:%SZ)'") | .databaseId' || echo "") + --json databaseId --jq '.[].databaseId' || echo "") if [[ -z "$candidate_run_ids" ]]; then echo >&2 "No recent '$WORKFLOW_FILE' runs found since start. Sleeping $sleep_time seconds." @@ -439,15 +460,21 @@ wait_for_synchronize_workflow() { echo >&2 "Found candidate run IDs: $candidate_run_ids. Checking runs..." for run_id in $candidate_run_ids; do + if is_recorded_workflow_run "$run_id"; then + echo >&2 "Skipping workflow run ID $run_id because it existed before this trigger." + continue + fi echo >&2 "Checking candidate run ID: $run_id" - run_info=$(log_cmd gh run view "$run_id" --repo "$REPO_FULL_NAME" --json headBranch || echo "{}") + run_info=$(log_cmd gh run view "$run_id" --repo "$REPO_FULL_NAME" --json displayTitle,headBranch || echo "{}") + run_display_title=$(echo "$run_info" | jq -r '.displayTitle // ""') run_head_branch=$(echo "$run_info" | jq -r '.headBranch // ""') + echo >&2 " Run display title: $run_display_title" echo >&2 " Run head branch: $run_head_branch" - if [[ "$run_head_branch" == "$branch_name" ]]; then - echo >&2 "Found matching workflow run ID: $run_id (branch matches)" + if [[ "$run_display_title" == "$expected_run_name"* && "$run_head_branch" == "$branch_name" ]]; then + echo >&2 "Found matching workflow run ID: $run_id (run name and branch match)" target_run_id="$run_id" break fi @@ -503,6 +530,7 @@ wait_for_workflow() { local max_attempts=20 # Increased attempts (~7 mins max wait) local attempt=0 local target_run_id="" + local expected_run_name="closed PR #$pr_number" echo >&2 "Waiting for workflow '$WORKFLOW_FILE' triggered by merge of PR #$pr_number (merge commit $merge_commit_sha)..." @@ -519,8 +547,8 @@ wait_for_workflow() { --repo "$REPO_FULL_NAME" \ --workflow "$WORKFLOW_FILE" \ --event pull_request \ - --limit 10 \ - --json databaseId --jq '.[].databaseId' || echo "") # Get IDs, handle potential errors + --limit 15 \ + --json databaseId --jq '.[].databaseId' || echo "") if [[ -z "$candidate_run_ids" ]]; then echo >&2 "No recent '$WORKFLOW_FILE' runs found for 'pull_request' event. Sleeping $sleep_time seconds." @@ -531,20 +559,26 @@ wait_for_workflow() { echo >&2 "Found candidate run IDs: $candidate_run_ids. Checking runs..." for run_id in $candidate_run_ids; do + if is_recorded_workflow_run "$run_id"; then + echo >&2 "Skipping workflow run ID $run_id because it existed before this trigger." + continue + fi echo >&2 "Checking candidate run ID: $run_id" - run_info=$(log_cmd gh run view "$run_id" --repo "$REPO_FULL_NAME" --json headBranch,headSha || echo "{}") # Fetch run info, default to empty JSON on error + run_info=$(log_cmd gh run view "$run_id" --repo "$REPO_FULL_NAME" --json displayTitle,headBranch,headSha || echo "{}") # Fetch run info, default to empty JSON on error # Check if the run matches our merged branch + run_display_title=$(echo "$run_info" | jq -r '.displayTitle // ""') run_head_branch=$(echo "$run_info" | jq -r '.headBranch // ""') run_head_sha=$(echo "$run_info" | jq -r '.headSha // ""') + echo >&2 " Run display title: $run_display_title" echo >&2 " Run head branch: $run_head_branch, head SHA: $run_head_sha" echo >&2 " Expected merged branch: $merged_branch_name, merge commit SHA: $merge_commit_sha" # For pull_request events, the workflow runs on the PR's head branch - # Match by the head branch being the merged branch name - if [[ "$run_head_branch" == "$merged_branch_name" ]]; then - echo >&2 "Found matching workflow run ID: $run_id (headBranch matches merged branch)" + # Match by the run name event action and the head branch. + if [[ "$run_display_title" == "$expected_run_name"* && "$run_head_branch" == "$merged_branch_name" ]]; then + echo >&2 "Found matching workflow run ID: $run_id (run name and headBranch match)" target_run_id="$run_id" break # Found the run, exit the inner loop else From e55fb75188f0584c825ed966944543de5caa8b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Mon, 1 Jun 2026 10:36:44 +0200 Subject: [PATCH 09/17] Make e2e workflow run names matchable The e2e waiters need to distinguish closed and synchronize pull_request runs that can share the same head branch. Quote the generated run name so the PR number is preserved instead of parsed as a YAML comment, and align the local e2e wrapper with the token generator's file-based private key lookup. --- .claude/run-e2e-tests.sh | 11 ++++++++--- .github/workflows/update-pr-stack.yml | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.claude/run-e2e-tests.sh b/.claude/run-e2e-tests.sh index 05f45ea..5769506 100755 --- a/.claude/run-e2e-tests.sh +++ b/.claude/run-e2e-tests.sh @@ -62,9 +62,14 @@ install_gh_cli() { acquire_token() { log_info "Acquiring GitHub App token..." - # Check if required environment variables are set - if [[ -z "$GH_APP_ID" ]] || [[ -z "$GH_APP_PRIVATE_KEY" ]]; then - log_error "Missing required environment variables: GH_APP_ID and/or GH_APP_PRIVATE_KEY" + # Check if required configuration is present. The token generator reads the + # private key from the repo-local .gh-app-private-key.pem file. + if [[ -z "$GH_APP_ID" ]]; then + log_error "Missing required environment variable: GH_APP_ID" + return 1 + fi + if [[ ! -f "$PROJECT_ROOT/.gh-app-private-key.pem" ]]; then + log_error "Missing required private key file: $PROJECT_ROOT/.gh-app-private-key.pem" return 1 fi diff --git a/.github/workflows/update-pr-stack.yml b/.github/workflows/update-pr-stack.yml index f35dcf2..87cf4ea 100644 --- a/.github/workflows/update-pr-stack.yml +++ b/.github/workflows/update-pr-stack.yml @@ -1,5 +1,5 @@ name: Update PR Stack -run-name: ${{ github.event.action }} PR #${{ github.event.pull_request.number }} - ${{ github.event.pull_request.title }} +run-name: "${{ github.event.action }} PR #${{ github.event.pull_request.number }} - ${{ github.event.pull_request.title }}" on: pull_request: From e59dad873b1d604927afae249cc3cac7702ec6d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Mon, 1 Jun 2026 10:56:07 +0200 Subject: [PATCH 10/17] Drop --ff-only from the conflict-resolution recipe The pre-pushed base merge leaves the user's local branch strictly behind origin, so the pull is always a fast-forward and the flag is a no-op in the expected case. Plain `git pull` is one less thing in the recipe. Co-Authored-By: Claude Opus 4.8 --- tests/test_e2e.sh | 6 +++--- update-pr-stack.sh | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_e2e.sh b/tests/test_e2e.sh index e46081a..0334bd6 100755 --- a/tests/test_e2e.sh +++ b/tests/test_e2e.sh @@ -238,12 +238,12 @@ follow_conflict_comment() { log_cmd git fetch origin log_cmd git checkout "$branch" - if ! echo "$comment" | grep -q "^git pull --ff-only origin $branch"; then - echo >&2 "❌ Verification Failed: comment does not tell the user to re-sync (git pull --ff-only origin $branch)." + if ! echo "$comment" | grep -q "^git pull origin $branch"; then + echo >&2 "❌ Verification Failed: comment does not tell the user to re-sync (git pull origin $branch)." echo >&2 "$comment" exit 1 fi - log_cmd git pull --ff-only origin "$branch" + log_cmd git pull origin "$branch" comment_merges=$(echo "$comment" | grep -E '^git merge' || true) if [[ -z "$comment_merges" ]]; then diff --git a/update-pr-stack.sh b/update-pr-stack.sh index 7e04c38..ab58abb 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -114,7 +114,7 @@ update_direct_target() { echo '```bash' echo "git fetch origin" echo "git switch $BRANCH" - echo "git pull --ff-only origin $BRANCH" + echo "git pull origin $BRANCH" for conflict in "${CONFLICTS[@]}"; do echo "git merge $conflict" echo "# ..." From 50aa4fe903c4e5830f093e59de13399869ed1d47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Mon, 1 Jun 2026 10:59:40 +0200 Subject: [PATCH 11/17] Drop the tautological git-pull assertion from the e2e helper The helper checked that the conflict comment contains `git pull origin `, but that line is static text the action always emits, so the assertion only restated the script. The helper still runs the pull. Co-Authored-By: Claude Opus 4.8 --- tests/test_e2e.sh | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_e2e.sh b/tests/test_e2e.sh index 0334bd6..5444b96 100755 --- a/tests/test_e2e.sh +++ b/tests/test_e2e.sh @@ -237,12 +237,6 @@ follow_conflict_comment() { log_cmd git fetch origin log_cmd git checkout "$branch" - - if ! echo "$comment" | grep -q "^git pull origin $branch"; then - echo >&2 "❌ Verification Failed: comment does not tell the user to re-sync (git pull origin $branch)." - echo >&2 "$comment" - exit 1 - fi log_cmd git pull origin "$branch" comment_merges=$(echo "$comment" | grep -E '^git merge' || true) From e9ed00bca6a2d203bfb09021a431b2f9aa2a8a1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Mon, 1 Jun 2026 11:14:37 +0200 Subject: [PATCH 12/17] Apply suggestion from @Phlogistique --- update-pr-stack.sh | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/update-pr-stack.sh b/update-pr-stack.sh index da03bb5..4fd3052 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -112,13 +112,11 @@ update_direct_target() { echo echo "#### How to resolve" for i in "${!CONFLICTS[@]}"; do + echo '```bash' if [[ "$i" -eq 0 ]]; then - echo '```bash' echo "git fetch origin" echo "git switch $BRANCH" echo "git pull origin $BRANCH" - else - echo '```bash' fi echo "git merge ${CONFLICTS[$i]}" echo '```' From b0d933777768c739c5a46fbf193c643b10935270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Mon, 1 Jun 2026 11:14:49 +0200 Subject: [PATCH 13/17] Apply suggestion from @Phlogistique --- update-pr-stack.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/update-pr-stack.sh b/update-pr-stack.sh index 4fd3052..c0fd441 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -121,7 +121,7 @@ update_direct_target() { echo "git merge ${CONFLICTS[$i]}" echo '```' echo - echo 'If this stops with conflicts, fix them (for instance with `git mergetool`), then run `git commit` before continuing.' + echo 'Fix the conflicts (for instance with `git mergetool`), then run `git commit` before continuing.' echo done echo '```bash' From 1663988cf5fa3134e47a44a5cc5dc7e9293c3343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Mon, 1 Jun 2026 11:21:24 +0200 Subject: [PATCH 14/17] Apply suggestion from @Phlogistique Lift the first conflict block's fetch/switch/pull out of the loop and open the next block at each iteration's tail, dropping the per-iteration `if [[ "$i" -eq 0 ]]` conditional. Rendered comment is unchanged. Co-Authored-By: Claude Opus 4.8 --- update-pr-stack.sh | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/update-pr-stack.sh b/update-pr-stack.sh index c0fd441..c000f41 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -111,20 +111,19 @@ update_direct_target() { echo " into this branch while updating the pull request stack and hit conflicts." echo echo "#### How to resolve" + echo '```bash' + echo "git fetch origin" + echo "git switch $BRANCH" + echo "git pull origin $BRANCH" + for i in "${!CONFLICTS[@]}"; do - echo '```bash' - if [[ "$i" -eq 0 ]]; then - echo "git fetch origin" - echo "git switch $BRANCH" - echo "git pull origin $BRANCH" - fi echo "git merge ${CONFLICTS[$i]}" echo '```' echo echo 'Fix the conflicts (for instance with `git mergetool`), then run `git commit` before continuing.' echo + echo '```bash' done - echo '```bash' echo "git push origin $BRANCH" echo '```' echo From 3a0c8cde0724c8e516f644803cb156fbc2b3c7bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Mon, 1 Jun 2026 14:49:48 +0200 Subject: [PATCH 15/17] Address review: faithful conflict resolution, run-name matching, e2e helpers - follow_conflict_comment now rewrites only the conflicted region to a sentinel line, keeping changes the merge brought in cleanly. The previous whole-file `git checkout --ours` dropped any clean incoming change, so the test only matched a human resolution by accident of the fixtures. - The workflow-run waiters match the run-name prefix through " - ", so "PR #2" no longer prefix-matches "PR #20"; the head-branch tiebreaker is gone. - edit_and_commit / create_pr replace the stack-setup boilerplate repeated across every scenario. - Add the missing Scenario 3 entry to the top-of-file index. Co-Authored-By: Claude Opus 4.8 --- tests/test_e2e.sh | 340 ++++++++++++++++++---------------------------- 1 file changed, 133 insertions(+), 207 deletions(-) diff --git a/tests/test_e2e.sh b/tests/test_e2e.sh index b9b1d3d..b510424 100755 --- a/tests/test_e2e.sh +++ b/tests/test_e2e.sh @@ -98,6 +98,20 @@ # - Deletes feature2 branch (no other conflicted PRs depend on it) # - NOTE: feature4 is NOT updated (indirect children are not modified) # +# SCENARIO 3: Sibling Conflicts (Steps 16-22) +# ------------------------------------------- +# Tests that the old base branch is kept until ALL sibling PRs (multiple PRs +# from the same base) have resolved their conflicts. +# +# Setup: +# - Create main <- feature5 <- (feature6, feature7) parallel children +# - feature6 and feature7 both modify line 5; main modifies line 5 differently +# +# Expected Behavior: +# - After merging feature5, both feature6 and feature7 conflict +# - feature5 is kept while either sibling is still conflicted +# - feature5 is deleted only after both siblings have resolved +# # SCENARIO 4: Multi-child with 0 conflicts (Steps 23-25) # ------------------------------------------------------- # Tests that when a PR with 2 children is merged and neither conflicts, @@ -213,6 +227,29 @@ compare_diffs() { fi } +# Apply one or more " " edits to file.txt and commit them. +edit_and_commit() { + local message=$1 + shift + while [[ $# -ge 2 ]]; do + sed -i "${1}s/.*/${2}/" file.txt + shift 2 + done + log_cmd git add file.txt + log_cmd git commit -m "$message" +} + +# Push and open a PR for it. The last two arguments name the caller +# variables that receive the PR URL and number. +create_pr() { + local branch=$1 base=$2 title=$3 body=$4 + local -n _url=$5 _num=$6 + log_cmd git push origin "$branch" + _url=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base "$base" --head "$branch" --title "$title" --body "$body") + _num=${_url##*/} + echo >&2 "Created PR #$_num: $_url" +} + get_conflict_comment() { local pr_url=$1 local pr_number=$2 @@ -273,7 +310,7 @@ assert_conflict_comment_merges() { follow_conflict_comment() { local comment=$1 local conflict_file=$2 - local resolution_description=$3 + local resolution_label=$3 local expected_conflicts=$4 local before_push_hook=${5:-} local in_block=false @@ -314,8 +351,22 @@ follow_conflict_comment() { fi if ! log_cmd bash -e -c "$block"; then if git diff --name-only --diff-filter=U | grep -qx "$conflict_file"; then - echo >&2 "Conflict during comment block; resolving by keeping $resolution_description..." - log_cmd git checkout --ours "$conflict_file" + # Resolve like a human editing the file: replace each conflicted + # region with one sentinel line and keep every other line, + # including changes the merge brought in cleanly from the other + # side. `git checkout --ours` would instead take our whole copy of + # the file and silently drop those clean incoming changes. + local sentinel="Conflict resolved on $resolution_label" + echo >&2 "Conflict during comment block; resolving to '$sentinel'..." + local resolved + resolved=$(mktemp) + awk -v repl="$sentinel" ' + /^<<<<<<>>>>>>/ { skip=0; next } + skip { next } + { print } + ' "$conflict_file" > "$resolved" + mv "$resolved" "$conflict_file" log_cmd git add "$conflict_file" log_cmd git commit --no-edit hit_conflicts=$((hit_conflicts + 1)) @@ -473,8 +524,10 @@ wait_for_synchronize_workflow() { echo >&2 " Run display title: $run_display_title" echo >&2 " Run head branch: $run_head_branch" - if [[ "$run_display_title" == "$expected_run_name"* && "$run_head_branch" == "$branch_name" ]]; then - echo >&2 "Found matching workflow run ID: $run_id (run name and branch match)" + # The run-name is " PR # - "; matching the + # prefix including " - " keeps "#2" from matching "#20". + if [[ "$run_display_title" == "$expected_run_name - "* ]]; then + echo >&2 "Found matching workflow run ID: $run_id (run name matches)" target_run_id="$run_id" break fi @@ -575,10 +628,10 @@ wait_for_workflow() { echo >&2 " Run head branch: $run_head_branch, head SHA: $run_head_sha" echo >&2 " Expected merged branch: $merged_branch_name, merge commit SHA: $merge_commit_sha" - # For pull_request events, the workflow runs on the PR's head branch - # Match by the run name event action and the head branch. - if [[ "$run_display_title" == "$expected_run_name"* && "$run_head_branch" == "$merged_branch_name" ]]; then - echo >&2 "Found matching workflow run ID: $run_id (run name and headBranch match)" + # The run-name is "<action> PR #<n> - <title>"; matching the + # prefix including " - " keeps "#2" from matching "#20". + if [[ "$run_display_title" == "$expected_run_name - "* ]]; then + echo >&2 "Found matching workflow run ID: $run_id (run name matches)" target_run_id="$run_id" break # Found the run, exit the inner loop else @@ -738,22 +791,13 @@ log_cmd gh api -X PATCH "/repos/$REPO_FULL_NAME" --input - <<< '{"delete_branch_ echo >&2 "0b. Creating 'no action' stack..." log_cmd git checkout main log_cmd git checkout -b noact_feature1 main -sed -i '3s/.*/NoAct Feature 1 line 3/' file.txt # Feature 1 changes LINE 3 -log_cmd git add file.txt -log_cmd git commit -m "NoAct: Add feature 1" -log_cmd git push origin noact_feature1 -NOACT_PR1_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base main --head noact_feature1 --title "NoAct Feature 1" --body "NoAct PR 1") -NOACT_PR1_NUM=$(echo "$NOACT_PR1_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created NoAct PR #$NOACT_PR1_NUM: $NOACT_PR1_URL" +# Each feature changes a DIFFERENT line so pollution is clearly visible +edit_and_commit "NoAct: Add feature 1" 3 "NoAct Feature 1 line 3" +create_pr noact_feature1 main "NoAct Feature 1" "NoAct PR 1" NOACT_PR1_URL NOACT_PR1_NUM log_cmd git checkout -b noact_feature2 noact_feature1 -sed -i '4s/.*/NoAct Feature 2 line 4/' file.txt # Feature 2 changes LINE 4 (different!) -log_cmd git add file.txt -log_cmd git commit -m "NoAct: Add feature 2" -log_cmd git push origin noact_feature2 -NOACT_PR2_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base noact_feature1 --head noact_feature2 --title "NoAct Feature 2" --body "NoAct PR 2, based on NoAct PR 1") -NOACT_PR2_NUM=$(echo "$NOACT_PR2_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created NoAct PR #$NOACT_PR2_NUM: $NOACT_PR2_URL" +edit_and_commit "NoAct: Add feature 2" 4 "NoAct Feature 2 line 4" +create_pr noact_feature2 noact_feature1 "NoAct Feature 2" "NoAct PR 2, based on NoAct PR 1" NOACT_PR2_URL NOACT_PR2_NUM # Capture initial diff (should show only 1 line change) echo >&2 "0c. Capturing initial diff for PR2..." @@ -825,44 +869,21 @@ echo >&2 "4. Creating stacked branches and PRs..." # - A unique line (for diff pollution visibility) # Branch feature1 (base: main) log_cmd git checkout -b feature1 main -sed -i '2s/.*/Feature 1 content line 2/' file.txt # Edit line 2 -log_cmd git add file.txt -log_cmd git commit -m "Add feature 1" -log_cmd git push origin feature1 -PR1_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base main --head feature1 --title "Feature 1" --body "This is PR 1") -PR1_NUM=$(echo "$PR1_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created PR #$PR1_NUM: $PR1_URL" +edit_and_commit "Add feature 1" 2 "Feature 1 content line 2" +create_pr feature1 main "Feature 1" "This is PR 1" PR1_URL PR1_NUM # Branch feature2 (base: feature1) log_cmd git checkout -b feature2 feature1 -sed -i '2s/.*/Feature 2 content line 2/' file.txt # Edit line 2 (shared) -sed -i '3s/.*/Feature 2 content line 3/' file.txt # Edit line 3 (unique) -log_cmd git add file.txt -log_cmd git commit -m "Add feature 2" -log_cmd git push origin feature2 -PR2_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature1 --head feature2 --title "Feature 2" --body "This is PR 2, based on PR 1") -PR2_NUM=$(echo "$PR2_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created PR #$PR2_NUM: $PR2_URL" +edit_and_commit "Add feature 2" 2 "Feature 2 content line 2" 3 "Feature 2 content line 3" +create_pr feature2 feature1 "Feature 2" "This is PR 2, based on PR 1" PR2_URL PR2_NUM # Branch feature3 (base: feature2) log_cmd git checkout -b feature3 feature2 -sed -i '2s/.*/Feature 3 content line 2/' file.txt # Edit line 2 (shared) -sed -i '4s/.*/Feature 3 content line 4/' file.txt # Edit line 4 (unique) -log_cmd git add file.txt -log_cmd git commit -m "Add feature 3" -log_cmd git push origin feature3 -PR3_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature2 --head feature3 --title "Feature 3" --body "This is PR 3, based on PR 2") -PR3_NUM=$(echo "$PR3_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created PR #$PR3_NUM: $PR3_URL" +edit_and_commit "Add feature 3" 2 "Feature 3 content line 2" 4 "Feature 3 content line 4" +create_pr feature3 feature2 "Feature 3" "This is PR 3, based on PR 2" PR3_URL PR3_NUM # Branch feature4 (base: feature3) - tests that indirect children's diffs remain correct log_cmd git checkout -b feature4 feature3 -sed -i '2s/.*/Feature 4 content line 2/' file.txt # Edit line 2 (shared) -sed -i '5s/.*/Feature 4 content line 5/' file.txt # Edit line 5 (unique) -log_cmd git add file.txt -log_cmd git commit -m "Add feature 4" -log_cmd git push origin feature4 -PR4_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature3 --head feature4 --title "Feature 4" --body "This is PR 4, based on PR 3 (indirect child, tests diff preservation)") -PR4_NUM=$(echo "$PR4_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created PR #$PR4_NUM: $PR4_URL" +edit_and_commit "Add feature 4" 2 "Feature 4 content line 2" 5 "Feature 4 content line 5" +create_pr feature4 feature3 "Feature 4" "This is PR 4, based on PR 3 (indirect child, tests diff preservation)" PR4_URL PR4_NUM # Capture initial diffs for diff validation echo >&2 "Capturing initial diffs for diff validation..." @@ -957,17 +978,13 @@ echo >&2 "--- Testing Conflict Scenario (Merging PR2) ---" echo >&2 "8. Introducing conflicting changes..." # Change line 7 on feature3 (far from line 2 to avoid adjacent-line conflicts) log_cmd git checkout feature3 -sed -i '7s/.*/Feature 3 conflicting change line 7/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Conflict: Modify line 7 on feature3" +edit_and_commit "Conflict: Modify line 7 on feature3" 7 "Feature 3 conflicting change line 7" FEATURE3_CONFLICT_COMMIT_SHA=$(git rev-parse HEAD) # Store this SHA log_cmd git push origin feature3 # Change line 7 on main differently - this will conflict when rebasing feature3 after PR2 merge log_cmd git checkout main log_cmd git pull origin main # Pull latest changes from PR1 merge -sed -i '7s/.*/Main conflicting change line 7/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Conflict: Modify line 7 on main" +edit_and_commit "Conflict: Modify line 7 on main" 7 "Main conflicting change line 7" log_cmd git push origin main # 9. Trigger Action by Squash Merging PR2 (which is now based on the updated main from step 7) @@ -1074,7 +1091,7 @@ echo >&2 "12. Resolving conflict on feature3 by following the posted comment..." # then run it. Following the comment must leave feature3 cleanly mergeable into # its new base, or the synchronize-triggered continuation can never make progress # and the conflict label stays stuck. -follow_conflict_comment "$CONFLICT_COMMENT" file.txt "feature3's side" 1 +follow_conflict_comment "$CONFLICT_COMMENT" file.txt "feature3" 1 echo >&2 "Resolved file.txt content:" cat file.txt echo >&2 "Pushed resolved feature3." @@ -1145,11 +1162,11 @@ echo >&2 "✅ feature4 intentionally not updated (indirect child of resolved PR) # Verify the final content of file.txt on feature3 # Line 1: Original base # Line 2: From feature 3 commit ("Feature 3 content line 2") -# Line 7: From feature 3 conflict commit, kept during resolution ("Feature 3 conflicting change line 7") +# Line 7: The conflicting line, rewritten to the resolution sentinel log_cmd git checkout feature3 EXPECTED_CONTENT_LINE1="Base file content line 1" EXPECTED_CONTENT_LINE2="Feature 3 content line 2" -EXPECTED_CONTENT_LINE7="Feature 3 conflicting change line 7" +EXPECTED_CONTENT_LINE7="Conflict resolved on feature3" ACTUAL_CONTENT_LINE1=$(sed -n '1p' file.txt) ACTUAL_CONTENT_LINE2=$(sed -n '2p' file.txt) @@ -1198,41 +1215,24 @@ log_cmd git pull origin main # Create feature5 based on main (modifies line 2, no conflict with line 5) log_cmd git checkout -b feature5 main -sed -i '2s/.*/Feature 5 content line 2/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 5" -log_cmd git push origin feature5 -PR5_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base main --head feature5 --title "Feature 5" --body "This is PR 5") -PR5_NUM=$(echo "$PR5_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created PR #$PR5_NUM: $PR5_URL" +edit_and_commit "Add feature 5" 2 "Feature 5 content line 2" +create_pr feature5 main "Feature 5" "This is PR 5" PR5_URL PR5_NUM # Create feature6 based on feature5 (modifies line 5, will conflict with main) log_cmd git checkout -b feature6 feature5 -sed -i '5s/.*/Feature 6 conflicting content line 5/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 6 (modifies line 5)" -log_cmd git push origin feature6 -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") -PR6_NUM=$(echo "$PR6_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created PR #$PR6_NUM: $PR6_URL" +edit_and_commit "Add feature 6 (modifies line 5)" 5 "Feature 6 conflicting content line 5" +create_pr feature6 feature5 "Feature 6" "This is PR 6, sibling of PR 7" PR6_URL PR6_NUM # Create feature7 based on feature5 (also modifies line 5, will conflict with main) log_cmd git checkout feature5 log_cmd git checkout -b feature7 -sed -i '5s/.*/Feature 7 conflicting content line 5/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 7 (also modifies line 5)" -log_cmd git push origin feature7 -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") -PR7_NUM=$(echo "$PR7_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created PR #$PR7_NUM: $PR7_URL" +edit_and_commit "Add feature 7 (also modifies line 5)" 5 "Feature 7 conflicting content line 5" +create_pr feature7 feature5 "Feature 7" "This is PR 7, sibling of PR 6" PR7_URL PR7_NUM # Introduce conflicting change on main (line 5) - this will conflict with feature6/7 # when the action tries to merge SQUASH_COMMIT~ into them log_cmd git checkout main -sed -i '5s/.*/Main conflicting content line 5/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add conflicting change on main line 5" +edit_and_commit "Add conflicting change on main line 5" 5 "Main conflicting content line 5" log_cmd git push origin main # 17. Merge feature5 to trigger conflicts on both siblings @@ -1300,7 +1300,7 @@ assert_conflict_comment_merges "$PR7_CONFLICT_COMMENT" "$PRE_SQUASH_COMMIT5" # 19. Resolve first sibling (feature6) - feature5 should still be kept echo >&2 "19. Resolving first sibling (feature6) by following the posted comment..." -follow_conflict_comment "$PR6_CONFLICT_COMMENT" file.txt "feature6's side" 1 +follow_conflict_comment "$PR6_CONFLICT_COMMENT" file.txt "feature6" 1 # Wait for continuation workflow echo >&2 "Waiting for continuation workflow for feature6..." @@ -1340,7 +1340,7 @@ else fi assert_pr_changed_lines "$PR6_URL" "PR6 diff contains only its resolved conflict" "$(cat <<'EOF' -Main conflicting content line 5 -+Feature 6 conflicting content line 5 ++Conflict resolved on feature6 EOF )" @@ -1356,7 +1356,7 @@ fi # 21. Resolve second sibling (feature7) - now feature5 should be deleted echo >&2 "21. Resolving second sibling (feature7) by following the posted comment..." -follow_conflict_comment "$PR7_CONFLICT_COMMENT" file.txt "feature7's side" 1 +follow_conflict_comment "$PR7_CONFLICT_COMMENT" file.txt "feature7" 1 # Wait for continuation workflow echo >&2 "Waiting for continuation workflow for feature7..." @@ -1386,7 +1386,7 @@ else fi assert_pr_changed_lines "$PR7_URL" "PR7 diff contains only its resolved conflict" "$(cat <<'EOF' -Main conflicting content line 5 -+Feature 7 conflicting content line 5 ++Conflict resolved on feature7 EOF )" @@ -1416,23 +1416,13 @@ log_cmd git pull origin main # Create feature8 based on main log_cmd git checkout -b feature8 main -sed -i '2s/.*/Feature 8 content line 2/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 8" -log_cmd git push origin feature8 -PR8_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base main --head feature8 --title "Feature 8" --body "This is PR 8") -PR8_NUM=$(echo "$PR8_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created PR #$PR8_NUM: $PR8_URL" +edit_and_commit "Add feature 8" 2 "Feature 8 content line 2" +create_pr feature8 main "Feature 8" "This is PR 8" PR8_URL PR8_NUM # Create feature9 based on feature8 (modifies line 3 — no conflict) log_cmd git checkout -b feature9 feature8 -sed -i '3s/.*/Feature 9 content line 3/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 9 (modifies line 3)" -log_cmd git push origin feature9 -PR9_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature8 --head feature9 --title "Feature 9" --body "This is PR 9, child of PR 8") -PR9_NUM=$(echo "$PR9_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created PR #$PR9_NUM: $PR9_URL" +edit_and_commit "Add feature 9 (modifies line 3)" 3 "Feature 9 content line 3" +create_pr feature9 feature8 "Feature 9" "This is PR 9, child of PR 8" PR9_URL PR9_NUM # Capture PR9 diff before merge PR9_DIFF_BEFORE=$(get_pr_diff "$PR9_URL") @@ -1440,13 +1430,8 @@ PR9_DIFF_BEFORE=$(get_pr_diff "$PR9_URL") # Create feature10 based on feature8 (modifies line 4 — no conflict) log_cmd git checkout feature8 log_cmd git checkout -b feature10 -sed -i '4s/.*/Feature 10 content line 4/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 10 (modifies line 4)" -log_cmd git push origin feature10 -PR10_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature8 --head feature10 --title "Feature 10" --body "This is PR 10, child of PR 8") -PR10_NUM=$(echo "$PR10_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created PR #$PR10_NUM: $PR10_URL" +edit_and_commit "Add feature 10 (modifies line 4)" 4 "Feature 10 content line 4" +create_pr feature10 feature8 "Feature 10" "This is PR 10, child of PR 8" PR10_URL PR10_NUM # Capture PR10 diff before merge PR10_DIFF_BEFORE=$(get_pr_diff "$PR10_URL") @@ -1531,43 +1516,26 @@ log_cmd git pull origin main # Create feature11 based on main log_cmd git checkout -b feature11 main -sed -i '2s/.*/Feature 11 content line 2/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 11" -log_cmd git push origin feature11 -PR11_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base main --head feature11 --title "Feature 11" --body "This is PR 11") -PR11_NUM=$(echo "$PR11_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created PR #$PR11_NUM: $PR11_URL" +edit_and_commit "Add feature 11" 2 "Feature 11 content line 2" +create_pr feature11 main "Feature 11" "This is PR 11" PR11_URL PR11_NUM # Create feature12 based on feature11 (modifies line 5 — will conflict) log_cmd git checkout -b feature12 feature11 -sed -i '5s/.*/Feature 12 conflicting content line 5/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 12 (modifies line 5)" -log_cmd git push origin feature12 -PR12_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature11 --head feature12 --title "Feature 12" --body "This is PR 12, child of PR 11") -PR12_NUM=$(echo "$PR12_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created PR #$PR12_NUM: $PR12_URL" +edit_and_commit "Add feature 12 (modifies line 5)" 5 "Feature 12 conflicting content line 5" +create_pr feature12 feature11 "Feature 12" "This is PR 12, child of PR 11" PR12_URL PR12_NUM # Create feature13 based on feature11 (modifies line 6 — no conflict) log_cmd git checkout feature11 log_cmd git checkout -b feature13 -sed -i '14s/.*/Feature 13 content line 14/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 13 (modifies line 14)" -log_cmd git push origin feature13 -PR13_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature11 --head feature13 --title "Feature 13" --body "This is PR 13, child of PR 11") -PR13_NUM=$(echo "$PR13_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created PR #$PR13_NUM: $PR13_URL" +edit_and_commit "Add feature 13 (modifies line 14)" 14 "Feature 13 content line 14" +create_pr feature13 feature11 "Feature 13" "This is PR 13, child of PR 11" PR13_URL PR13_NUM # Capture PR13 diff before merge PR13_DIFF_BEFORE=$(get_pr_diff "$PR13_URL") # Push conflicting change to main (line 5) log_cmd git checkout main -sed -i '5s/.*/Main conflicting content line 5 for scenario 5/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add conflicting change on main line 5 (scenario 5)" +edit_and_commit "Add conflicting change on main line 5 (scenario 5)" 5 "Main conflicting content line 5 for scenario 5" log_cmd git push origin main # 27. Merge feature11 to trigger mixed outcome @@ -1657,13 +1625,8 @@ log_cmd git checkout main log_cmd git pull origin main log_cmd git checkout -b feature14 main -sed -i '2s/.*/Feature 14 content line 2/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 14" -log_cmd git push origin feature14 -PR14_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base main --head feature14 --title "Feature 14" --body "This is PR 14, no children") -PR14_NUM=$(echo "$PR14_URL" | awk -F'/' '{print $NF}') -echo >&2 "Created PR #$PR14_NUM: $PR14_URL" +edit_and_commit "Add feature 14" 2 "Feature 14 content line 2" +create_pr feature14 main "Feature 14" "This is PR 14, no children" PR14_URL PR14_NUM # 30. Merge feature14 echo >&2 "30. Squash merging PR #$PR14_NUM (feature14, no children)..." @@ -1707,26 +1670,16 @@ log_cmd git checkout main log_cmd git pull origin main log_cmd git checkout -b feature15 main -sed -i '8s/.*/Feature 15 content line 8/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 15" -log_cmd git push origin feature15 -PR15_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base main --head feature15 --title "Feature 15" --body "Base conflict parent") -PR15_NUM=$(echo "$PR15_URL" | awk -F'/' '{print $NF}') +edit_and_commit "Add feature 15" 8 "Feature 15 content line 8" +create_pr feature15 main "Feature 15" "Base conflict parent" PR15_URL PR15_NUM log_cmd git checkout -b feature16 feature15 -sed -i '9s/.*/Feature 16 base conflict line 9/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 16" +edit_and_commit "Add feature 16" 9 "Feature 16 base conflict line 9" FEATURE16_BEFORE_CONFLICT=$(git rev-parse HEAD) -log_cmd git push origin feature16 -PR16_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature15 --head feature16 --title "Feature 16" --body "Base conflict child") -PR16_NUM=$(echo "$PR16_URL" | awk -F'/' '{print $NF}') +create_pr feature16 feature15 "Feature 16" "Base conflict child" PR16_URL PR16_NUM log_cmd git checkout feature15 -sed -i '9s/.*/Feature 15 base conflict line 9/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Create base conflict for feature16" +edit_and_commit "Create base conflict for feature16" 9 "Feature 15 base conflict line 9" log_cmd git push origin feature15 merge_pr_with_retry "$PR15_URL" @@ -1745,7 +1698,7 @@ else fi PR16_CONFLICT_COMMENT=$(get_conflict_comment "$PR16_URL" "$PR16_NUM" 1) assert_conflict_comment_merges "$PR16_CONFLICT_COMMENT" "origin/feature15" -follow_conflict_comment "$PR16_CONFLICT_COMMENT" file.txt "feature16's side" 1 +follow_conflict_comment "$PR16_CONFLICT_COMMENT" file.txt "feature16" 1 if ! wait_for_synchronize_workflow "$PR16_NUM" "feature16" "success"; then echo >&2 "Continuation workflow for feature16 did not complete successfully." exit 1 @@ -1760,7 +1713,7 @@ else fi assert_pr_changed_lines "$PR16_URL" "PR16 diff contains only its resolved base conflict" "$(cat <<'EOF' -Feature 15 base conflict line 9 -+Feature 16 base conflict line 9 ++Conflict resolved on feature16 EOF )" @@ -1770,33 +1723,20 @@ log_cmd git checkout main log_cmd git pull origin main log_cmd git checkout -b feature17 main -sed -i '8s/.*/Feature 17 content line 8/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 17" -log_cmd git push origin feature17 -PR17_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base main --head feature17 --title "Feature 17" --body "Base and trunk conflict parent") -PR17_NUM=$(echo "$PR17_URL" | awk -F'/' '{print $NF}') +edit_and_commit "Add feature 17" 8 "Feature 17 content line 8" +create_pr feature17 main "Feature 17" "Base and trunk conflict parent" PR17_URL PR17_NUM log_cmd git checkout -b feature18 feature17 -sed -i '9s/.*/Feature 18 base conflict line 9/' file.txt -sed -i '13s/.*/Feature 18 trunk conflict line 13/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 18" +edit_and_commit "Add feature 18" 9 "Feature 18 base conflict line 9" 13 "Feature 18 trunk conflict line 13" FEATURE18_BEFORE_CONFLICT=$(git rev-parse HEAD) -log_cmd git push origin feature18 -PR18_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature17 --head feature18 --title "Feature 18" --body "Base and trunk conflict child") -PR18_NUM=$(echo "$PR18_URL" | awk -F'/' '{print $NF}') +create_pr feature18 feature17 "Feature 18" "Base and trunk conflict child" PR18_URL PR18_NUM log_cmd git checkout feature17 -sed -i '9s/.*/Feature 17 base conflict line 9/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Create base conflict for feature18" +edit_and_commit "Create base conflict for feature18" 9 "Feature 17 base conflict line 9" log_cmd git push origin feature17 log_cmd git checkout main -sed -i '13s/.*/Main trunk conflict line 13/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Create trunk conflict for feature18" +edit_and_commit "Create trunk conflict for feature18" 13 "Main trunk conflict line 13" log_cmd git push origin main merge_pr_with_retry "$PR17_URL" @@ -1816,16 +1756,16 @@ fi PR18_CONFLICT_COMMENT=$(get_conflict_comment "$PR18_URL" "$PR18_NUM" 1) PRE_SQUASH_COMMIT17=$(git rev-parse "$MERGE_COMMIT_SHA17~") assert_conflict_comment_merges "$PR18_CONFLICT_COMMENT" "origin/feature17" "$PRE_SQUASH_COMMIT17" -follow_conflict_comment "$PR18_CONFLICT_COMMENT" file.txt "feature18's side" 2 +follow_conflict_comment "$PR18_CONFLICT_COMMENT" file.txt "feature18" 2 if ! wait_for_synchronize_workflow "$PR18_NUM" "feature18" "success"; then echo >&2 "Continuation workflow for feature18 did not complete successfully." exit 1 fi assert_pr_changed_lines "$PR18_URL" "PR18 diff contains only its two resolved conflicts" "$(cat <<'EOF' -Feature 17 base conflict line 9 -+Feature 18 base conflict line 9 ++Conflict resolved on feature18 -Main trunk conflict line 13 -+Feature 18 trunk conflict line 13 ++Conflict resolved on feature18 EOF )" @@ -1835,32 +1775,20 @@ log_cmd git checkout main log_cmd git pull origin main log_cmd git checkout -b feature19 main -sed -i '8s/.*/Feature 19 content line 8/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 19" -log_cmd git push origin feature19 -PR19_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base main --head feature19 --title "Feature 19" --body "Follow-up trunk conflict parent") -PR19_NUM=$(echo "$PR19_URL" | awk -F'/' '{print $NF}') +edit_and_commit "Add feature 19" 8 "Feature 19 content line 8" +create_pr feature19 main "Feature 19" "Follow-up trunk conflict parent" PR19_URL PR19_NUM log_cmd git checkout -b feature20 feature19 -sed -i '9s/.*/Feature 20 base conflict line 9/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Add feature 20" +edit_and_commit "Add feature 20" 9 "Feature 20 base conflict line 9" FEATURE20_BEFORE_CONFLICT=$(git rev-parse HEAD) -log_cmd git push origin feature20 -PR20_URL=$(log_cmd gh pr create --repo "$REPO_FULL_NAME" --base feature19 --head feature20 --title "Feature 20" --body "Follow-up trunk conflict child") -PR20_NUM=$(echo "$PR20_URL" | awk -F'/' '{print $NF}') +create_pr feature20 feature19 "Feature 20" "Follow-up trunk conflict child" PR20_URL PR20_NUM log_cmd git checkout feature19 -sed -i '9s/.*/Feature 19 base conflict line 9/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Create base conflict for feature20" +edit_and_commit "Create base conflict for feature20" 9 "Feature 19 base conflict line 9" log_cmd git push origin feature19 log_cmd git checkout main -sed -i '13s/.*/Main follow-up trunk conflict line 13/' file.txt -log_cmd git add file.txt -log_cmd git commit -m "Create follow-up trunk conflict for feature20" +edit_and_commit "Create follow-up trunk conflict for feature20" 13 "Main follow-up trunk conflict line 13" log_cmd git push origin main merge_pr_with_retry "$PR19_URL" @@ -1881,12 +1809,10 @@ PR20_FIRST_CONFLICT_COMMENT=$(get_conflict_comment "$PR20_URL" "$PR20_NUM" 1) assert_conflict_comment_merges "$PR20_FIRST_CONFLICT_COMMENT" "origin/feature19" introduce_feature20_trunk_conflict_before_push() { - sed -i '13s/.*/Feature 20 follow-up trunk conflict line 13/' file.txt - log_cmd git add file.txt - log_cmd git commit -m "Introduce follow-up trunk conflict" + edit_and_commit "Introduce follow-up trunk conflict" 13 "Feature 20 follow-up trunk conflict line 13" } -follow_conflict_comment "$PR20_FIRST_CONFLICT_COMMENT" file.txt "feature20's side" 1 introduce_feature20_trunk_conflict_before_push +follow_conflict_comment "$PR20_FIRST_CONFLICT_COMMENT" file.txt "feature20" 1 introduce_feature20_trunk_conflict_before_push if ! wait_for_synchronize_workflow "$PR20_NUM" "feature20" "failure"; then echo >&2 "Expected continuation workflow for feature20 to fail with a new trunk conflict." exit 1 @@ -1894,16 +1820,16 @@ fi PR20_SECOND_CONFLICT_COMMENT=$(get_conflict_comment "$PR20_URL" "$PR20_NUM" 2) PRE_SQUASH_COMMIT19=$(git rev-parse "$MERGE_COMMIT_SHA19~") assert_conflict_comment_merges "$PR20_SECOND_CONFLICT_COMMENT" "$PRE_SQUASH_COMMIT19" -follow_conflict_comment "$PR20_SECOND_CONFLICT_COMMENT" file.txt "feature20's side" 1 +follow_conflict_comment "$PR20_SECOND_CONFLICT_COMMENT" file.txt "feature20" 1 if ! wait_for_synchronize_workflow "$PR20_NUM" "feature20" "success"; then echo >&2 "Continuation workflow for feature20 did not complete successfully after trunk conflict resolution." exit 1 fi assert_pr_changed_lines "$PR20_URL" "PR20 diff contains only the base and follow-up trunk resolutions" "$(cat <<'EOF' -Feature 19 base conflict line 9 -+Feature 20 base conflict line 9 ++Conflict resolved on feature20 -Main follow-up trunk conflict line 13 -+Feature 20 follow-up trunk conflict line 13 ++Conflict resolved on feature20 EOF )" From fd933f0b6a27fe514bc104421e30f7704d773c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= <nrubinstein@scortex.io> Date: Mon, 1 Jun 2026 15:02:44 +0200 Subject: [PATCH 16/17] Read GitHub App private key from the environment again Reverts #34's switch to a .gh-app-private-key.pem file. The env-var form (GH_APP_PRIVATE_KEY) lets the token generator run in Claude Code web, where the key is set as an environment variable rather than a file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .claude/run-e2e-tests.sh | 11 +++-------- tests/get_github_app_token.py | 19 ++++++------------- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/.claude/run-e2e-tests.sh b/.claude/run-e2e-tests.sh index 5769506..05f45ea 100755 --- a/.claude/run-e2e-tests.sh +++ b/.claude/run-e2e-tests.sh @@ -62,14 +62,9 @@ install_gh_cli() { acquire_token() { log_info "Acquiring GitHub App token..." - # Check if required configuration is present. The token generator reads the - # private key from the repo-local .gh-app-private-key.pem file. - if [[ -z "$GH_APP_ID" ]]; then - log_error "Missing required environment variable: GH_APP_ID" - return 1 - fi - if [[ ! -f "$PROJECT_ROOT/.gh-app-private-key.pem" ]]; then - log_error "Missing required private key file: $PROJECT_ROOT/.gh-app-private-key.pem" + # Check if required environment variables are set + if [[ -z "$GH_APP_ID" ]] || [[ -z "$GH_APP_PRIVATE_KEY" ]]; then + log_error "Missing required environment variables: GH_APP_ID and/or GH_APP_PRIVATE_KEY" return 1 fi diff --git a/tests/get_github_app_token.py b/tests/get_github_app_token.py index 44bdd53..4eacd03 100755 --- a/tests/get_github_app_token.py +++ b/tests/get_github_app_token.py @@ -10,14 +10,12 @@ """ GitHub App Token Generator with caching -Generates an installation access token from GitHub App credentials. +Generates an installation access token from GitHub App credentials in environment. Caches tokens and reuses them until they expire (with 5-minute buffer). Environment variables required: - GH_APP_ID: GitHub App ID - -Files required: -- .gh-app-private-key.pem: PEM private key +- GH_APP_PRIVATE_KEY: PEM private key Usage: # Run with uv (automatically installs dependencies) @@ -42,7 +40,6 @@ # Cache file location CACHE_FILE = Path("/tmp/gh_app_token_cache.json") -PRIVATE_KEY_FILE = Path(__file__).resolve().parents[1] / ".gh-app-private-key.pem" # Buffer time before expiration (5 minutes) EXPIRATION_BUFFER_SECONDS = 300 @@ -99,18 +96,14 @@ def save_token_to_cache(token, expires_at): def generate_installation_token(): """Generate a new installation access token for the GitHub App.""" + # Get credentials from environment app_id = os.getenv("GH_APP_ID") + private_key = os.getenv("GH_APP_PRIVATE_KEY") - if not app_id: - print("Error: Missing GH_APP_ID", file=sys.stderr) - sys.exit(1) - - if not PRIVATE_KEY_FILE.exists(): - print(f"Error: Missing private key file: {PRIVATE_KEY_FILE}", file=sys.stderr) + if not all([app_id, private_key]): + print("Error: Missing GH_APP_ID or GH_APP_PRIVATE_KEY", file=sys.stderr) sys.exit(1) - private_key = PRIVATE_KEY_FILE.read_text() - # Generate JWT now = int(time.time()) payload = { From 131d974913da191169a050cbe8e4c1e6061f9073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= <nrubinstein@scortex.io> Date: Mon, 1 Jun 2026 15:06:45 +0200 Subject: [PATCH 17/17] Read the GitHub App private key from the environment Reverts #34. Reading GH_APP_PRIVATE_KEY from the environment instead of a .gh-app-private-key.pem file lets the token generator run in Claude Code web, where the key is provided as an environment variable rather than a file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- tests/get_github_app_token.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/tests/get_github_app_token.py b/tests/get_github_app_token.py index 44bdd53..4eacd03 100755 --- a/tests/get_github_app_token.py +++ b/tests/get_github_app_token.py @@ -10,14 +10,12 @@ """ GitHub App Token Generator with caching -Generates an installation access token from GitHub App credentials. +Generates an installation access token from GitHub App credentials in environment. Caches tokens and reuses them until they expire (with 5-minute buffer). Environment variables required: - GH_APP_ID: GitHub App ID - -Files required: -- .gh-app-private-key.pem: PEM private key +- GH_APP_PRIVATE_KEY: PEM private key Usage: # Run with uv (automatically installs dependencies) @@ -42,7 +40,6 @@ # Cache file location CACHE_FILE = Path("/tmp/gh_app_token_cache.json") -PRIVATE_KEY_FILE = Path(__file__).resolve().parents[1] / ".gh-app-private-key.pem" # Buffer time before expiration (5 minutes) EXPIRATION_BUFFER_SECONDS = 300 @@ -99,18 +96,14 @@ def save_token_to_cache(token, expires_at): def generate_installation_token(): """Generate a new installation access token for the GitHub App.""" + # Get credentials from environment app_id = os.getenv("GH_APP_ID") + private_key = os.getenv("GH_APP_PRIVATE_KEY") - if not app_id: - print("Error: Missing GH_APP_ID", file=sys.stderr) - sys.exit(1) - - if not PRIVATE_KEY_FILE.exists(): - print(f"Error: Missing private key file: {PRIVATE_KEY_FILE}", file=sys.stderr) + if not all([app_id, private_key]): + print("Error: Missing GH_APP_ID or GH_APP_PRIVATE_KEY", file=sys.stderr) sys.exit(1) - private_key = PRIVATE_KEY_FILE.read_text() - # Generate JWT now = int(time.time()) payload = {