Merge PR
Overview
Merge a prepared PR via deterministic squash merge (--match-head-commit + explicit co-author trailer), then clean up the worktree after success.
- Ask for PR number or URL.
- If missing, use
.local/prep.env from the worktree if present.
- If ambiguous, ask.
Safety
- Use
gh pr merge --squash as the only path to main.
- Do not run
git push at all during merge.
- Do not use
gh pr merge --auto for maintainer landings.
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
Execution Rule
- Execute the workflow. Do not stop after printing the TODO checklist.
- If delegating, require the delegate to run commands and capture outputs.
- If you see "fatal: not a git repository", you are in the wrong directory. Move to the repo root and retry.
- Read
.local/review.md, .local/prep.md, and .local/prep.env in the worktree. Do not skip.
- Always merge with
--match-head-commit "$PREP_HEAD_SHA" to prevent racing stale or changed heads.
- Clean up
.worktrees/pr-<PR> only after confirmed MERGED.
Completion Criteria
- Ensure
gh pr merge succeeds.
- Ensure PR state is
MERGED, never CLOSED.
- Record the merge SHA.
- Leave a PR comment with merge SHA and prepared head SHA, and capture the comment URL.
- Run cleanup only after merge success.
First: Create a TODO Checklist
Create a checklist of all merge steps, print it, then continue and execute the commands.
Setup: Use a Worktree
Use an isolated worktree for all merge work.
sh
1repo_root=$(git rev-parse --show-toplevel)
2cd "$repo_root"
3gh auth status
4
5WORKTREE_DIR=".worktrees/pr-<PR>"
6cd "$WORKTREE_DIR"
Run all commands inside the worktree directory.
Load Local Artifacts (Mandatory)
Expect these files from earlier steps:
.local/review.md from /review-pr
.local/prep.md from /prepare-pr
.local/prep.env from /prepare-pr
sh
1ls -la .local || true
2
3for required in .local/review.md .local/prep.md .local/prep.env; do
4 if [ ! -f "$required" ]; then
5 echo "Missing $required. Stop and run /review-pr then /prepare-pr."
6 exit 1
7 fi
8done
9
10sed -n '1,120p' .local/review.md
11sed -n '1,120p' .local/prep.md
12source .local/prep.env
Steps
- Identify PR meta and verify prepared SHA still matches
sh
1pr_meta_json=$(gh pr view <PR> --json number,title,state,isDraft,author,headRefName,headRefOid,baseRefName,headRepository,body)
2printf '%s\n' "$pr_meta_json" | jq '{number,title,state,isDraft,author:.author.login,head:.headRefName,headSha:.headRefOid,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
3pr_title=$(printf '%s\n' "$pr_meta_json" | jq -r .title)
4pr_number=$(printf '%s\n' "$pr_meta_json" | jq -r .number)
5pr_head_sha=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefOid)
6contrib=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login)
7is_draft=$(printf '%s\n' "$pr_meta_json" | jq -r .isDraft)
8
9if [ "$is_draft" = "true" ]; then
10 echo "ERROR: PR is draft. Stop and run /prepare-pr after draft is cleared."
11 exit 1
12fi
13
14if [ "$pr_head_sha" != "$PREP_HEAD_SHA" ]; then
15 echo "ERROR: PR head changed after /prepare-pr (expected $PREP_HEAD_SHA, got $pr_head_sha). Re-run /prepare-pr."
16 exit 1
17fi
- Run sanity checks
Stop if any are true:
- PR is a draft.
- Required checks are failing.
- Branch is behind main.
If checks are pending, wait for completion before merging. Do not use --auto.
If no required checks are configured, continue.
sh
1gh pr checks <PR> --required --watch --fail-fast || true
2checks_json=$(gh pr checks <PR> --required --json name,bucket,state 2>/tmp/gh-checks.err || true)
3if [ -z "$checks_json" ]; then
4 checks_json='[]'
5fi
6required_count=$(printf '%s\n' "$checks_json" | jq 'length')
7if [ "$required_count" -eq 0 ]; then
8 echo "No required checks configured for this PR."
9fi
10printf '%s\n' "$checks_json" | jq -r '.[] | "\(.bucket)\t\(.name)\t\(.state)"'
11
12failed_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="fail")] | length')
13pending_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="pending")] | length')
14if [ "$failed_required" -gt 0 ]; then
15 echo "Required checks are failing, run /prepare-pr."
16 exit 1
17fi
18if [ "$pending_required" -gt 0 ]; then
19 echo "Required checks are still pending, retry /merge-pr when green."
20 exit 1
21fi
22
23git fetch origin main
24git fetch origin pull/<PR>/head:pr-<PR> --force
25git merge-base --is-ancestor origin/main pr-<PR> || (echo "PR branch is behind main, run /prepare-pr" && exit 1)
If anything is failing or behind, stop and say to run /prepare-pr.
- Merge PR with explicit attribution metadata
sh
1reviewer=$(gh api user --jq .login)
2reviewer_id=$(gh api user --jq .id)
3coauthor_email=${COAUTHOR_EMAIL:-"$contrib@users.noreply.github.com"}
4if [ -z "$coauthor_email" ] || [ "$coauthor_email" = "null" ]; then
5 contrib_id=$(gh api users/$contrib --jq .id)
6 coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com"
7fi
8
9gh_email=$(gh api user --jq '.email // ""' || true)
10git_email=$(git config user.email || true)
11mapfile -t reviewer_email_candidates < <(
12 printf '%s\n' \
13 "$gh_email" \
14 "$git_email" \
15 "${reviewer_id}+${reviewer}@users.noreply.github.com" \
16 "${reviewer}@users.noreply.github.com" | awk 'NF && !seen[$0]++'
17)
18[ "${#reviewer_email_candidates[@]}" -gt 0 ] || { echo "ERROR: could not resolve reviewer author email"; exit 1; }
19reviewer_email="${reviewer_email_candidates[0]}"
20
21cat > .local/merge-body.txt <<EOF
22Merged via /review-pr -> /prepare-pr -> /merge-pr.
23
24Prepared head SHA: $PREP_HEAD_SHA
25Co-authored-by: $contrib <$coauthor_email>
26Co-authored-by: $reviewer <$reviewer_email>
27Reviewed-by: @$reviewer
28EOF
29
30run_merge() {
31 local email="$1"
32 local stderr_file
33 stderr_file=$(mktemp)
34 if gh pr merge <PR> \
35 --squash \
36 --delete-branch \
37 --match-head-commit "$PREP_HEAD_SHA" \
38 --author-email "$email" \
39 --subject "$pr_title (#$pr_number)" \
40 --body-file .local/merge-body.txt \
41 2> >(tee "$stderr_file" >&2)
42 then
43 rm -f "$stderr_file"
44 return 0
45 fi
46 merge_err=$(cat "$stderr_file")
47 rm -f "$stderr_file"
48 return 1
49}
50
51merge_err=""
52selected_merge_author_email="$reviewer_email"
53if ! run_merge "$selected_merge_author_email"; then
54 if printf '%s\n' "$merge_err" | rg -qi 'author.?email|email.*associated|associated.*email|invalid.*email' && [ "${#reviewer_email_candidates[@]}" -ge 2 ]; then
55 selected_merge_author_email="${reviewer_email_candidates[1]}"
56 echo "Retrying once with fallback author email: $selected_merge_author_email"
57 run_merge "$selected_merge_author_email" || { echo "ERROR: merge failed after fallback retry"; exit 1; }
58 else
59 echo "ERROR: merge failed"
60 exit 1
61 fi
62fi
Retry is allowed exactly once when the error is clearly author-email validation.
- Verify PR state and capture merge SHA
sh
1state=$(gh pr view <PR> --json state --jq .state)
2if [ "$state" != "MERGED" ]; then
3 echo "Merge not finalized yet (state=$state), waiting up to 15 minutes..."
4 for _ in $(seq 1 90); do
5 sleep 10
6 state=$(gh pr view <PR> --json state --jq .state)
7 if [ "$state" = "MERGED" ]; then
8 break
9 fi
10 done
11fi
12
13if [ "$state" != "MERGED" ]; then
14 echo "ERROR: PR state is $state after waiting. Leave worktree and retry /merge-pr later."
15 exit 1
16fi
17
18merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
19if [ -z "$merge_sha" ] || [ "$merge_sha" = "null" ]; then
20 echo "ERROR: merge commit SHA missing."
21 exit 1
22fi
23
24commit_body=$(gh api repos/:owner/:repo/commits/$merge_sha --jq .commit.message)
25contrib=${contrib:-$(gh pr view <PR> --json author --jq .author.login)}
26reviewer=${reviewer:-$(gh api user --jq .login)}
27printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $contrib <" || { echo "ERROR: missing PR author co-author trailer"; exit 1; }
28printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $reviewer <" || { echo "ERROR: missing reviewer co-author trailer"; exit 1; }
29
30echo "merge_sha=$merge_sha"
- PR comment
Use a multiline heredoc with interpolation enabled.
sh
1ok=0
2comment_output=""
3for _ in 1 2 3; do
4 if comment_output=$(gh pr comment <PR> -F - <<EOF
5Merged via squash.
6
7- Prepared head SHA: $PREP_HEAD_SHA
8- Merge commit: $merge_sha
9
10Thanks @$contrib!
11EOF
12); then
13 ok=1
14 break
15 fi
16 sleep 2
17done
18
19[ "$ok" -eq 1 ] || { echo "ERROR: failed to post PR comment after retries"; exit 1; }
20comment_url=$(printf '%s\n' "$comment_output" | rg -o 'https://github.com/[^ ]+/pull/[0-9]+#issuecomment-[0-9]+' -m1 || true)
21[ -n "$comment_url" ] || comment_url="unresolved"
22echo "comment_url=$comment_url"
- Clean up worktree only on success
Run cleanup only if step 4 returned MERGED.
sh
1cd "$repo_root"
2git worktree remove ".worktrees/pr-<PR>" --force
3git branch -D temp/pr-<PR> 2>/dev/null || true
4git branch -D pr-<PR> 2>/dev/null || true
5git branch -D pr-<PR>-prep 2>/dev/null || true
Guardrails
- Worktree only.
- Do not close PRs.
- End in MERGED state.
- Clean up only after merge success.
- Never push to main. Use
gh pr merge --squash only.
- Do not run
git push at all in this command.