From 36b78a9ad8974d7c71e4309172a1a7c902ee3a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Fri, 26 Jun 2026 16:06:36 +0200 Subject: [PATCH 1/6] Re-parent with the vendored git-merge-onto instead of the merge dance update_direct_target re-homed each child with five git commands (merge the merged branch, merge the pre-squash target, merge -s ours the squash, commit-tree a three-parent commit, reset). That is one re-parent: a merge of the squash commit with the base forced to merge-base(HEAD, merged branch). Replace it with a single git-merge-onto call, vendored as a zero-dependency file run with python3 so the action needs no download. The two-merge conflict path collapses: on a conflict the action now commits and pushes nothing (the pre-push of the clean half is gone), and the comment asks for one `uvx git-merge-onto`. Drops the conflict-matrix e2e scenario, whose base-vs-trunk distinctions and follow-up-trunk-conflict case only existed because there were two merges. git-merge-onto: https://github.com/scortexio/git-merge-onto Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SRBBuBCbXvdmtRQiSMixVL --- .github/workflows/tests.yml | 6 + README.md | 8 +- git-merge-onto | 268 +++++++++++++++++++++++++++++++++++ tests/test_e2e.sh | 272 ++++-------------------------------- update-pr-stack.sh | 136 ++++++++---------- 5 files changed, 363 insertions(+), 327 deletions(-) create mode 100755 git-merge-onto diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 856d69c..c2c4dbe 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,6 +37,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@v8 + - name: Run e2e tests env: GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/README.md b/README.md index b544cd8..cf6b223 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,14 @@ This action tries to fix that in a transparent way. Install it, and hopefully th 1. Triggers when a PR is squash merged 2. Finds PRs that were based on the merged branch (direct children only) -3. Creates a synthetic merge commit with three parents (child tip, deleted branch tip, squash commit) to preserve history without re-introducing code +3. Re-parents each child onto the trunk with a single merge — [git-merge-onto](https://github.com/scortexio/git-merge-onto), the merge equivalent of `git rebase --onto` — so the squashed branch's content is dropped without rewriting history 4. Pushes the updated branches 5. Updates the direct child PRs to base on trunk now that the bottom change has landed 6. Deletes the merged branch -**Note:** Indirect descendants (grandchildren, etc.) are intentionally not modified. Their PR diffs remain correct because the merge-base calculation still works—the synthetic merge commit includes the original parent commit as an ancestor. When their direct parent is eventually merged, they become direct children and get updated at that point. +The re-parent primitive ([git-merge-onto](https://github.com/scortexio/git-merge-onto)) is vendored as a single zero-dependency file and run with `python3`, so the action needs no network download. + +**Note:** Indirect descendants (grandchildren, etc.) are intentionally not modified. Their PR diffs remain correct because the merge-base calculation still works—the re-parent merge keeps the child's original commit as a parent. When their direct parent is eventually merged, they become direct children and get updated at that point. ### Conflict handling @@ -61,7 +63,7 @@ gh api -X PATCH "/repos/OWNER/REPO" --input - <<< '{"delete_branch_on_merge":fal **2. Create a GitHub App** -When autorestack pushes the synthetic merge commit to upstack branches, you probably want CI to run on those PRs so they can become mergeable. Pushes made with the default `GITHUB_TOKEN` [do not trigger workflow runs](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow) — this is a deliberate GitHub limitation to prevent infinite loops. A GitHub App installation token does not have this limitation. +When autorestack pushes the re-parent merge commit to upstack branches, you probably want CI to run on those PRs so they can become mergeable. Pushes made with the default `GITHUB_TOKEN` [do not trigger workflow runs](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow) — this is a deliberate GitHub limitation to prevent infinite loops. A GitHub App installation token does not have this limitation. 1. [Create a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) with the following repository permissions: - **Contents:** Read and write (to push branches) diff --git a/git-merge-onto b/git-merge-onto new file mode 100755 index 0000000..2d1f8d7 --- /dev/null +++ b/git-merge-onto @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.9" +# dependencies = [] +# /// +# +# Vendored from https://github.com/scortexio/git-merge-onto (v0.1.0): a single +# zero-dependency file so the action re-parents a branch without a network +# download. Do not edit here -- change it upstream, publish, and re-sync. +"""git merge-onto: re-parent HEAD onto , dropping . + +A 3-way merge of into HEAD whose merge base is forced to +merge-base(HEAD, ). That keeps HEAD's own delta, drops the content it +shared with its old parent , and makes a real ancestor -- the merge +equivalent of `git rebase --onto `, without rewriting history. + +The forced base is the one operation git porcelain cannot express: a `git merge` +chooses its base from the commit graph, and the base it picks is wrong in two +ways a re-parent hits. Too low -- when contains 's *content* but not +its commit (a squash-merge) -- and a plain merge re-applies , often +conflicting. Too high -- when the new parent transitively contains HEAD's own +commit (a reorder) -- and a plain merge fast-forwards, silently dropping HEAD's +change. Forcing the base to merge-base(HEAD, ) is correct in both. +""" + +from __future__ import annotations + +import argparse +import os +import shlex +import subprocess +import sys +from pathlib import Path + +__version__ = "0.1.0" # vendored snapshot; see the header above + +# Point at a specific git binary (used by the test suite; "git" otherwise). +GIT = os.environ.get("GIT_MERGE_ONTO_GIT", "git") + +# Echo each executed git command to stderr (the transcript value of the tool: +# you see that a re-parent is one `git merge-recursive`). Silenced by --quiet. +VERBOSE = True + + +class CommandError(RuntimeError): + """A git command exited non-zero where success was required.""" + + def __init__(self, argv: list[str], returncode: int, stderr: str): + self.argv = argv + self.returncode = returncode + self.stderr = stderr + super().__init__(f"command failed ({returncode}): {' '.join(argv)}\n{stderr}") + + +class UserError(RuntimeError): + """A problem the user can fix (dirty tree, bad ref); reported without a traceback.""" + + +def _ansi(code: str, text: str) -> str: + """Wrap text in an ANSI SGR code, but only on a terminal so redirected or piped + output stays plain.""" + return f"\033[{code}m{text}\033[0m" if sys.stderr.isatty() else text + + +def bold(text: str) -> str: + return _ansi("1", text) + + +def dim(text: str) -> str: + return _ansi("2", text) + + +def red(text: str) -> str: + return _ansi("31", text) + + +def _log_cmd(argv: list[str]) -> None: + if VERBOSE: + print(dim("Executing: " + " ".join(shlex.quote(a) for a in argv)), file=sys.stderr) + + +def run(argv: list[str], *, check: bool = True, capture: bool = True) -> subprocess.CompletedProcess: + _log_cmd(argv) + proc = subprocess.run( + argv, + text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, + ) + if check and proc.returncode != 0: + raise CommandError(argv, proc.returncode, (proc.stderr or "") if capture else "") + return proc + + +def git(*args: str, check: bool = True, capture: bool = True) -> str: + proc = run([GIT, *args], check=check, capture=capture) + return (proc.stdout or "").strip() + + +def git_rc(*args: str) -> int: + """Run git, return only the exit code (for merge etc. where non-zero is expected).""" + return run([GIT, *args], check=False, capture=False).returncode + + +def rev_parse(ref: str) -> str | None: + proc = run([GIT, "rev-parse", "--verify", "--quiet", ref + "^{commit}"], check=False) + out = (proc.stdout or "").strip() + return out or None + + +def git_dir() -> Path: + # Absolute so the MERGE_HEAD markers land in the real git dir regardless of cwd. + return Path(git("rev-parse", "--absolute-git-dir")) + + +def worktree_dirty(include_untracked: bool = True) -> bool: + args = ["status", "--porcelain"] + if not include_untracked: + args.append("--untracked-files=no") + return bool(git(*args)) + + +def in_progress_merge() -> bool: + return (git_dir() / "MERGE_HEAD").exists() + + +def blocking_operation() -> str | None: + """Name of an in-progress git operation a merge would corrupt, or None. A merge + leaves MERGE_HEAD, but a paused rebase, cherry-pick, or revert can leave a clean + tree on a detached HEAD, which would otherwise slip past the dirty-tree guard and + let the merge commit onto the operation's temporary HEAD.""" + gd = git_dir() + if (gd / "MERGE_HEAD").exists(): + return "merge" + if (gd / "CHERRY_PICK_HEAD").exists(): + return "cherry-pick" + if (gd / "REVERT_HEAD").exists(): + return "revert" + if (gd / "rebase-merge").is_dir() or (gd / "rebase-apply").is_dir(): + return "rebase" + return None + + +def setup_merge_markers(theirs: str, message: str, head_tip: str) -> None: + """Write the in-progress-merge state `git commit` reads to finalize a merge: + parents come from HEAD + MERGE_HEAD, the message from MERGE_MSG.""" + gd = git_dir() + (gd / "MERGE_HEAD").write_text(theirs + "\n") + (gd / "MERGE_MODE").write_text("") + (gd / "MERGE_MSG").write_text(message + "\n") + (gd / "ORIG_HEAD").write_text(head_tip + "\n") + + +def merge_with_base(base: str, theirs: str, message: str) -> bool: + """Merge `theirs` into HEAD as if `base` were the merge base -- a `git merge` + with a caller-chosen base, the one thing git porcelain cannot do. + + Clean -> commits with parents [HEAD, theirs] and returns True. Conflict -> + leaves the merge in progress (MERGE_HEAD set, conflict markers in the worktree) + and returns False, so the caller (or a human) resolves and `git commit`s normally. + """ + head_tip = git("rev-parse", "HEAD") + # 3-way merge into index+worktree with the merge base forced to `base`. + rc = git_rc("merge-recursive", base, "--", head_tip, theirs) + # merge-recursive returns 0 = clean, 1 = content conflict, >1 = it refused to run + # at all (dirty index/worktree, bad arg). Only set the in-progress-merge markers + # when there is a real merge to finalize; on a refusal, raise so we never fabricate + # a merge commit or clobber an existing MERGE_HEAD. + if rc == 0: + # A re-parent normally changes the tree; if it doesn't AND `theirs` is already + # an ancestor, the merge commit would add nothing (no content, no new ancestor), + # so skip it. (Don't skip merely because `theirs` is an ancestor: re-parenting + # onto a trunk that is already an ancestor still must drop the old parent's content.) + if git("write-tree") == git("rev-parse", "HEAD^{tree}") and git_rc("merge-base", "--is-ancestor", theirs, head_tip) == 0: + return True + setup_merge_markers(theirs, message, head_tip) + git("commit", "--no-edit") + return True + if rc == 1: + setup_merge_markers(theirs, message, head_tip) + return False + raise CommandError( + [GIT, "merge-recursive", base, "--", head_tip, theirs], + rc, + "merge-recursive refused to run (working tree/index not clean, or bad argument)", + ) + + +def _resolve_commit(ref: str) -> str | None: + """Resolve a commit-ish, falling back to origin/ for a bare branch name + that only exists as a remote-tracking ref (like `git merge` would DWIM).""" + return rev_parse(ref) or rev_parse(f"origin/{ref}") + + +def merge_onto(new: str, old: str, message: str | None = None) -> bool: + """Re-parent HEAD onto `new`, dropping `old`. Returns True on a clean merge + (committed), False on a conflict (left in progress to resolve and commit). + Raises UserError on a precondition failure (dirty tree, bad ref, no ancestor).""" + # merge-recursive writes straight into the index/worktree, so refuse to run during + # another git operation or on a dirty tree rather than corrupt either. + op = blocking_operation() + if op is not None: + raise UserError(f"a {op} is already in progress; finish it or abort it first") + if worktree_dirty(): + raise UserError("working tree is not clean; commit or stash your changes first") + old_sha = _resolve_commit(old) + if old_sha is None: + raise UserError(f"old parent {old!r} is not a valid commit") + new_sha = _resolve_commit(new) + if new_sha is None: + raise UserError(f"{new!r} is not a valid commit") + # The forced base is what HEAD and its old parent share. git's own choice (against + # ) would keep the old parent's content; this drops it. + base = git("merge-base", "HEAD", old_sha, check=False) + if not base: + raise UserError(f"no common ancestor between HEAD and old parent {old!r}") + msg = message or f"Merge {new} into HEAD, dropping {old}" + return merge_with_base(base, new_sha, msg) + + +def cmd_merge_onto(new: str, old: str, message: str | None) -> int: + if merge_onto(new, old, message): + print(bold(f"git merge-onto: merged {new} into HEAD, dropping {old}."), file=sys.stderr) + return 0 + print( + f"\n{bold('git merge-onto: conflict. Resolve it like a normal merge:')}\n" + f" # edit the conflicted files, then:\n" + f" git add -A\n" + f" git commit --no-edit\n", + file=sys.stderr, + ) + return 1 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="git merge-onto", + description=( + "Re-parent HEAD onto , dropping : a 3-way merge of with " + "merge-base(HEAD, ) as the base. The merge equivalent of " + "`git rebase --onto `, without rewriting history." + ), + ) + p.add_argument("-m", "--message", help="commit message for a clean merge") + p.add_argument("--quiet", action="store_true", help="do not echo executed git commands") + p.add_argument("--version", action="version", version=f"git-merge-onto {__version__}") + p.add_argument("new", help="the new parent to merge into HEAD") + p.add_argument("old", help="the old parent to drop; the merge base is merge-base(HEAD, old)") + return p + + +def main(argv: list[str] | None = None) -> int: + global VERBOSE + args = build_parser().parse_args(sys.argv[1:] if argv is None else argv) + if args.quiet: + VERBOSE = False + try: + return cmd_merge_onto(args.new, args.old, args.message) + except UserError as e: + print(red(f"git merge-onto: error: {e}"), file=sys.stderr) + return 2 + except CommandError as e: + print(red(str(e)), file=sys.stderr) + return e.returncode or 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_e2e.sh b/tests/test_e2e.sh index b6e9f19..ccf521c 100755 --- a/tests/test_e2e.sh +++ b/tests/test_e2e.sh @@ -71,10 +71,9 @@ # - Squash merge PR2 (feature2) into main # # Expected Behavior: -# - The action merges feature2 into feature3 if that clean base merge is safe -# - Detects a merge conflict with the pre-squash target state (both modified -# line 7 differently) -# - Pushes only the clean base merge, never any conflicted state +# - The action re-parents feature3 onto main in one merge (git-merge-onto) +# - That merge conflicts (feature3 and main both modified line 7 differently) +# - Commits and pushes nothing; feature3 stays at its pre-conflict head # - Posts a comment on PR3 explaining the conflict # - Adds a label "autorestack-needs-conflict-resolution" to PR3 # - Does NOT update PR3's base branch (keeps it as feature2 for readable diff) @@ -86,7 +85,7 @@ # - PR3 base branch stays as feature2 (not updated to main) # - Conflict comment exists on PR3 # - Conflict label "autorestack-needs-conflict-resolution" exists on PR3 -# - feature3 branch advanced only by the clean base merge +# - feature3 branch is unchanged (nothing is pushed on a conflict) # # Manual Conflict Resolution (Steps 12-15): # - Test simulates user resolving the conflict manually @@ -128,11 +127,6 @@ # Tests that merging a PR with no children simply deletes the branch # and the action completes successfully. # -# SCENARIO 7: Conflict Matrix Edge Cases (Steps 32-34) -# ---------------------------------------------------- -# Tests base-branch conflicts, simultaneous base/trunk conflicts, and a -# follow-up trunk conflict discovered after a base conflict is resolved. -# # ============================================================================= set -e # Exit immediately if a command exits with a non-zero status. # set -x # Debugging: print commands as they are executed @@ -281,26 +275,16 @@ get_conflict_comment() { printf '%s\n' "$comment" } -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 origin/`. +assert_conflict_comment_reparent() { local comment=$1 - shift - local expected="" - local actual + local merged=$2 - for conflict in "$@"; do - expected+="git merge $conflict"$'\n' - done - expected=${expected%$'\n'} - actual=$(echo "$comment" | grep -E '^git merge' | grep -v -- '--ff-only' | sed 's/ *#.*//' || true) - - if [[ "$actual" == "$expected" ]]; then - echo >&2 "✅ Verification Passed: conflict comment lists expected merge command(s)." + if echo "$comment" | grep -qE "^uvx git-merge-onto [0-9a-f]{7,40} origin/${merged}\$"; then + echo >&2 "✅ Verification Passed: conflict comment has the re-parent command for origin/$merged." else - echo >&2 "❌ Verification Failed: conflict comment merge commands differ." - echo >&2 "--- Expected ---" - echo >&2 "$expected" - echo >&2 "--- Actual ---" - echo >&2 "$actual" + echo >&2 "❌ Verification Failed: conflict comment lacks 'uvx git-merge-onto origin/$merged'." echo >&2 "--- Full comment ---" echo >&2 "$comment" exit 1 @@ -1034,8 +1018,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) -PRE_SQUASH_COMMIT2=$(git rev-parse "$MERGE_COMMIT_SHA2~") -assert_conflict_comment_merges "$CONFLICT_COMMENT" "$PRE_SQUASH_COMMIT2" +assert_conflict_comment_reparent "$CONFLICT_COMMENT" "feature2" # Verify conflict label exists on PR3 echo >&2 "Checking for conflict label on PR #$PR3_NUM..." @@ -1050,47 +1033,27 @@ else exit 1 fi -# The base-branch merge (feature2 into feature3) is clean here; only the -# pre-squash merge conflicts. The action pushes that clean base merge before -# commenting, so feature3 stays a descendant of its base (mergeable, so the -# synchronize event that resumes the action keeps firing). Verify the push -# happened and that it only fast-forwarded on top of the pre-conflict head. +# 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. That +# unchanged head stays a descendant of its base (feature2), so the PR is mergeable +# and the synchronize event that resumes the action keeps firing. REMOTE_FEATURE3_SHA_BEFORE_RESOLVE=$(log_cmd git rev-parse "refs/remotes/origin/feature3") -if log_cmd git merge-base --is-ancestor "$FEATURE3_CONFLICT_COMMIT_SHA" "refs/remotes/origin/feature3" \ - && [[ "$REMOTE_FEATURE3_SHA_BEFORE_RESOLVE" != "$FEATURE3_CONFLICT_COMMIT_SHA" ]]; then - echo >&2 "✅ Verification Passed: action pushed the clean base merge on top of feature3 (still a descendant of its pre-conflict head)." +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." else - echo >&2 "❌ Verification Failed: expected origin/feature3 to advance from $FEATURE3_CONFLICT_COMMIT_SHA with the pushed base merge, got $REMOTE_FEATURE3_SHA_BEFORE_RESOLVE." + echo >&2 "❌ Verification Failed: origin/feature3 advanced on a conflict (expected $FEATURE3_CONFLICT_COMMIT_SHA, got $REMOTE_FEATURE3_SHA_BEFORE_RESOLVE)." log_cmd git log --graph --oneline origin/feature3 origin/feature2 exit 1 fi -# The base merge brought feature2's updated state into feature3... -if log_cmd git merge-base --is-ancestor "refs/remotes/origin/feature2" "refs/remotes/origin/feature3"; then - echo >&2 "✅ Verification Passed: origin/feature3 contains origin/feature2 (the pushed base merge)." -else - echo >&2 "❌ Verification Failed: origin/feature3 does not contain origin/feature2 after the push." - exit 1 -fi -# ...and the comment must ask only for the genuine conflict (the pre-squash -# merge), not the base merge the action already did and pushed. -if echo "$CONFLICT_COMMENT" | grep -q "^git merge origin/feature2"; then - echo >&2 "❌ Verification Failed: comment asks the user to merge origin/feature2, which the action already pushed." - echo >&2 "$CONFLICT_COMMENT" - exit 1 -else - echo >&2 "✅ Verification Passed: comment omits the base merge the action already pushed." -fi # 12. Resolve the conflict by following the comment the action posted. echo >&2 "12. Resolving conflict on feature3 by following the posted comment..." -# The action pushed the clean base merge to feature3, so the local branch is now -# behind origin. The comment tells the user to fast-forward to it before merging; -# skipping that would leave the resolution on a stale head and the final push -# would be rejected as non-fast-forward. Verify the comment carries that step, -# then run it. Following the comment must leave feature3 cleanly mergeable into -# its new base, or the synchronize-triggered continuation can never make progress -# and the conflict label stays stuck. +# 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. follow_conflict_comment "$CONFLICT_COMMENT" file.txt "feature3" 1 echo >&2 "Resolved file.txt content:" cat file.txt @@ -1230,7 +1193,7 @@ edit_and_commit "Add feature 7 (also modifies line 5)" 5 "Feature 7 conflicting create_pr feature7 feature5 "Feature 7" "This is PR 7, sibling of PR 6" PR7_URL PR7_NUM # Introduce conflicting change on main (line 5) - this will conflict with feature6/7 -# when the action tries to merge SQUASH_COMMIT~ into them +# when the action re-parents each of them onto main log_cmd git checkout main edit_and_commit "Add conflicting change on main line 5" 5 "Main conflicting content line 5" log_cmd git push origin main @@ -1294,9 +1257,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) -PRE_SQUASH_COMMIT5=$(git rev-parse "$MERGE_COMMIT_SHA5~") -assert_conflict_comment_merges "$PR6_CONFLICT_COMMENT" "$PRE_SQUASH_COMMIT5" -assert_conflict_comment_merges "$PR7_CONFLICT_COMMENT" "$PRE_SQUASH_COMMIT5" +assert_conflict_comment_reparent "$PR6_CONFLICT_COMMENT" "feature5" +assert_conflict_comment_reparent "$PR7_CONFLICT_COMMENT" "feature5" # 19. Resolve first sibling (feature6) - feature5 should still be kept echo >&2 "19. Resolving first sibling (feature6) by following the posted comment..." @@ -1654,186 +1616,6 @@ fi echo >&2 "--- No Children Scenario Test Completed Successfully ---" -# --- SCENARIO 7: Conflict Matrix Edge Cases --- -# =================================================================================== -# Covers conflict paths not exercised by the main trunk-conflict scenario: -# - base branch conflicts -# - base and trunk branch both conflict in the first run -# - base branch conflicts, then the pushed fix exposes a trunk conflict -# =================================================================================== - -echo >&2 "--- Testing Conflict Matrix Edge Cases ---" - -# 32. Base branch conflict only -echo >&2 "32. Testing base-branch conflict..." -log_cmd git checkout main -log_cmd git pull origin main - -log_cmd git checkout -b feature15 main -edit_and_commit "Add feature 15" 8 "Feature 15 content line 8" -create_pr feature15 main "Feature 15" "Base conflict parent" PR15_URL PR15_NUM - -log_cmd git checkout -b feature16 feature15 -edit_and_commit "Add feature 16" 9 "Feature 16 base conflict line 9" -FEATURE16_BEFORE_CONFLICT=$(git rev-parse HEAD) -create_pr feature16 feature15 "Feature 16" "Base conflict child" PR16_URL PR16_NUM - -log_cmd git checkout feature15 -edit_and_commit "Create base conflict for feature16" 9 "Feature 15 base conflict line 9" -log_cmd git push origin feature15 - -merge_pr_with_retry "$PR15_URL" -MERGE_COMMIT_SHA15=$(gh pr view "$PR15_URL" --repo "$REPO_FULL_NAME" --json mergeCommit -q .mergeCommit.oid) -if ! wait_for_workflow "$PR15_NUM" "feature15" "$MERGE_COMMIT_SHA15" "success"; then - echo >&2 "Workflow for PR15 merge did not complete successfully." - exit 1 -fi - -log_cmd git fetch origin --prune -if [[ "$(git rev-parse refs/remotes/origin/feature16)" == "$FEATURE16_BEFORE_CONFLICT" ]]; then - echo >&2 "✅ Verification Passed: base-conflicted feature16 was not pre-pushed." -else - echo >&2 "❌ Verification Failed: feature16 advanced even though the base merge conflicted." - exit 1 -fi -PR16_CONFLICT_COMMENT=$(get_conflict_comment "$PR16_URL" "$PR16_NUM" 1) -assert_conflict_comment_merges "$PR16_CONFLICT_COMMENT" "origin/feature15" -follow_conflict_comment "$PR16_CONFLICT_COMMENT" file.txt "feature16" 1 -if ! wait_for_synchronize_workflow "$PR16_NUM" "feature16" "success"; then - echo >&2 "Continuation workflow for feature16 did not complete successfully." - exit 1 -fi -PR16_LABEL_AFTER=$(gh pr view "$PR16_URL" --repo "$REPO_FULL_NAME" --json labels --jq '.labels[] | select(.name == "autorestack-needs-conflict-resolution") | .name') -PR16_BASE_AFTER=$(gh pr view "$PR16_NUM" --repo "$REPO_FULL_NAME" --json baseRefName --jq .baseRefName) -if [[ -z "$PR16_LABEL_AFTER" && "$PR16_BASE_AFTER" == "main" ]]; then - echo >&2 "✅ Verification Passed: feature16 conflict label removed and base updated." -else - echo >&2 "❌ Verification Failed: feature16 label='$PR16_LABEL_AFTER', base='$PR16_BASE_AFTER'." - exit 1 -fi -assert_pr_changed_lines "$PR16_URL" "PR16 diff contains only its resolved base conflict" "$(cat <<'EOF' --Feature 15 base conflict line 9 -+Conflict resolved on feature16 -EOF -)" - -# 33. Base and trunk branch both conflict in the first run -echo >&2 "33. Testing base-and-trunk conflict in one comment..." -log_cmd git checkout main -log_cmd git pull origin main - -log_cmd git checkout -b feature17 main -edit_and_commit "Add feature 17" 8 "Feature 17 content line 8" -create_pr feature17 main "Feature 17" "Base and trunk conflict parent" PR17_URL PR17_NUM - -log_cmd git checkout -b feature18 feature17 -edit_and_commit "Add feature 18" 9 "Feature 18 base conflict line 9" 13 "Feature 18 trunk conflict line 13" -FEATURE18_BEFORE_CONFLICT=$(git rev-parse HEAD) -create_pr feature18 feature17 "Feature 18" "Base and trunk conflict child" PR18_URL PR18_NUM - -log_cmd git checkout feature17 -edit_and_commit "Create base conflict for feature18" 9 "Feature 17 base conflict line 9" -log_cmd git push origin feature17 - -log_cmd git checkout main -edit_and_commit "Create trunk conflict for feature18" 13 "Main trunk conflict line 13" -log_cmd git push origin main - -merge_pr_with_retry "$PR17_URL" -MERGE_COMMIT_SHA17=$(gh pr view "$PR17_URL" --repo "$REPO_FULL_NAME" --json mergeCommit -q .mergeCommit.oid) -if ! wait_for_workflow "$PR17_NUM" "feature17" "$MERGE_COMMIT_SHA17" "success"; then - echo >&2 "Workflow for PR17 merge did not complete successfully." - exit 1 -fi - -log_cmd git fetch origin --prune -if [[ "$(git rev-parse refs/remotes/origin/feature18)" == "$FEATURE18_BEFORE_CONFLICT" ]]; then - echo >&2 "✅ Verification Passed: base-and-trunk-conflicted feature18 was not pre-pushed." -else - echo >&2 "❌ Verification Failed: feature18 advanced even though the base merge conflicted." - exit 1 -fi -PR18_CONFLICT_COMMENT=$(get_conflict_comment "$PR18_URL" "$PR18_NUM" 1) -PRE_SQUASH_COMMIT17=$(git rev-parse "$MERGE_COMMIT_SHA17~") -assert_conflict_comment_merges "$PR18_CONFLICT_COMMENT" "origin/feature17" "$PRE_SQUASH_COMMIT17" -follow_conflict_comment "$PR18_CONFLICT_COMMENT" file.txt "feature18" 2 -if ! wait_for_synchronize_workflow "$PR18_NUM" "feature18" "success"; then - echo >&2 "Continuation workflow for feature18 did not complete successfully." - exit 1 -fi -assert_pr_changed_lines "$PR18_URL" "PR18 diff contains only its two resolved conflicts" "$(cat <<'EOF' --Feature 17 base conflict line 9 -+Conflict resolved on feature18 --Main trunk conflict line 13 -+Conflict resolved on feature18 -EOF -)" - -# 34. Base branch conflict, followed by trunk conflict after the user pushes a fix -echo >&2 "34. Testing base conflict followed by trunk conflict on continuation..." -log_cmd git checkout main -log_cmd git pull origin main - -log_cmd git checkout -b feature19 main -edit_and_commit "Add feature 19" 8 "Feature 19 content line 8" -create_pr feature19 main "Feature 19" "Follow-up trunk conflict parent" PR19_URL PR19_NUM - -log_cmd git checkout -b feature20 feature19 -edit_and_commit "Add feature 20" 9 "Feature 20 base conflict line 9" -FEATURE20_BEFORE_CONFLICT=$(git rev-parse HEAD) -create_pr feature20 feature19 "Feature 20" "Follow-up trunk conflict child" PR20_URL PR20_NUM - -log_cmd git checkout feature19 -edit_and_commit "Create base conflict for feature20" 9 "Feature 19 base conflict line 9" -log_cmd git push origin feature19 - -log_cmd git checkout main -edit_and_commit "Create follow-up trunk conflict for feature20" 13 "Main follow-up trunk conflict line 13" -log_cmd git push origin main - -merge_pr_with_retry "$PR19_URL" -MERGE_COMMIT_SHA19=$(gh pr view "$PR19_URL" --repo "$REPO_FULL_NAME" --json mergeCommit -q .mergeCommit.oid) -if ! wait_for_workflow "$PR19_NUM" "feature19" "$MERGE_COMMIT_SHA19" "success"; then - echo >&2 "Workflow for PR19 merge did not complete successfully." - exit 1 -fi - -log_cmd git fetch origin --prune -if [[ "$(git rev-parse refs/remotes/origin/feature20)" == "$FEATURE20_BEFORE_CONFLICT" ]]; then - echo >&2 "✅ Verification Passed: base-conflicted feature20 was not pre-pushed." -else - echo >&2 "❌ Verification Failed: feature20 advanced even though the base merge conflicted." - exit 1 -fi -PR20_FIRST_CONFLICT_COMMENT=$(get_conflict_comment "$PR20_URL" "$PR20_NUM" 1) -assert_conflict_comment_merges "$PR20_FIRST_CONFLICT_COMMENT" "origin/feature19" - -introduce_feature20_trunk_conflict_before_push() { - edit_and_commit "Introduce follow-up trunk conflict" 13 "Feature 20 follow-up trunk conflict line 13" -} - -follow_conflict_comment "$PR20_FIRST_CONFLICT_COMMENT" file.txt "feature20" 1 introduce_feature20_trunk_conflict_before_push -if ! wait_for_synchronize_workflow "$PR20_NUM" "feature20" "failure"; then - echo >&2 "Expected continuation workflow for feature20 to fail with a new trunk conflict." - exit 1 -fi -PR20_SECOND_CONFLICT_COMMENT=$(get_conflict_comment "$PR20_URL" "$PR20_NUM" 2) -PRE_SQUASH_COMMIT19=$(git rev-parse "$MERGE_COMMIT_SHA19~") -assert_conflict_comment_merges "$PR20_SECOND_CONFLICT_COMMENT" "$PRE_SQUASH_COMMIT19" -follow_conflict_comment "$PR20_SECOND_CONFLICT_COMMENT" file.txt "feature20" 1 -if ! wait_for_synchronize_workflow "$PR20_NUM" "feature20" "success"; then - echo >&2 "Continuation workflow for feature20 did not complete successfully after trunk conflict resolution." - exit 1 -fi -assert_pr_changed_lines "$PR20_URL" "PR20 diff contains only the base and follow-up trunk resolutions" "$(cat <<'EOF' --Feature 19 base conflict line 9 -+Conflict resolved on feature20 --Main follow-up trunk conflict line 13 -+Conflict resolved on feature20 -EOF -)" - -echo >&2 "--- Conflict Matrix Edge Cases Completed Successfully ---" # --- Test Succeeded --- diff --git a/update-pr-stack.sh b/update-pr-stack.sh index 78b1c18..8ef1090 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -171,79 +171,58 @@ update_direct_target() { echo "Updating direct target $BRANCH (from $MERGED_BRANCH to $BASE_BRANCH)" - CONFLICTS=() - local BASE_MERGE_CLEAN=true - log_cmd git update-ref BEFORE_MERGE HEAD - if ! log_cmd git merge --no-edit "origin/$MERGED_BRANCH"; then - CONFLICTS+=("origin/$MERGED_BRANCH") - BASE_MERGE_CLEAN=false - abort_merge_if_in_progress - fi - # Only try merging the pre-squash target state if it's not already - # included in the merged branch — otherwise the first merge covers it. - if ! git merge-base --is-ancestor SQUASH_COMMIT~ "origin/$MERGED_BRANCH"; then - if ! log_cmd git merge --no-edit SQUASH_COMMIT~; then - CONFLICTS+=( "$(git rev-parse SQUASH_COMMIT~) # $TARGET_BRANCH just before $MERGED_BRANCH was merged" ) - abort_merge_if_in_progress - fi - fi - - if [[ "${#CONFLICTS[@]}" -gt 0 ]]; then - # When the base-branch merge was clean, HEAD now holds it (the - # conflicting pre-squash merge was aborted back to it). Push it before - # asking for help: the user resolves on top of it, and the head stays a - # descendant of its base so the PR stays mergeable and the synchronize - # event that resumes this action still fires. GitHub does not run - # pull_request workflows on a PR conflicting with its base, which would - # otherwise strand the branch for good. If the base merge itself - # conflicted we have nothing safe to pre-push, so we just ask for help. - # Note: ordering is important here: if we label before pushing, we - # re-trigger ourselves immediately. - if [[ "$BASE_MERGE_CLEAN" == true ]]; then - log_cmd git push origin "$BRANCH" - fi - { - echo "### ⚠️ Automatic update blocked by merge conflicts" - echo - echo "Resolve them like this:" - echo '```bash' - echo "git fetch origin" - echo "git switch $BRANCH" - echo "git merge --ff-only origin/$BRANCH" - - for i in "${!CONFLICTS[@]}"; do - echo "git merge ${CONFLICTS[$i]}" - echo '```' - echo - echo 'Fix the conflicts (for instance with `git mergetool`), then run `git commit` before continuing.' - echo - echo '```bash' - done - echo "git push origin $BRANCH" - echo '```' - echo - echo "Once you push, this action will resume and finish updating this pull request." - echo - format_state_marker "$MERGED_BRANCH" "$TARGET_BRANCH" "$(git rev-parse SQUASH_COMMIT)" - } | log_cmd gh pr comment "$PR_NUMBER" -F - - # Create the label if it doesn't exist, then add it to the PR - gh label create "$CONFLICT_LABEL" --description "PR needs manual conflict resolution" --color "d73a4a" 2>/dev/null || true - log_cmd gh pr edit "$PR_NUMBER" --add-label "$CONFLICT_LABEL" - return 1 - else - log_cmd git merge --no-edit -s ours SQUASH_COMMIT - log_cmd git update-ref MERGE_RESULT "HEAD^{tree}" - COMMIT_MSG="Merge updates from $BASE_BRANCH and squash commit" - if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then - COMMIT_MSG="$COMMIT_MSG + local MERGE_MSG="Merge updates from $BASE_BRANCH and squash commit" + if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + MERGE_MSG="$MERGE_MSG See $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" - fi - CUSTOM_COMMIT=$(log_cmd git commit-tree MERGE_RESULT -p BEFORE_MERGE -p "origin/$MERGED_BRANCH" -p SQUASH_COMMIT -m "$COMMIT_MSG") - log_cmd git reset --hard "$CUSTOM_COMMIT" fi - return 0 + # Re-parent the child onto the target in a single merge: merge the squash + # commit with the base forced to merge-base(HEAD, origin/$MERGED_BRANCH). That + # drops the merged branch's content (now carried by the target via the squash) + # while keeping the child's own changes -- the merge equivalent of + # `git rebase --onto`, done by the vendored git-merge-onto. + local RC=0 + log_cmd python3 "$SCRIPT_DIR/git-merge-onto" -m "$MERGE_MSG" SQUASH_COMMIT "origin/$MERGED_BRANCH" || RC=$? + if [[ "$RC" -eq 0 ]]; then + return 0 + fi + if [[ "$RC" -ne 1 ]]; then + echo "❌ git-merge-onto failed (exit $RC) while re-parenting $BRANCH" >&2 + exit 1 + fi + + # Conflict (exit 1): git-merge-onto committed nothing and left the merge in + # progress, so the head is unchanged and still a descendant of its base -- the + # PR stays mergeable and the synchronize event that resumes this action keeps + # firing. Clean the runner's tree, ask the user to resolve, and record the state + # so the next push can resume. The label comes last: it is what re-triggers us. + abort_merge_if_in_progress + { + echo "### ⚠️ Automatic update blocked by a merge conflict" + echo + echo "Resolve it like this:" + echo '```bash' + echo "git fetch origin" + echo "git switch $BRANCH" + echo "git merge --ff-only origin/$BRANCH" + echo "uvx git-merge-onto $(git rev-parse SQUASH_COMMIT) origin/$MERGED_BRANCH" + echo '```' + echo + 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" + echo '```' + echo + echo "Once you push, this action will resume and finish updating this pull request." + echo + format_state_marker "$MERGED_BRANCH" "$TARGET_BRANCH" "$(git rev-parse SQUASH_COMMIT)" + } | log_cmd gh pr comment "$PR_NUMBER" -F - + gh label create "$CONFLICT_LABEL" --description "PR needs manual conflict resolution" --color "d73a4a" 2>/dev/null || true + log_cmd gh pr edit "$PR_NUMBER" --add-label "$CONFLICT_LABEL" + return 1 } # Check if a PR has the conflict resolution label. @@ -342,22 +321,21 @@ continue_after_resolution() { return fi - # Same check for the old base: the resume re-merges origin/$OLD_BASE, so if - # that branch is gone (auto-delete head branches left enabled, or deleted - # manually) the merge can never succeed and the label would re-trigger a - # failing run on every push. Give up cleanly instead. + # Same check for the old base: the resume re-runs git-merge-onto against + # origin/$OLD_BASE, so if that branch is gone (auto-delete head branches left + # enabled, or deleted manually) it can never 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 echo "⚠️ Recorded base branch '$OLD_BASE' no longer exists; abandoning resume of $PR_BRANCH." abandon_resume "$PR_NUMBER" "ℹ️ The branch this PR was based on (\`$OLD_BASE\`) no longer exists, so autorestack stepped back. If this PR still needs its base updated, update its base manually." return fi - # The squash-merge run pushed the base merge and asked the user to resolve the - # pre-squash merge, but it never recorded the squash itself. Finish that now: - # re-run the same merge sequence as the squash-merge path. With the user's - # resolution in place the base merge and pre-squash merge are no-ops; only the - # "-s ours" squash record gets applied, keeping the diff against the new base - # clean. has_squash_commit makes this idempotent. + # The squash-merge run asked the user to resolve the re-parent and recorded the + # state, but committed nothing. Re-run the same re-parent now: with the user's + # resolution pushed, git-merge-onto is a no-op (the squash is already an + # ancestor of the resolved head), so update_direct_target just confirms the + # branch and moves on. has_squash_commit makes this idempotent. log_cmd git update-ref SQUASH_COMMIT "$SQUASH_HASH" MERGED_BRANCH="$OLD_BASE" TARGET_BRANCH="$NEW_TARGET" From f0835d414fd08986e63d987e1837b30d29784312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Fri, 26 Jun 2026 16:08:31 +0200 Subject: [PATCH 2/6] Pin setup-uv to a SHA in the e2e job astral-sh/setup-uv publishes no moving `v8` major tag, so `@v8` fails to resolve. Pin the exact v8.2.0 commit. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SRBBuBCbXvdmtRQiSMixVL --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c2c4dbe..9d09d78 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,7 +41,7 @@ jobs: # 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@v8 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Run e2e tests env: From 58aef36ca0142adee828200f02da3421809e51af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Fri, 26 Jun 2026 16:21:34 +0200 Subject: [PATCH 3/6] Name the merged branch in the re-parent commit message "Merge updates from and squash commit" was vague; the squash commit lives on the target, and the branch this commit exists because of is the merged one. Say " and ". Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SRBBuBCbXvdmtRQiSMixVL --- update-pr-stack.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/update-pr-stack.sh b/update-pr-stack.sh index 8ef1090..36fbccb 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -171,7 +171,7 @@ update_direct_target() { echo "Updating direct target $BRANCH (from $MERGED_BRANCH to $BASE_BRANCH)" - local MERGE_MSG="Merge updates from $BASE_BRANCH and squash commit" + local MERGE_MSG="Merge updates from $BASE_BRANCH and $MERGED_BRANCH" if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then MERGE_MSG="$MERGE_MSG From d0593d04bfe26dc71b9cf68a91ba1106fbd1b396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Fri, 26 Jun 2026 16:39:09 +0200 Subject: [PATCH 4/6] Point the conflict comment at the live trunk; fix the e2e grep The human-facing conflict comment now says `uvx git-merge-onto origin/ origin/` instead of pinning the squash SHA. If trunk advanced since the parent landed, the user resolves those conflicts now (they would have to before merging anyway) and gets readable branch names. The action's own call keeps SQUASH_COMMIT, the stable pin that keeps the resume a no-op. Also fix the e2e's get_conflict_comment: it still grepped the old plural "merge conflicts" heading after the single-merge rewrite made it "a merge conflict", so it found the (correctly posted) comment as 0. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SRBBuBCbXvdmtRQiSMixVL --- tests/test_e2e.sh | 19 ++++++++++--------- update-pr-stack.sh | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/test_e2e.sh b/tests/test_e2e.sh index ccf521c..78b2d7a 100755 --- a/tests/test_e2e.sh +++ b/tests/test_e2e.sh @@ -252,7 +252,7 @@ get_conflict_comment() { local comment local count - comments=$(log_cmd gh pr view "$pr_url" --repo "$REPO_FULL_NAME" --json comments --jq '[.comments[] | select(.body | contains("Automatic update blocked by merge conflicts")) | .body]') + comments=$(log_cmd gh pr view "$pr_url" --repo "$REPO_FULL_NAME" --json comments --jq '[.comments[] | select(.body | contains("Automatic update blocked by")) | .body]') count=$(echo "$comments" | jq 'length') comment=$(echo "$comments" | jq -r '.[-1] // ""') if [[ -n "$expected_count" && "$count" != "$expected_count" ]]; then @@ -276,15 +276,16 @@ get_conflict_comment() { } # The conflict comment must tell the user to run the re-parent the action tried: -# a single `uvx git-merge-onto origin/`. +# a single `uvx git-merge-onto origin/ origin/`. assert_conflict_comment_reparent() { local comment=$1 - local merged=$2 + local target=$2 + local merged=$3 - if echo "$comment" | grep -qE "^uvx git-merge-onto [0-9a-f]{7,40} origin/${merged}\$"; then - echo >&2 "✅ Verification Passed: conflict comment has the re-parent command for origin/$merged." + if echo "$comment" | grep -qxF "uvx git-merge-onto origin/$target origin/$merged"; 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/$merged'." + echo >&2 "❌ Verification Failed: conflict comment lacks 'uvx git-merge-onto origin/$target origin/$merged'." echo >&2 "--- Full comment ---" echo >&2 "$comment" exit 1 @@ -1018,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" "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..." @@ -1257,8 +1258,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" "feature5" -assert_conflict_comment_reparent "$PR7_CONFLICT_COMMENT" "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 36fbccb..4afec8a 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -207,7 +207,7 @@ See $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" echo "git fetch origin" echo "git switch $BRANCH" echo "git merge --ff-only origin/$BRANCH" - echo "uvx git-merge-onto $(git rev-parse SQUASH_COMMIT) origin/$MERGED_BRANCH" + echo "uvx git-merge-onto origin/$BASE_BRANCH origin/$MERGED_BRANCH" echo '```' echo echo 'Fix the conflicts (for instance with `git mergetool`), then run `git add -A && git commit` to finish the merge.' From 81c70a90d8e2af4cc4232ec5494d7e424179bc3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Fri, 26 Jun 2026 16:52:12 +0200 Subject: [PATCH 5/6] Make the resume verify the resolution instead of re-merging continue_after_resolution re-ran the re-parent through update_direct_target to "confirm" it. But git-merge-onto's forced base is the old parent, where the lines the user just resolved still differ from the trunk, so the re-merge re-raised the very conflict they had fixed; the no-op skip never fired because the conflict (rc=1) precedes it. (Not a merge-onto bug: "theirs is an ancestor" cannot mean "no-op", or a down-move that must drop the old parent's content would wrongly skip.) The resume only needs to confirm the user's pushed head contains the squash, then retarget and drop the label. Replace the re-merge with that ancestry check. update_direct_target is now used only by the squash-merge path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SRBBuBCbXvdmtRQiSMixVL --- update-pr-stack.sh | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/update-pr-stack.sh b/update-pr-stack.sh index 4afec8a..a341fc2 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -321,9 +321,9 @@ continue_after_resolution() { return fi - # Same check for the old base: the resume re-runs git-merge-onto against - # origin/$OLD_BASE, so if that branch is gone (auto-delete head branches left - # enabled, or deleted manually) it can never resolve and the label would + # 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 echo "⚠️ Recorded base branch '$OLD_BASE' no longer exists; abandoning resume of $PR_BRANCH." @@ -331,16 +331,15 @@ continue_after_resolution() { return fi - # The squash-merge run asked the user to resolve the re-parent and recorded the - # state, but committed nothing. Re-run the same re-parent now: with the user's - # resolution pushed, git-merge-onto is a no-op (the squash is already an - # ancestor of the resolved head), so update_direct_target just confirms the - # branch and moves on. has_squash_commit makes this idempotent. + # The user resolved by re-parenting (the comment's `git-merge-onto`), so the + # head now contains the squash commit. 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. A plain ancestry check is all the resume needs. log_cmd git update-ref SQUASH_COMMIT "$SQUASH_HASH" - MERGED_BRANCH="$OLD_BASE" - TARGET_BRANCH="$NEW_TARGET" - if ! update_direct_target "$PR_BRANCH" "$NEW_TARGET" "$PR_NUMBER"; then - echo "⚠️ '$PR_BRANCH' still conflicts; re-posted the conflict comment, will retry on next push" + log_cmd git checkout "$PR_BRANCH" + if ! git merge-base --is-ancestor SQUASH_COMMIT "$PR_BRANCH"; then + echo "⚠️ '$PR_BRANCH' does not contain the squash commit yet; resolution incomplete, will retry on next push" return 1 fi From ad3641e21d2a8bbce18f32f9cfe79167dc754779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Rubinstein?= Date: Fri, 26 Jun 2026 17:02:40 +0200 Subject: [PATCH 6/6] Fail the resume loudly when the push carries no resolution If the user pushes without finishing the re-parent, the head lacks the squash; make the run fail with an actionable message instead of retrying silently, so they notice and look again. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SRBBuBCbXvdmtRQiSMixVL --- update-pr-stack.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/update-pr-stack.sh b/update-pr-stack.sh index a341fc2..e14ca38 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -339,7 +339,10 @@ continue_after_resolution() { log_cmd git update-ref SQUASH_COMMIT "$SQUASH_HASH" log_cmd git checkout "$PR_BRANCH" if ! git merge-base --is-ancestor SQUASH_COMMIT "$PR_BRANCH"; then - echo "⚠️ '$PR_BRANCH' does not contain the squash commit yet; resolution incomplete, will retry on next push" + # Fail loudly rather than silently: the user pushed without finishing the + # re-parent, so a red run is the signal they need to look again. + echo "❌ '$PR_BRANCH' does not contain the squash commit; the conflict is not resolved." >&2 + echo " Follow the conflict comment on this PR (run its git-merge-onto command), then push again." >&2 return 1 fi