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