From a1fba5f31fd47e6098dae4e33eed5a29c0f08f99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Fri, 6 Nov 2026 18:00:18 +0100 Subject: [PATCH 1/3] Post two plain merges in the conflict comment instead of git-merge-onto Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 8 +-- ...h => test_conflict_two_step_resolution.sh} | 46 +++++++++------- tests/test_e2e.sh | 38 ++++++------- update-pr-stack.sh | 55 +++++++++++-------- 4 files changed, 78 insertions(+), 69 deletions(-) rename tests/{test_conflict_absorbed_resolution.sh => test_conflict_two_step_resolution.sh} (78%) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 52fd93e..df1ca3b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,7 +18,7 @@ jobs: bash tests/test_rebase_workflow.sh bash tests/test_mixed_workflows.sh bash tests/test_conflict_resolution_resume.sh - bash tests/test_conflict_absorbed_resolution.sh + bash tests/test_conflict_two_step_resolution.sh bash tests/test_merge_commit_merge.sh bash tests/test_rebase_merge.sh bash tests/test_merged_branch_moved.sh @@ -40,12 +40,6 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} owner: autorestack-test - # The e2e simulates a human resolving a conflict by running the posted - # comment, which calls `uvx git-merge-onto`. The action itself uses the - # vendored copy via python3 and needs no uv. - - name: Install uv - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - - name: Run e2e tests env: GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/tests/test_conflict_absorbed_resolution.sh b/tests/test_conflict_two_step_resolution.sh similarity index 78% rename from tests/test_conflict_absorbed_resolution.sh rename to tests/test_conflict_two_step_resolution.sh index 7a4f7f5..c796255 100644 --- a/tests/test_conflict_absorbed_resolution.sh +++ b/tests/test_conflict_two_step_resolution.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Replays the incident that motivated --absorbed (scortexio/gh-stack-mv#36): +# Replays the incident that stranded a conflicted PR (scortexio/gh-stack-mv#36): # # 1. main <- feature1 <- feature2, where feature1 advanced AFTER feature2 # forked (in the incident, autorestack itself advanced it when a @@ -8,7 +8,8 @@ # feature2. # 2. feature1 is squash-merged; the action's re-parent of feature2 conflicts, # so the action posts the resolution comment and stops. -# 3. The user resolves by running the posted re-parent with --absorbed. +# 3. The user resolves with the two posted merges: origin/feature1, then +# origin/main. # # The resolution must descend from origin/feature1's tip: feature2's PR is # still based on feature1 at that point, and GitHub creates no pull_request @@ -102,28 +103,33 @@ if [[ "$(git rev-parse feature2)" != "$FEATURE2_BEFORE" ]]; then echo "❌ feature2 must not move on a conflict" exit 1 fi -if ! grep -q -- "--absorbed" "$COMMENT_FILE"; then - echo "❌ The posted resolution command must use --absorbed" - cat "$COMMENT_FILE" - exit 1 -fi -echo "✅ Conflict detected: comment posted with --absorbed, stack left alone" - -# Resolve as the comment instructs, with the vendored copy standing in for -# `uvx git-merge-onto` (unit tests have no network). +for REF in origin/feature1 origin/main; do + if ! grep -qxF "git merge $REF" "$COMMENT_FILE"; then + echo "❌ The posted resolution must run 'git merge $REF'" + cat "$COMMENT_FILE" + exit 1 + fi +done +echo "✅ Conflict detected: two-step resolution posted, stack left alone" + +# Resolve as the comment instructs. Both merges conflict: feature2 rewrote line 2, +# which feature1 rewrote too, and a plain merge of main uses the true merge base, +# from before feature1 existed. log_cmd git checkout feature2 -if python3 "$SCRIPT_DIR/../git-merge-onto" --absorbed origin/main origin/feature1; then - echo "❌ The re-parent should conflict (both sides rewrote line 2)" - exit 1 -fi -sed -i '/^<<<<<<>>>>>>/c\Feature 2 version' file.txt -log_cmd git add file.txt -log_cmd git commit --no-edit +for REF in origin/feature1 origin/main; do + if log_cmd git merge --no-edit "$REF"; then + echo "❌ Merging $REF should conflict (both sides rewrote line 2)" + exit 1 + fi + sed -i '/^<<<<<<>>>>>>/c\Feature 2 version' file.txt + log_cmd git add file.txt + log_cmd git commit --no-edit +done simulate_push feature2 # The regression assertion: the resolution descends from the old base's tip. if log_cmd git merge-base --is-ancestor "$FEATURE1_TIP" feature2; then - echo "✅ Resolution descends from origin/feature1's tip (--absorbed recorded it)" + echo "✅ Resolution descends from origin/feature1's tip (first merge recorded it)" else echo "❌ Resolution does not descend from origin/feature1's tip" log_cmd git log --graph --oneline feature2 feature1 @@ -143,5 +149,5 @@ else fi echo "" -echo "All absorbed-resolution tests passed! 🎉" +echo "All two-step resolution tests passed! 🎉" echo "Test repository remains at: $TEST_REPO for inspection" diff --git a/tests/test_e2e.sh b/tests/test_e2e.sh index 358056f..0a4b58f 100755 --- a/tests/test_e2e.sh +++ b/tests/test_e2e.sh @@ -275,17 +275,17 @@ get_conflict_comment() { printf '%s\n' "$comment" } -# The conflict comment must tell the user to run the re-parent the action tried: -# a single `uvx git-merge-onto --absorbed origin/ origin/`. -assert_conflict_comment_reparent() { +# The conflict comment must tell the user to merge the merged branch, then the +# target. +assert_conflict_comment_merges() { local comment=$1 local target=$2 local merged=$3 - if echo "$comment" | grep -qxF "uvx git-merge-onto origin/$target origin/$merged --absorbed"; then - echo >&2 "✅ Verification Passed: conflict comment re-parents origin/$merged onto origin/$target." + if [[ "$(echo "$comment" | grep -xE 'git merge origin/.*')" == "git merge origin/$merged"$'\n'"git merge origin/$target" ]]; then + echo >&2 "✅ Verification Passed: conflict comment merges origin/$merged, then origin/$target." else - echo >&2 "❌ Verification Failed: conflict comment lacks 'uvx git-merge-onto origin/$target origin/$merged --absorbed'." + echo >&2 "❌ Verification Failed: conflict comment lacks 'git merge origin/$merged' then 'git merge origin/$target'." echo >&2 "--- Full comment ---" echo >&2 "$comment" exit 1 @@ -1019,7 +1019,7 @@ 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" 1) -assert_conflict_comment_reparent "$CONFLICT_COMMENT" "main" "feature2" +assert_conflict_comment_merges "$CONFLICT_COMMENT" "main" "feature2" # Verify conflict label exists on PR3 echo >&2 "Checking for conflict label on PR #$PR3_NUM..." @@ -1036,10 +1036,10 @@ fi # The re-parent is one atomic merge, so on a conflict the action commits and # pushes nothing: origin/feature3 must still sit at its pre-conflict head. The -# resume is guaranteed by the resolution itself: its --absorbed re-parent records -# origin/feature2's tip as a parent, so the pushed head descends from its base -# and GitHub creates the synchronize run (it creates none for a PR that -# conflicts with its base). Asserted after the resolution below. +# resume is guaranteed by the resolution itself: its `git merge origin/feature2` +# makes the pushed head descend from its base, so GitHub creates the synchronize +# run (it creates none for a PR that conflicts with its base). Asserted after the +# resolution below. REMOTE_FEATURE3_SHA_BEFORE_RESOLVE=$(log_cmd git rev-parse "refs/remotes/origin/feature3") if [[ "$REMOTE_FEATURE3_SHA_BEFORE_RESOLVE" == "$FEATURE3_CONFLICT_COMMIT_SHA" ]]; then echo >&2 "✅ Verification Passed: action pushed nothing on the conflict; origin/feature3 is unchanged." @@ -1056,8 +1056,8 @@ echo >&2 "12. Resolving conflict on feature3 by following the posted comment..." # asserts the resolution descends from this tip. log_cmd git fetch origin FEATURE2_TIP_AT_RESOLUTION=$(log_cmd git rev-parse "refs/remotes/origin/feature2") -# Follow the comment exactly: fetch, fast-forward to origin/feature3, run the -# re-parent (uvx git-merge-onto), resolve the conflict, and push. Following it +# Follow the comment exactly: fetch, fast-forward to origin/feature3, merge +# origin/feature2, merge origin/main, resolve the conflict, and push. Following it # must leave feature3 cleanly mergeable into its new base, or the # synchronize-triggered continuation can never make progress and the conflict # label stays stuck. @@ -1125,11 +1125,11 @@ else exit 1 fi -# Verify the --absorbed re-parent recorded the old base's tip as a parent. This -# is the structural guarantee that PR3, still based on feature2 when the -# resolution was pushed, read as mergeable so GitHub created the resume run. +# Verify the resolution merged the old base's tip. This is the structural +# guarantee that PR3, still based on feature2 when the resolution was pushed, read +# as mergeable so GitHub created the resume run. if log_cmd git merge-base --is-ancestor "$FEATURE2_TIP_AT_RESOLUTION" feature3; then - echo >&2 "✅ Verification Passed: Resolved feature3 descends from feature2's tip (--absorbed)." + echo >&2 "✅ Verification Passed: Resolved feature3 descends from feature2's tip (first merge)." else echo >&2 "❌ Verification Failed: Resolved feature3 does not descend from feature2's tip ($FEATURE2_TIP_AT_RESOLUTION)." log_cmd git log --graph --oneline feature3 @@ -1275,8 +1275,8 @@ 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" 1) PR7_CONFLICT_COMMENT=$(get_conflict_comment "$PR7_URL" "$PR7_NUM" 1) -assert_conflict_comment_reparent "$PR6_CONFLICT_COMMENT" "main" "feature5" -assert_conflict_comment_reparent "$PR7_CONFLICT_COMMENT" "main" "feature5" +assert_conflict_comment_merges "$PR6_CONFLICT_COMMENT" "main" "feature5" +assert_conflict_comment_merges "$PR7_CONFLICT_COMMENT" "main" "feature5" # 19. Resolve first sibling (feature6) - feature5 should still be kept echo >&2 "19. Resolving first sibling (feature6) by following the posted comment..." diff --git a/update-pr-stack.sh b/update-pr-stack.sh index 6f98cc2..66548af 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -123,22 +123,29 @@ abort_merge_if_in_progress() { # Post the conflict-resolution comment and add the conflict label. # Args: head branch, merged parent branch, final target branch, squash hash, PR number. -# The user re-parents the head from MERGED onto TARGET with git-merge-onto; the -# state marker records both plus the squash so the resume can finalize. +# The user merges MERGED, then TARGET; the state marker records both plus the +# squash so the resume can finalize. post_conflict_comment() { local BRANCH="$1" MERGED="$2" TARGET="$3" SQUASH_HASH="$4" PR_NUMBER="$5" { echo "### ⚠️ Automatic update blocked by a merge conflict" echo - echo "Resolve it like this:" + echo "Resolve it in two steps. First merge the base branch \`$MERGED\`:" echo '```bash' echo "git fetch origin" echo "git switch $BRANCH" echo "git merge --ff-only origin/$BRANCH" - echo "uvx git-merge-onto origin/$TARGET origin/$MERGED --absorbed" + echo "git merge origin/$MERGED" echo '```' echo - echo 'Fix the conflicts (for instance with `git mergetool`), then run `git add -A && git commit` to finish the merge.' + echo 'Fix any conflicts (for instance with `git mergetool`), then run `git add -A && git commit` to finish the merge.' + echo + echo "Then merge \`$TARGET\`:" + echo '```bash' + echo "git merge origin/$TARGET" + echo '```' + echo + echo 'Fix any conflicts the same way, then push:' echo echo '```bash' echo "git push origin $BRANCH" @@ -207,9 +214,12 @@ See $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" # head forked (an ancestor PR merged first), GitHub falls back to a textual # merge to decide mergeability, which fails exactly when the resolution # rewrote the same lines. That would strand the PR: no run, label stuck. - # --absorbed in the posted command is what prevents this: it records the - # merged branch's tip as a parent of the resolution, so the pushed head - # descends from its base again and the resume event is guaranteed to fire. + # The posted `git merge origin/$MERGED_BRANCH` prevents this: the pushed head + # descends from its base again, so the resume event is guaranteed to fire. + # + # The posted merge of the target uses the true merge base, not the merged + # branch's tip: if the child deleted a line the merged branch added, the line + # comes back without a conflict. abort_merge_if_in_progress local SQUASH_HASH_FOR_MARKER SQUASH_HASH_FOR_MARKER=$(git rev-parse SQUASH_COMMIT) || die "cannot resolve SQUASH_COMMIT" @@ -316,8 +326,8 @@ continue_after_resolution() { return fi - # Same check for the old base: the resolution command we posted re-parents - # against origin/$OLD_BASE, so if that branch is gone (auto-delete head branches + # Same check for the old base: the resolution we posted merges + # origin/$OLD_BASE, so if that branch is gone (auto-delete head branches # left enabled, or deleted manually) the user cannot resolve and the label would # re-trigger a failing run on every push. Give up cleanly instead. if ! git rev-parse --verify --quiet "origin/$OLD_BASE" >/dev/null; then @@ -326,24 +336,23 @@ continue_after_resolution() { return fi - # The user resolved by re-parenting (the comment's `git-merge-onto`), so the - # head now contains the squash commit and, through --absorbed, the old base's - # tip. Verify that and finalize -- do NOT re-run the merge. Its forced base is - # the old parent, where the lines the user just resolved still differ from the - # trunk, so a re-merge would re-raise the very conflict they fixed. + # The user resolved by merging the old base, then the target (the comment's + # two steps), so the head now contains the old base's tip and the squash + # commit. Verify that and finalize -- do NOT re-run the re-parent. Its forced + # base is the old parent, where the lines the user just resolved still differ + # from the trunk, so a re-merge would re-raise the very conflict they fixed. # - # The squash commit alone is not enough: a plain `git merge origin/` - # also brings it in, but merges from the natural merge base, which predates - # the old base's later commits. Lines those commits removed come back. + # The squash commit alone is not enough: `git merge origin/` also + # brings it in, but without the old base merged first, lines removed by the + # old base's later commits come back. run git update-ref SQUASH_COMMIT "$SQUASH_HASH" run git checkout "$PR_BRANCH" if ! git merge-base --is-ancestor SQUASH_COMMIT "$PR_BRANCH" \ || ! git merge-base --is-ancestor "origin/$OLD_BASE" "$PR_BRANCH"; then - # The user pushed without finishing the re-parent, resolved with a plain - # merge, or followed an old-format conflict comment (a prior action - # version). Re-post the current instructions and keep the label on so the - # next push resumes. Still return 1: a red run flags that the PR is not - # resolved yet. + # The user pushed without finishing both merges, or followed an old-format + # conflict comment (a prior action version). Re-post the current + # instructions and keep the label on so the next push resumes. Still + # return 1: a red run flags that the PR is not resolved yet. echo "⚠️ '$PR_BRANCH' does not contain both the squash commit and the tip of '$OLD_BASE' yet; re-posting the conflict comment." >&2 post_conflict_comment "$PR_BRANCH" "$OLD_BASE" "$NEW_TARGET" "$SQUASH_HASH" "$PR_NUMBER" return 1 From fba1b246955e6f7758daba1b694084c33845e42d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Mon, 9 Nov 2026 11:22:56 +0100 Subject: [PATCH 2/3] Post a plain-git re-parent instead of two merges Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 8 ++- ...h => test_conflict_absorbed_resolution.sh} | 46 +++++++--------- tests/test_e2e.sh | 38 ++++++------- update-pr-stack.sh | 55 ++++++++----------- 4 files changed, 69 insertions(+), 78 deletions(-) rename tests/{test_conflict_two_step_resolution.sh => test_conflict_absorbed_resolution.sh} (78%) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bddb5ee..425201e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,7 +18,7 @@ jobs: bash tests/test_rebase_workflow.sh bash tests/test_mixed_workflows.sh bash tests/test_conflict_resolution_resume.sh - bash tests/test_conflict_two_step_resolution.sh + bash tests/test_conflict_absorbed_resolution.sh bash tests/test_merge_commit_merge.sh bash tests/test_rebase_merge.sh bash tests/test_merged_branch_moved.sh @@ -41,6 +41,12 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} owner: autorestack-test + # The e2e simulates a human resolving a conflict by running the posted + # comment, which calls `uvx git-merge-onto`. The action itself uses the + # vendored copy via python3 and needs no uv. + - name: Install uv + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - name: Run e2e tests env: GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/tests/test_conflict_two_step_resolution.sh b/tests/test_conflict_absorbed_resolution.sh similarity index 78% rename from tests/test_conflict_two_step_resolution.sh rename to tests/test_conflict_absorbed_resolution.sh index c796255..7a4f7f5 100644 --- a/tests/test_conflict_two_step_resolution.sh +++ b/tests/test_conflict_absorbed_resolution.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Replays the incident that stranded a conflicted PR (scortexio/gh-stack-mv#36): +# Replays the incident that motivated --absorbed (scortexio/gh-stack-mv#36): # # 1. main <- feature1 <- feature2, where feature1 advanced AFTER feature2 # forked (in the incident, autorestack itself advanced it when a @@ -8,8 +8,7 @@ # feature2. # 2. feature1 is squash-merged; the action's re-parent of feature2 conflicts, # so the action posts the resolution comment and stops. -# 3. The user resolves with the two posted merges: origin/feature1, then -# origin/main. +# 3. The user resolves by running the posted re-parent with --absorbed. # # The resolution must descend from origin/feature1's tip: feature2's PR is # still based on feature1 at that point, and GitHub creates no pull_request @@ -103,33 +102,28 @@ if [[ "$(git rev-parse feature2)" != "$FEATURE2_BEFORE" ]]; then echo "❌ feature2 must not move on a conflict" exit 1 fi -for REF in origin/feature1 origin/main; do - if ! grep -qxF "git merge $REF" "$COMMENT_FILE"; then - echo "❌ The posted resolution must run 'git merge $REF'" - cat "$COMMENT_FILE" - exit 1 - fi -done -echo "✅ Conflict detected: two-step resolution posted, stack left alone" - -# Resolve as the comment instructs. Both merges conflict: feature2 rewrote line 2, -# which feature1 rewrote too, and a plain merge of main uses the true merge base, -# from before feature1 existed. +if ! grep -q -- "--absorbed" "$COMMENT_FILE"; then + echo "❌ The posted resolution command must use --absorbed" + cat "$COMMENT_FILE" + exit 1 +fi +echo "✅ Conflict detected: comment posted with --absorbed, stack left alone" + +# Resolve as the comment instructs, with the vendored copy standing in for +# `uvx git-merge-onto` (unit tests have no network). log_cmd git checkout feature2 -for REF in origin/feature1 origin/main; do - if log_cmd git merge --no-edit "$REF"; then - echo "❌ Merging $REF should conflict (both sides rewrote line 2)" - exit 1 - fi - sed -i '/^<<<<<<>>>>>>/c\Feature 2 version' file.txt - log_cmd git add file.txt - log_cmd git commit --no-edit -done +if python3 "$SCRIPT_DIR/../git-merge-onto" --absorbed origin/main origin/feature1; then + echo "❌ The re-parent should conflict (both sides rewrote line 2)" + exit 1 +fi +sed -i '/^<<<<<<>>>>>>/c\Feature 2 version' file.txt +log_cmd git add file.txt +log_cmd git commit --no-edit simulate_push feature2 # The regression assertion: the resolution descends from the old base's tip. if log_cmd git merge-base --is-ancestor "$FEATURE1_TIP" feature2; then - echo "✅ Resolution descends from origin/feature1's tip (first merge recorded it)" + echo "✅ Resolution descends from origin/feature1's tip (--absorbed recorded it)" else echo "❌ Resolution does not descend from origin/feature1's tip" log_cmd git log --graph --oneline feature2 feature1 @@ -149,5 +143,5 @@ else fi echo "" -echo "All two-step resolution tests passed! 🎉" +echo "All absorbed-resolution tests passed! 🎉" echo "Test repository remains at: $TEST_REPO for inspection" diff --git a/tests/test_e2e.sh b/tests/test_e2e.sh index 0a4b58f..358056f 100755 --- a/tests/test_e2e.sh +++ b/tests/test_e2e.sh @@ -275,17 +275,17 @@ get_conflict_comment() { printf '%s\n' "$comment" } -# The conflict comment must tell the user to merge the merged branch, then the -# target. -assert_conflict_comment_merges() { +# The conflict comment must tell the user to run the re-parent the action tried: +# a single `uvx git-merge-onto --absorbed origin/ origin/`. +assert_conflict_comment_reparent() { local comment=$1 local target=$2 local merged=$3 - if [[ "$(echo "$comment" | grep -xE 'git merge origin/.*')" == "git merge origin/$merged"$'\n'"git merge origin/$target" ]]; then - echo >&2 "✅ Verification Passed: conflict comment merges origin/$merged, then origin/$target." + if echo "$comment" | grep -qxF "uvx git-merge-onto origin/$target origin/$merged --absorbed"; then + echo >&2 "✅ Verification Passed: conflict comment re-parents origin/$merged onto origin/$target." else - echo >&2 "❌ Verification Failed: conflict comment lacks 'git merge origin/$merged' then 'git merge origin/$target'." + echo >&2 "❌ Verification Failed: conflict comment lacks 'uvx git-merge-onto origin/$target origin/$merged --absorbed'." echo >&2 "--- Full comment ---" echo >&2 "$comment" exit 1 @@ -1019,7 +1019,7 @@ 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" 1) -assert_conflict_comment_merges "$CONFLICT_COMMENT" "main" "feature2" +assert_conflict_comment_reparent "$CONFLICT_COMMENT" "main" "feature2" # Verify conflict label exists on PR3 echo >&2 "Checking for conflict label on PR #$PR3_NUM..." @@ -1036,10 +1036,10 @@ fi # The re-parent is one atomic merge, so on a conflict the action commits and # pushes nothing: origin/feature3 must still sit at its pre-conflict head. The -# resume is guaranteed by the resolution itself: its `git merge origin/feature2` -# makes the pushed head descend from its base, so GitHub creates the synchronize -# run (it creates none for a PR that conflicts with its base). Asserted after the -# resolution below. +# resume is guaranteed by the resolution itself: its --absorbed re-parent records +# origin/feature2's tip as a parent, so the pushed head descends from its base +# and GitHub creates the synchronize run (it creates none for a PR that +# conflicts with its base). Asserted after the resolution below. REMOTE_FEATURE3_SHA_BEFORE_RESOLVE=$(log_cmd git rev-parse "refs/remotes/origin/feature3") if [[ "$REMOTE_FEATURE3_SHA_BEFORE_RESOLVE" == "$FEATURE3_CONFLICT_COMMIT_SHA" ]]; then echo >&2 "✅ Verification Passed: action pushed nothing on the conflict; origin/feature3 is unchanged." @@ -1056,8 +1056,8 @@ echo >&2 "12. Resolving conflict on feature3 by following the posted comment..." # asserts the resolution descends from this tip. log_cmd git fetch origin FEATURE2_TIP_AT_RESOLUTION=$(log_cmd git rev-parse "refs/remotes/origin/feature2") -# Follow the comment exactly: fetch, fast-forward to origin/feature3, merge -# origin/feature2, merge origin/main, resolve the conflict, and push. Following it +# Follow the comment exactly: fetch, fast-forward to origin/feature3, run the +# re-parent (uvx git-merge-onto), resolve the conflict, and push. Following it # must leave feature3 cleanly mergeable into its new base, or the # synchronize-triggered continuation can never make progress and the conflict # label stays stuck. @@ -1125,11 +1125,11 @@ else exit 1 fi -# Verify the resolution merged the old base's tip. This is the structural -# guarantee that PR3, still based on feature2 when the resolution was pushed, read -# as mergeable so GitHub created the resume run. +# Verify the --absorbed re-parent recorded the old base's tip as a parent. This +# is the structural guarantee that PR3, still based on feature2 when the +# resolution was pushed, read as mergeable so GitHub created the resume run. if log_cmd git merge-base --is-ancestor "$FEATURE2_TIP_AT_RESOLUTION" feature3; then - echo >&2 "✅ Verification Passed: Resolved feature3 descends from feature2's tip (first merge)." + echo >&2 "✅ Verification Passed: Resolved feature3 descends from feature2's tip (--absorbed)." else echo >&2 "❌ Verification Failed: Resolved feature3 does not descend from feature2's tip ($FEATURE2_TIP_AT_RESOLUTION)." log_cmd git log --graph --oneline feature3 @@ -1275,8 +1275,8 @@ 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" 1) PR7_CONFLICT_COMMENT=$(get_conflict_comment "$PR7_URL" "$PR7_NUM" 1) -assert_conflict_comment_merges "$PR6_CONFLICT_COMMENT" "main" "feature5" -assert_conflict_comment_merges "$PR7_CONFLICT_COMMENT" "main" "feature5" +assert_conflict_comment_reparent "$PR6_CONFLICT_COMMENT" "main" "feature5" +assert_conflict_comment_reparent "$PR7_CONFLICT_COMMENT" "main" "feature5" # 19. Resolve first sibling (feature6) - feature5 should still be kept echo >&2 "19. Resolving first sibling (feature6) by following the posted comment..." diff --git a/update-pr-stack.sh b/update-pr-stack.sh index ce5be6b..eee4a2d 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -123,29 +123,22 @@ abort_merge_if_in_progress() { # Post the conflict-resolution comment and add the conflict label. # Args: head branch, merged parent branch, final target branch, squash hash, PR number. -# The user merges MERGED, then TARGET; the state marker records both plus the -# squash so the resume can finalize. +# The user re-parents the head from MERGED onto TARGET with git-merge-onto; the +# state marker records both plus the squash so the resume can finalize. post_conflict_comment() { local BRANCH="$1" MERGED="$2" TARGET="$3" SQUASH_HASH="$4" PR_NUMBER="$5" { echo "### ⚠️ Automatic update blocked by a merge conflict" echo - echo "Resolve it in two steps. First merge the base branch \`$MERGED\`:" + echo "Resolve it like this:" echo '```bash' echo "git fetch origin" echo "git switch $BRANCH" echo "git merge --ff-only origin/$BRANCH" - echo "git merge origin/$MERGED" + echo "uvx git-merge-onto origin/$TARGET origin/$MERGED --absorbed" echo '```' echo - echo 'Fix any conflicts (for instance with `git mergetool`), then run `git add -A && git commit` to finish the merge.' - echo - echo "Then merge \`$TARGET\`:" - echo '```bash' - echo "git merge origin/$TARGET" - echo '```' - echo - echo 'Fix any conflicts the same way, then push:' + echo 'Fix the conflicts (for instance with `git mergetool`), then run `git add -A && git commit` to finish the merge.' echo echo '```bash' echo "git push origin $BRANCH" @@ -229,12 +222,9 @@ See $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" # head forked (an ancestor PR merged first), GitHub falls back to a textual # merge to decide mergeability, which fails exactly when the resolution # rewrote the same lines. That would strand the PR: no run, label stuck. - # The posted `git merge origin/$MERGED_BRANCH` prevents this: the pushed head - # descends from its base again, so the resume event is guaranteed to fire. - # - # The posted merge of the target uses the true merge base, not the merged - # branch's tip: if the child deleted a line the merged branch added, the line - # comes back without a conflict. + # --absorbed in the posted command is what prevents this: it records the + # merged branch's tip as a parent of the resolution, so the pushed head + # descends from its base again and the resume event is guaranteed to fire. abort_merge_if_in_progress local SQUASH_HASH_FOR_MARKER SQUASH_HASH_FOR_MARKER=$(git rev-parse SQUASH_COMMIT) || die "cannot resolve SQUASH_COMMIT" @@ -341,8 +331,8 @@ continue_after_resolution() { return fi - # Same check for the old base: the resolution we posted merges - # origin/$OLD_BASE, so if that branch is gone (auto-delete head branches + # Same check for the old base: the resolution command we posted re-parents + # against origin/$OLD_BASE, so if that branch is gone (auto-delete head branches # left enabled, or deleted manually) the user cannot resolve and the label would # re-trigger a failing run on every push. Give up cleanly instead. if ! git rev-parse --verify --quiet "origin/$OLD_BASE" >/dev/null; then @@ -351,23 +341,24 @@ continue_after_resolution() { return fi - # The user resolved by merging the old base, then the target (the comment's - # two steps), so the head now contains the old base's tip and the squash - # commit. Verify that and finalize -- do NOT re-run the re-parent. Its forced - # base is the old parent, where the lines the user just resolved still differ - # from the trunk, so a re-merge would re-raise the very conflict they fixed. + # The user resolved by re-parenting (the comment's `git-merge-onto`), so the + # head now contains the squash commit and, through --absorbed, the old base's + # tip. Verify that and finalize -- do NOT re-run the merge. Its forced base is + # the old parent, where the lines the user just resolved still differ from the + # trunk, so a re-merge would re-raise the very conflict they fixed. # - # The squash commit alone is not enough: `git merge origin/` also - # brings it in, but without the old base merged first, lines removed by the - # old base's later commits come back. + # The squash commit alone is not enough: a plain `git merge origin/` + # also brings it in, but merges from the natural merge base, which predates + # the old base's later commits. Lines those commits removed come back. run git update-ref SQUASH_COMMIT "$SQUASH_HASH" run git checkout "$PR_BRANCH" if ! git merge-base --is-ancestor SQUASH_COMMIT "$PR_BRANCH" \ || ! git merge-base --is-ancestor "origin/$OLD_BASE" "$PR_BRANCH"; then - # The user pushed without finishing both merges, or followed an old-format - # conflict comment (a prior action version). Re-post the current - # instructions and keep the label on so the next push resumes. Still - # return 1: a red run flags that the PR is not resolved yet. + # The user pushed without finishing the re-parent, resolved with a plain + # merge, or followed an old-format conflict comment (a prior action + # version). Re-post the current instructions and keep the label on so the + # next push resumes. Still return 1: a red run flags that the PR is not + # resolved yet. echo "⚠️ '$PR_BRANCH' does not contain both the squash commit and the tip of '$OLD_BASE' yet; re-posting the conflict comment." >&2 post_conflict_comment "$PR_BRANCH" "$OLD_BASE" "$NEW_TARGET" "$SQUASH_HASH" "$PR_NUMBER" return 1 From 40178d1544fe8105ebbc6dd9eff5c5e67fb62ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Mon, 9 Nov 2026 11:23:10 +0100 Subject: [PATCH 3/3] Post the re-parent as a plain git merge of a synthetic commit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 6 ------ tests/test_conflict_absorbed_resolution.sh | 17 ++++++++------- tests/test_e2e.sh | 17 +++++++-------- update-pr-stack.sh | 24 ++++++++++++---------- 4 files changed, 30 insertions(+), 34 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 425201e..19637be 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,12 +41,6 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} owner: autorestack-test - # The e2e simulates a human resolving a conflict by running the posted - # comment, which calls `uvx git-merge-onto`. The action itself uses the - # vendored copy via python3 and needs no uv. - - name: Install uv - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - - name: Run e2e tests env: GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/tests/test_conflict_absorbed_resolution.sh b/tests/test_conflict_absorbed_resolution.sh index 7a4f7f5..455676d 100644 --- a/tests/test_conflict_absorbed_resolution.sh +++ b/tests/test_conflict_absorbed_resolution.sh @@ -8,7 +8,7 @@ # feature2. # 2. feature1 is squash-merged; the action's re-parent of feature2 conflicts, # so the action posts the resolution comment and stops. -# 3. The user resolves by running the posted re-parent with --absorbed. +# 3. The user resolves by running the posted re-parent. # # The resolution must descend from origin/feature1's tip: feature2's PR is # still based on feature1 at that point, and GitHub creates no pull_request @@ -102,17 +102,16 @@ if [[ "$(git rev-parse feature2)" != "$FEATURE2_BEFORE" ]]; then echo "❌ feature2 must not move on a conflict" exit 1 fi -if ! grep -q -- "--absorbed" "$COMMENT_FILE"; then - echo "❌ The posted resolution command must use --absorbed" +REPARENT=$(grep -x 'git merge .*' "$COMMENT_FILE") || { + echo "❌ The comment must post a re-parent command" cat "$COMMENT_FILE" exit 1 -fi -echo "✅ Conflict detected: comment posted with --absorbed, stack left alone" +} +echo "✅ Conflict detected: comment posted, stack left alone" -# Resolve as the comment instructs, with the vendored copy standing in for -# `uvx git-merge-onto` (unit tests have no network). +# Resolve as the comment instructs. log_cmd git checkout feature2 -if python3 "$SCRIPT_DIR/../git-merge-onto" --absorbed origin/main origin/feature1; then +if bash -c "$REPARENT"; then echo "❌ The re-parent should conflict (both sides rewrote line 2)" exit 1 fi @@ -123,7 +122,7 @@ simulate_push feature2 # The regression assertion: the resolution descends from the old base's tip. if log_cmd git merge-base --is-ancestor "$FEATURE1_TIP" feature2; then - echo "✅ Resolution descends from origin/feature1's tip (--absorbed recorded it)" + echo "✅ Resolution descends from origin/feature1's tip (the re-parent recorded it)" else echo "❌ Resolution does not descend from origin/feature1's tip" log_cmd git log --graph --oneline feature2 feature1 diff --git a/tests/test_e2e.sh b/tests/test_e2e.sh index 358056f..bea0343 100755 --- a/tests/test_e2e.sh +++ b/tests/test_e2e.sh @@ -275,17 +275,18 @@ get_conflict_comment() { printf '%s\n' "$comment" } -# The conflict comment must tell the user to run the re-parent the action tried: -# a single `uvx git-merge-onto --absorbed origin/ origin/`. +# The conflict comment must tell the user to run the re-parent the action tried, +# in plain git. assert_conflict_comment_reparent() { local comment=$1 local target=$2 local merged=$3 - if echo "$comment" | grep -qxF "uvx git-merge-onto origin/$target origin/$merged --absorbed"; then + local command="git merge \$(git commit-tree \"origin/$target^{tree}\" -p origin/$target -p origin/$merged -m \"$target with $merged merged\")" + if echo "$comment" | grep -qxF "$command"; then echo >&2 "✅ Verification Passed: conflict comment re-parents origin/$merged onto origin/$target." else - echo >&2 "❌ Verification Failed: conflict comment lacks 'uvx git-merge-onto origin/$target origin/$merged --absorbed'." + echo >&2 "❌ Verification Failed: conflict comment lacks '$command'." echo >&2 "--- Full comment ---" echo >&2 "$comment" exit 1 @@ -1036,7 +1037,7 @@ fi # The re-parent is one atomic merge, so on a conflict the action commits and # pushes nothing: origin/feature3 must still sit at its pre-conflict head. The -# resume is guaranteed by the resolution itself: its --absorbed re-parent records +# resume is guaranteed by the resolution itself: the commit it merges has # origin/feature2's tip as a parent, so the pushed head descends from its base # and GitHub creates the synchronize run (it creates none for a PR that # conflicts with its base). Asserted after the resolution below. @@ -1057,7 +1058,7 @@ echo >&2 "12. Resolving conflict on feature3 by following the posted comment..." log_cmd git fetch origin FEATURE2_TIP_AT_RESOLUTION=$(log_cmd git rev-parse "refs/remotes/origin/feature2") # Follow the comment exactly: fetch, fast-forward to origin/feature3, run the -# re-parent (uvx git-merge-onto), resolve the conflict, and push. Following it +# re-parent, resolve the conflict, and push. Following it # must leave feature3 cleanly mergeable into its new base, or the # synchronize-triggered continuation can never make progress and the conflict # label stays stuck. @@ -1125,11 +1126,11 @@ else exit 1 fi -# Verify the --absorbed re-parent recorded the old base's tip as a parent. This +# Verify the re-parent recorded the old base's tip as an ancestor. This # is the structural guarantee that PR3, still based on feature2 when the # resolution was pushed, read as mergeable so GitHub created the resume run. if log_cmd git merge-base --is-ancestor "$FEATURE2_TIP_AT_RESOLUTION" feature3; then - echo >&2 "✅ Verification Passed: Resolved feature3 descends from feature2's tip (--absorbed)." + echo >&2 "✅ Verification Passed: Resolved feature3 descends from feature2's tip." else echo >&2 "❌ Verification Failed: Resolved feature3 does not descend from feature2's tip ($FEATURE2_TIP_AT_RESOLUTION)." log_cmd git log --graph --oneline feature3 diff --git a/update-pr-stack.sh b/update-pr-stack.sh index eee4a2d..1e3f3d3 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -123,8 +123,10 @@ abort_merge_if_in_progress() { # Post the conflict-resolution comment and add the conflict label. # Args: head branch, merged parent branch, final target branch, squash hash, PR number. -# The user re-parents the head from MERGED onto TARGET with git-merge-onto; the -# state marker records both plus the squash so the resume can finalize. +# The posted command is `git merge-onto --absorbed` in plain git: the commit it +# merges has TARGET's tree and parents TARGET and MERGED, so in a stack git +# merges from merge-base(HEAD, MERGED). The state marker records both plus the +# squash so the resume can finalize. post_conflict_comment() { local BRANCH="$1" MERGED="$2" TARGET="$3" SQUASH_HASH="$4" PR_NUMBER="$5" { @@ -135,7 +137,7 @@ post_conflict_comment() { echo "git fetch origin" echo "git switch $BRANCH" echo "git merge --ff-only origin/$BRANCH" - echo "uvx git-merge-onto origin/$TARGET origin/$MERGED --absorbed" + echo "git merge \$(git commit-tree \"origin/$TARGET^{tree}\" -p origin/$TARGET -p origin/$MERGED -m \"$TARGET with $MERGED merged\")" echo '```' echo echo 'Fix the conflicts (for instance with `git mergetool`), then run `git add -A && git commit` to finish the merge.' @@ -222,9 +224,9 @@ See $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" # head forked (an ancestor PR merged first), GitHub falls back to a textual # merge to decide mergeability, which fails exactly when the resolution # rewrote the same lines. That would strand the PR: no run, label stuck. - # --absorbed in the posted command is what prevents this: it records the - # merged branch's tip as a parent of the resolution, so the pushed head - # descends from its base again and the resume event is guaranteed to fire. + # The posted command prevents this: the commit it merges has the merged + # branch's tip as a parent, so the pushed head descends from its base again + # and the resume event is guaranteed to fire. abort_merge_if_in_progress local SQUASH_HASH_FOR_MARKER SQUASH_HASH_FOR_MARKER=$(git rev-parse SQUASH_COMMIT) || die "cannot resolve SQUASH_COMMIT" @@ -341,11 +343,11 @@ continue_after_resolution() { return fi - # The user resolved by re-parenting (the comment's `git-merge-onto`), so the - # head now contains the squash commit and, through --absorbed, the old base's - # tip. Verify that and finalize -- do NOT re-run the merge. Its forced base is - # the old parent, where the lines the user just resolved still differ from the - # trunk, so a re-merge would re-raise the very conflict they fixed. + # The user resolved by re-parenting (the comment's command), so the head now + # contains the squash commit and the old base's tip. Verify that and finalize + # -- do NOT re-run the merge. Its forced base is the old parent, where the + # lines the user just resolved still differ from the trunk, so a re-merge + # would re-raise the very conflict they fixed. # # The squash commit alone is not enough: a plain `git merge origin/` # also brings it in, but merges from the natural merge base, which predates