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