|
| 1 | +#!/bin/bash |
| 2 | +# |
| 3 | +# Regression test for the conflict-resolution comment. |
| 4 | +# |
| 5 | +# THE BUG: |
| 6 | +# update_direct_target() merges several refs into a descendant branch: |
| 7 | +# 1. origin/$MERGED_BRANCH (the parent PR's tip) |
| 8 | +# 2. $SQUASH_COMMIT~ (the target branch just before the squash) |
| 9 | +# 3. -s ours $SQUASH_COMMIT (record the squash as merged, keep the tree) |
| 10 | +# When merge #1 is clean but merge #2 conflicts, only the conflicting ref used |
| 11 | +# to be listed in the "How to resolve" steps. A human following the comment |
| 12 | +# literally would merge ONLY $SQUASH_COMMIT~, ending up with a branch that is |
| 13 | +# missing both the parent's content (merge #1) and the squash ancestry (#3). |
| 14 | +# Such a branch is not cleanly mergeable into its new base, so the |
| 15 | +# synchronize-triggered continuation never makes progress and the label stays |
| 16 | +# stuck on the PR. |
| 17 | +# |
| 18 | +# This test reproduces exactly that topology, replays the merge commands the |
| 19 | +# comment tells the human to run, and asserts the branch ends up mergeable |
| 20 | +# (squash commit and parent content are both present). |
| 21 | +# |
| 22 | +# It runs fully offline using mock gh/git, like the other unit tests. |
| 23 | + |
| 24 | +set -e |
| 25 | + |
| 26 | +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 27 | +source "$SCRIPT_DIR/../command_utils.sh" |
| 28 | + |
| 29 | +simulate_push() { |
| 30 | + log_cmd git update-ref "refs/remotes/origin/$1" "$1" |
| 31 | +} |
| 32 | + |
| 33 | +TEST_REPO=$(mktemp -d) |
| 34 | +cd "$TEST_REPO" |
| 35 | +echo "Created test repo at $TEST_REPO" |
| 36 | + |
| 37 | +# A mock gh that records the conflict comment and otherwise stays quiet. |
| 38 | +COMMENT_FILE="$TEST_REPO/conflict_comment.md" |
| 39 | +cat > "$TEST_REPO/mock_gh.sh" <<MOCK |
| 40 | +#!/bin/bash |
| 41 | +if [[ "\$1" == "pr" && "\$2" == "list" ]]; then |
| 42 | + # Only feature2 is a direct child of feature1 in this scenario. |
| 43 | + base="" |
| 44 | + for ((i=1; i<=\$#; i++)); do |
| 45 | + if [[ "\${!i}" == "--base" ]]; then next=\$((i+1)); base="\${!next}"; fi |
| 46 | + done |
| 47 | + [[ "\$base" == "feature1" ]] && echo "feature2" |
| 48 | +elif [[ "\$1" == "pr" && "\$2" == "comment" ]]; then |
| 49 | + cat > "$COMMENT_FILE" |
| 50 | +elif [[ "\$1" == "label" || ( "\$1" == "pr" && "\$2" == "edit" ) ]]; then |
| 51 | + : # ignore label creation / edits |
| 52 | +else |
| 53 | + echo "Unknown gh command: \$@" >&2 |
| 54 | + exit 1 |
| 55 | +fi |
| 56 | +MOCK |
| 57 | +chmod +x "$TEST_REPO/mock_gh.sh" |
| 58 | + |
| 59 | +# Replaying merges may create non-ff merge commits; never open an editor. |
| 60 | +export GIT_EDITOR=true |
| 61 | + |
| 62 | +log_cmd git init -b main |
| 63 | +log_cmd git config user.email "test@example.com" |
| 64 | +log_cmd git config user.name "Test User" |
| 65 | + |
| 66 | +# 12-line file so changes on different lines do not collide. |
| 67 | +for i in $(seq 1 12); do echo "line $i" >> file.txt; done |
| 68 | +log_cmd git add file.txt |
| 69 | +log_cmd git commit -m "Initial commit" |
| 70 | +simulate_push main |
| 71 | + |
| 72 | +# feature1 (the parent PR): first commit on line 2. |
| 73 | +log_cmd git checkout -b feature1 |
| 74 | +sed -i '2s/.*/f1 commit 1/' file.txt |
| 75 | +log_cmd git add file.txt |
| 76 | +log_cmd git commit -m "f1: commit 1" |
| 77 | +simulate_push feature1 |
| 78 | + |
| 79 | +# feature2 (the child PR): branched off feature1, changes line 9. |
| 80 | +log_cmd git checkout -b feature2 |
| 81 | +sed -i '9s/.*/f2 content line 9/' file.txt |
| 82 | +log_cmd git add file.txt |
| 83 | +log_cmd git commit -m "f2: change line 9" |
| 84 | +simulate_push feature2 |
| 85 | + |
| 86 | +# feature1 advances AFTER feature2 branched (line 4). feature2 never picks |
| 87 | +# this up, so merging origin/feature1 into feature2 is clean and substantive. |
| 88 | +log_cmd git checkout feature1 |
| 89 | +sed -i '4s/.*/f1 commit 2/' file.txt |
| 90 | +log_cmd git add file.txt |
| 91 | +log_cmd git commit -m "f1: commit 2" |
| 92 | +simulate_push feature1 |
| 93 | + |
| 94 | +# main advances on line 9 with content that conflicts with feature2. |
| 95 | +log_cmd git checkout main |
| 96 | +sed -i '9s/.*/main conflicting line 9/' file.txt |
| 97 | +log_cmd git add file.txt |
| 98 | +log_cmd git commit -m "main: conflicting change on line 9" |
| 99 | +simulate_push main |
| 100 | + |
| 101 | +# Squash-merge feature1 into main. SQUASH_COMMIT~ is now main carrying the |
| 102 | +# conflicting line-9 change; merging it into feature2 conflicts. |
| 103 | +log_cmd git checkout main |
| 104 | +log_cmd git merge --squash feature1 |
| 105 | +log_cmd git commit -m "Squash merge feature1" |
| 106 | +SQUASH_COMMIT=$(git rev-parse HEAD) |
| 107 | +simulate_push main |
| 108 | +echo "Squash commit: $SQUASH_COMMIT" |
| 109 | + |
| 110 | +echo "" |
| 111 | +echo "=== Graph before running the action ===" |
| 112 | +log_cmd git log --graph --oneline --all |
| 113 | +echo "" |
| 114 | + |
| 115 | +# Run the action. We expect a conflict and a posted comment (exit 1 from |
| 116 | +# update_direct_target is swallowed by main, which returns 0). |
| 117 | +log_cmd \ |
| 118 | + env \ |
| 119 | + SQUASH_COMMIT="$SQUASH_COMMIT" \ |
| 120 | + MERGED_BRANCH=feature1 \ |
| 121 | + TARGET_BRANCH=main \ |
| 122 | + GH="$TEST_REPO/mock_gh.sh" \ |
| 123 | + GIT="$SCRIPT_DIR/mock_git.sh" \ |
| 124 | + "$SCRIPT_DIR/../update-pr-stack.sh" |
| 125 | + |
| 126 | +if [[ ! -f "$COMMENT_FILE" ]]; then |
| 127 | + echo "❌ No conflict comment was posted; scenario did not trigger a conflict" |
| 128 | + exit 1 |
| 129 | +fi |
| 130 | + |
| 131 | +echo "" |
| 132 | +echo "=== Conflict comment posted to the PR ===" |
| 133 | +cat "$COMMENT_FILE" |
| 134 | +echo "" |
| 135 | + |
| 136 | +# Simulate the human doing EXACTLY what the comment says: start from the |
| 137 | +# branch as it exists on the remote (the action never pushes its local merge), |
| 138 | +# then run each `git merge` line, resolving any conflict by keeping our side. |
| 139 | +log_cmd git checkout feature2 |
| 140 | +log_cmd git reset --hard origin/feature2 |
| 141 | + |
| 142 | +MERGE_CMDS=$(grep -E '^git merge' "$COMMENT_FILE" || true) |
| 143 | +if [[ -z "$MERGE_CMDS" ]]; then |
| 144 | + echo "❌ Comment lists no 'git merge' commands to follow" |
| 145 | + exit 1 |
| 146 | +fi |
| 147 | + |
| 148 | +while IFS= read -r cmd; do |
| 149 | + echo "Human runs: $cmd" |
| 150 | + if ! log_cmd bash -c "$cmd"; then |
| 151 | + echo "Resolving conflict by keeping our (feature2) side..." |
| 152 | + log_cmd git checkout --ours -- file.txt |
| 153 | + log_cmd git add file.txt |
| 154 | + log_cmd git commit --no-edit |
| 155 | + fi |
| 156 | +done <<< "$MERGE_CMDS" |
| 157 | + |
| 158 | +simulate_push feature2 |
| 159 | + |
| 160 | +echo "" |
| 161 | +echo "=== feature2 after following the comment ===" |
| 162 | +log_cmd git log --graph --oneline --all |
| 163 | +echo "" |
| 164 | + |
| 165 | +# After following the comment, the branch must be cleanly mergeable into its |
| 166 | +# new base (main). That requires the squash commit to be an ancestor (so the |
| 167 | +# diff against main is clean) and the parent's advanced content to be present. |
| 168 | +FAILED=0 |
| 169 | + |
| 170 | +if log_cmd git merge-base --is-ancestor "$SQUASH_COMMIT" feature2; then |
| 171 | + echo "✅ feature2 includes the squash commit (mergeable into main)" |
| 172 | +else |
| 173 | + echo "❌ BUG: feature2 is missing the squash commit after following the comment." |
| 174 | + echo " The PR cannot be cleanly merged into its new base; the continuation" |
| 175 | + echo " workflow will never make progress and the conflict label stays stuck." |
| 176 | + FAILED=1 |
| 177 | +fi |
| 178 | + |
| 179 | +if log_cmd git merge-base --is-ancestor origin/feature1 feature2; then |
| 180 | + echo "✅ feature2 includes the parent branch's advanced content" |
| 181 | +else |
| 182 | + echo "❌ BUG: feature2 is missing origin/feature1's content (merge #1 was" |
| 183 | + echo " omitted from the comment)." |
| 184 | + FAILED=1 |
| 185 | +fi |
| 186 | + |
| 187 | +if [[ "$FAILED" -ne 0 ]]; then |
| 188 | + exit 1 |
| 189 | +fi |
| 190 | + |
| 191 | +echo "" |
| 192 | +echo "All conflict-comment-completeness tests passed! 🎉" |
| 193 | +echo "Test repository remains at: $TEST_REPO for inspection" |
0 commit comments