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