diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 856d69c..9d09d78 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@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + - 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..78b2d7a 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 @@ -258,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 @@ -281,26 +275,17 @@ 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/ origin/`. +assert_conflict_comment_reparent() { local comment=$1 - shift - local expected="" - local actual + local target=$2 + local merged=$3 - 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 -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 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/$target origin/$merged'." echo >&2 "--- Full comment ---" echo >&2 "$comment" exit 1 @@ -1034,8 +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) -PRE_SQUASH_COMMIT2=$(git rev-parse "$MERGE_COMMIT_SHA2~") -assert_conflict_comment_merges "$CONFLICT_COMMENT" "$PRE_SQUASH_COMMIT2" +assert_conflict_comment_reparent "$CONFLICT_COMMENT" "main" "feature2" # Verify conflict label exists on PR3 echo >&2 "Checking for conflict label on PR #$PR3_NUM..." @@ -1050,47 +1034,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 +1194,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 +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) -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" "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..." @@ -1654,186 +1617,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..e14ca38 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 $MERGED_BRANCH" + 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 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.' + 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,27 +321,28 @@ 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 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." 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 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 + # 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