Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
17 changes: 8 additions & 9 deletions tests/test_conflict_absorbed_resolution.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
17 changes: 9 additions & 8 deletions tests/test_e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<target> origin/<merged branch>`.
# 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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
24 changes: 13 additions & 11 deletions update-pr-stack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
{
Expand All @@ -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.'
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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/<target>`
# also brings it in, but merges from the natural merge base, which predates
Expand Down
Loading