| name | merge-pr |
| description | Merge an open PR for the current branch. Refuses to merge when the worktree
has uncommitted changes, consolidates the branch's plan file (renaming
the latest Handoff Context section to Final Progress and moving the file
to done/ in state-machine mode), merges via GitHub CLI, and reports status.
Also cleans up stale merged-but-not-removed worktrees from prior sessions
on every run.
Use when the user says "merge", "merge PR", "merge this", or wants to
land the current branch.
|
Merge PR
Core Design: Why the Current Session's Own Worktree Cannot Be Deleted
When a Claude Code session starts, it locks a primary working directory. After
each Bash tool call completes, the harness resets CWD back to that path. If the
current session's worktree is deleted mid-session (e.g. via git worktree remove), the next Bash call fails its cd before any command runs — the
entire session becomes non-functional.
Solution: leave your own worktree for the next session to clean up. Every
/merge-pr run scans all worktrees and removes stale merged ones, except the
current session's own worktree. This caps the long-term residue at 1 leftover
worktree, which gets cleaned on the next /merge-pr run from any other session.
Flow
Step 0: Scan and clean up stale worktrees from prior sessions (always runs)
Regardless of whether there is a new PR to merge, start by cleaning up any
worktrees left over from previous sessions whose branches have already been
merged. Skip only the current session's own worktree.
CURRENT_WORKTREE=$(git rev-parse --show-toplevel)
MAIN_REPO=$(dirname "$(git rev-parse --git-common-dir)")
git worktree list --porcelain | awk '
/^worktree /{path=substr(\$0, index(\$0,\$2))}
/^branch refs\/heads\//{
br=\$2; sub(/^refs\/heads\//, "", br)
print path "\t" br
}
' | while IFS=$'\t' read -r wt br; do
[ "$wt" = "$CURRENT_WORKTREE" ] && continue
[ "$wt" = "$MAIN_REPO" ] && continue
[[ "$br" = "main" || "$br" = "master" ]] && continue
OPEN_PR=$(gh pr list --state open --head "$br" --json number --jq '.[0].number' 2>/dev/null)
[ -n "$OPEN_PR" ] && continue
PR_NUM=$(gh pr list --state merged --head "$br" --json number --jq '.[0].number' 2>/dev/null)
if [ -n "$PR_NUM" ]; then
echo "Cleaning up stale worktree: $wt (branch: $br, PR #$PR_NUM merged)"
git worktree remove --force "$wt" 2>/dev/null || true
git branch -D "$br" 2>/dev/null || true
git push origin --delete "$br" 2>/dev/null || true
fi
done
git worktree prune -v
echo "=== Step 0 complete ==="
git worktree list
Step 1: Confirm the current PR to merge
Determine the branch name from the current working context. If there is no open
PR for this branch (e.g. the user only wanted stale-worktree cleanup), stop
after Step 0.
BRANCH=$(git branch --show-current)
PR_NUMBER=$(gh pr view --json number --jq '.number' 2>/dev/null)
if [ -z "$PR_NUMBER" ]; then
echo "No open PR for the current branch — cleanup only, not merging."
exit 0
fi
echo "Ready to merge: branch=$BRANCH, PR=#$PR_NUMBER"
Step 2: Pre-merge uncommitted-changes check (only runs in a worktree)
Refuse to proceed if the worktree has any uncommitted changes. The new flow
commits everything intentionally, so anything uncommitted here is a mistake
that must be surfaced before merging.
IS_WORKTREE=$([ "$(git rev-parse --git-common-dir)" != "$(git rev-parse --git-dir)" ] && echo yes || echo no)
if [ "$IS_WORKTREE" = "yes" ]; then
if ! git diff --quiet HEAD; then
echo "Worktree has uncommitted changes — refusing to merge."
echo "Commit or stash first, then re-run /merge-pr."
exit 1
fi
fi
Step 3: Consolidate the plan file for this branch
Resolve the plan file associated with the current branch, rename the latest
Handoff Context section to Final Progress, delete older same-branch Handoff
Context sections, update the status line, optionally move the file to done/,
and commit each change.
Step 3a: Resolve plan file path
solopreneur_repo_key() {
local url root
url=$(git remote get-url origin 2>/dev/null || true)
if [ -n "$url" ]; then
url="${url#https://}"; url="${url#http://}"
url="${url#ssh://}"; url="${url#git://}"
url="${url#git@}"
url="${url%.git}"
url="${url/://}"
printf '%s\n' "$url"
return
fi
root=$(git rev-parse --show-toplevel 2>/dev/null || true)
if [ -n "$root" ]; then
printf '%s\n' "$root"
return
fi
printf '%s\n' "$PWD"
}
read_solopreneur_config() {
local key="\$1"
local primary="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/solopreneur.json"
local fallback="$HOME/.claude/solopreneur.json"
local repo_key; repo_key=$(solopreneur_repo_key)
local out
if [ -f "$primary" ]; then
out=$(jq -r --arg rk "$repo_key" --arg fk "$key" '.repos[$rk][$fk] | values' "$primary" 2>/dev/null)
if [ -n "$out" ]; then printf '%s\n' "$out"; return; fi
out=$(jq -r --arg fk "$key" '.default[$fk] | values' "$primary" 2>/dev/null)
if [ -n "$out" ]; then printf '%s\n' "$out"; return; fi
fi
if [ "$primary" != "$fallback" ] && [ -f "$fallback" ]; then
out=$(jq -r --arg rk "$repo_key" --arg fk "$key" '.repos[$rk][$fk] | values' "$fallback" 2>/dev/null)
if [ -n "$out" ]; then printf '%s\n' "$out"; return; fi
out=$(jq -r --arg fk "$key" '.default[$fk] | values' "$fallback" 2>/dev/null)
if [ -n "$out" ]; then printf '%s\n' "$out"; return; fi
fi
if [ -f "$primary" ]; then
out=$(jq -r --arg fk "$key" '.[$fk] | values' "$primary" 2>/dev/null)
if [ -n "$out" ]; then printf '%s\n' "$out"; return; fi
fi
if [ "$primary" != "$fallback" ] && [ -f "$fallback" ]; then
out=$(jq -r --arg fk "$key" '.[$fk] | values' "$fallback" 2>/dev/null)
if [ -n "$out" ]; then printf '%s\n' "$out"; return; fi
fi
}
write_solopreneur_config() {
local key="\$1"
local value_expr="\$2"
local primary="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/solopreneur.json"
local tmp existing
mkdir -p "$(dirname "$primary")"
tmp=$(mktemp "${primary}.XXXXXX")
existing=$(cat "$primary" 2>/dev/null); [ -z "$existing" ] && existing='{}'
printf '%s\n' "$existing" \
| jq --arg fk "$key" --argjson v "$(jq -n "$value_expr")" \
'.default = ((.default // {}) | .[$fk] = $v)' \
> "$tmp" || { rm -f "$tmp"; return 1; }
mv "$tmp" "$primary"
}
write_solopreneur_repo_config() {
local key="\$1"
local value_expr="\$2"
local primary="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/solopreneur.json"
local repo_key; repo_key=$(solopreneur_repo_key)
local tmp existing
mkdir -p "$(dirname "$primary")"
tmp=$(mktemp "${primary}.XXXXXX")
existing=$(cat "$primary" 2>/dev/null); [ -z "$existing" ] && existing='{}'
printf '%s\n' "$existing" \
| jq --arg rk "$repo_key" --arg fk "$key" --argjson v "$(jq -n "$value_expr")" \
'.repos = ((.repos // {}) | .[$rk] = ((.[$rk] // {}) | .[$fk] = $v))' \
> "$tmp" || { rm -f "$tmp"; return 1; }
mv "$tmp" "$primary"
}
TODOS_CONFIG=$(read_solopreneur_config todos)
PLANS_CONFIG=$(read_solopreneur_config plans)
BACKLOG=$(echo "${TODOS_CONFIG:-{}}" | jq -r '.backlog // empty')
DOING=$(echo "${TODOS_CONFIG:-{}}" | jq -r '.doing // empty')
DONE_DIR=$(echo "${TODOS_CONFIG:-{}}" | jq -r '.done // empty')
PLANS_DIR=$(echo "${PLANS_CONFIG:-{}}" | jq -r '.dir // empty')
PLANS_DIR="${PLANS_DIR:-docs/solopreneur/plans}"
BRANCH=$(git branch --show-current)
Build the list of plan roots — union of $BACKLOG, $DOING, $DONE_DIR, and
$PLANS_DIR (whichever are set and exist on disk).
Resolution order (first match wins):
-
Plan-Branch marker (primary): search for *.md files containing
Plan-Branch: <exact branch> in any plan root.
PLAN_FILE=""
for root in "$BACKLOG" "$DOING" "$DONE_DIR" "$PLANS_DIR"; do
[ -z "$root" ] && continue
[ -d "$root" ] || continue
match=$(grep -lF "Plan-Branch: ${BRANCH}" "$root"/*.md 2>/dev/null | head -1)
if [ -n "$match" ]; then
PLAN_FILE="$match"
break
fi
done
-
Commit-history grep (fallback): only if PLAN_FILE is still empty.
Find the most recent commit matching the handoff pattern and extract its
.md file path.
if [ -z "$PLAN_FILE" ]; then
HANDOFF_SHA=$(git log --pretty=format:'%H %s' main..HEAD \
| grep -F "docs(handoff): context for ${BRANCH}" | head -1 | awk '{print \$1}')
if [ -n "$HANDOFF_SHA" ]; then
PLAN_FILE=$(git show --name-only --format='' "$HANDOFF_SHA" \
| grep -E '\.md$' \
| head -1)
fi
fi
-
No match → skip consolidation. Not every branch has a plan file.
Step 3 still succeeds; consolidation is simply not performed.
Step 3b: Rule-based consolidation
Only runs if PLAN_FILE is set and the file exists on disk.
Changes to make in PLAN_FILE:
-
Find all ## Handoff Context (<date>, branch: <BRANCH>) sections
(matched by branch name in the heading):
ESC_BRANCH=$(printf '%s' "$BRANCH" | sed 's/[]\.[*^$(){}?+|]/\\&/g')
grep -n "^## Handoff Context (.*branch: ${ESC_BRANCH})" "$PLAN_FILE"
-
Rename the latest matching section to
## Final Progress (merged <MERGE_DATE>, branch: <BRANCH>) where
<MERGE_DATE> = $(date +%Y-%m-%d). Do NOT modify any [ ] or [x]
checkboxes inside the section.
-
Delete older same-branch Handoff Context sections (all but the latest).
"Older" means earlier in the file. Sections from other branches are left
untouched.
-
Update the top-of-file status line if present: find a line matching
Status: <something> in the first 20 lines and replace it with
Status: merged <MERGE_DATE>.
Implementation — use Python to process the file (sed is too fragile for
multi-line section deletion). The key invariant: never touch [ ] or [x]
checkboxes.
Write the following script to a temp file and run it:
import re, sys, os
from datetime import date
def consolidate(path, branch):
merge_date = date.today().isoformat()
with open(path) as f:
content = f.read()
lines = content.split('\n')
for i in range(min(20, len(lines))):
if re.match(r'^Status:', lines[i]):
lines[i] = f'Status: merged {merge_date}'
break
content = '\n'.join(lines)
pattern = rf'^## Handoff Context \([^)]*branch: {re.escape(branch)}\)'
matches = [(m.start(), m.group()) for m in re.finditer(pattern, content, re.MULTILINE)]
if not matches:
print(f'No Handoff Context sections found for branch {branch}')
return content
latest_start, latest_heading = matches[-1]
new_heading = f'## Final Progress (merged {merge_date}, branch: {branch})'
content = content[:latest_start] + new_heading + content[latest_start + len(latest_heading):]
for old_start, old_heading in reversed(matches[:-1]):
section_start = old_start
after = content[section_start + len(old_heading):]
next_h2 = re.search(r'\n## ', after)
if next_h2:
section_end = section_start + len(old_heading) + next_h2.start()
else:
section_end = len(content)
rest = content[section_end:].lstrip('\n')
content = content[:section_start] + rest
return content
if __name__ == '__main__':
path = sys.argv[1]
branch = sys.argv[2]
result = consolidate(path, branch)
with open(path, 'w') as f:
f.write(result)
print('Consolidation complete')
Run as:
TMPSCRIPT=$(mktemp /tmp/consolidate_XXXXXX.py)
cat > "$TMPSCRIPT" << 'PYEOF'
import re, sys, os
from datetime import date
def consolidate(path, branch):
merge_date = date.today().isoformat()
with open(path) as f:
content = f.read()
lines = content.split('\n')
for i in range(min(20, len(lines))):
if re.match(r'^Status:', lines[i]):
lines[i] = f'Status: merged {merge_date}'
break
content = '\n'.join(lines)
pattern = rf'^## Handoff Context \([^)]*branch: {re.escape(branch)}\)'
matches = [(m.start(), m.group()) for m in re.finditer(pattern, content, re.MULTILINE)]
if not matches:
print(f'No Handoff Context sections found for branch {branch}')
return content
latest_start, latest_heading = matches[-1]
new_heading = f'## Final Progress (merged {merge_date}, branch: {branch})'
content = content[:latest_start] + new_heading + content[latest_start + len(latest_heading):]
for old_start, old_heading in reversed(matches[:-1]):
section_start = old_start
after = content[section_start + len(old_heading):]
next_h2 = re.search(r'\n## ', after)
if next_h2:
section_end = section_start + len(old_heading) + next_h2.start()
else:
section_end = len(content)
rest = content[section_end:].lstrip('\n')
content = content[:section_start] + rest
return content
if __name__ == '__main__':
path = sys.argv[1]
branch = sys.argv[2]
result = consolidate(path, branch)
with open(path, 'w') as f:
f.write(result)
print('Consolidation complete')
PYEOF
python3 "$TMPSCRIPT" "$PLAN_FILE" "$BRANCH"
rm -f "$TMPSCRIPT"
Step 3c: Commit consolidation
Only run if consolidation actually changed the file.
git add "$PLAN_FILE"
git diff --cached --quiet || {
git commit -m "docs: consolidate plan progress before merging PR #${PR_NUMBER}"
git push
}
Step 3d: Move file (state-machine mode only)
State-machine mode = both $DOING and $DONE_DIR are set. Only move the file
if it currently lives in $DOING. Filename is preserved — same date prefix,
no re-dating.
if [ -n "$DOING" ] && [ -n "$DONE_DIR" ]; then
if [[ "$PLAN_FILE" == "$DOING"/* ]]; then
FILENAME=$(basename "$PLAN_FILE")
mkdir -p "$DONE_DIR"
git mv "$PLAN_FILE" "$DONE_DIR/$FILENAME"
fi
fi
Step 3e: Commit the move (state-machine only)
if [ -n "$DOING" ] && [ -n "$DONE_DIR" ] && [[ "$PLAN_FILE" == "$DOING"/* ]]; then
FILENAME=$(basename "$PLAN_FILE")
git commit -m "chore: move $FILENAME to done/"
git push
fi
Step 4: CI gate — merge only when checks for the head SHA are green
Before merging, confirm CI has passed for the exact commit that will be
merged. Every read is pinned to the PR's current head SHA so a just-pushed
commit whose CI has not registered yet can never inherit an earlier commit's
green — a gate that passes on a stale green is worse than no gate, because
everything downstream then trusts a false signal.
Four outcomes (all handled by the loop below):
- All checks for the head SHA green → merge.
- Any check pending, or no checks reported yet → not green. Poll every
60s, up to 10 attempts (mirrors autopilot Step 6). Still not green → abort
with
CI still pending — not merging. Absence of checks is never success.
- Any check failed → abort, listing the failing check names.
- Repo has zero CI checks configured at all → merge, but print a
prominent
merged with no CI signal flag line. This is concluded only
after the full poll budget elapses with no check ever reported — a check
that shows up pending keeps us in outcome 2, never here.
HEAD_SHA=$(gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid')
if [ -z "$HEAD_SHA" ]; then
echo "Could not resolve head SHA for PR $PR_NUMBER — refusing to merge blind."
exit 1
fi
echo "Gating merge on CI for head SHA: $HEAD_SHA"
ci_verdict_for_sha() {
local sha="$1" cr st
cr=$(gh api --paginate --slurp "repos/{owner}/{repo}/commits/$sha/check-runs?per_page=100") || { echo pending; return; }
st=$(gh api "repos/{owner}/{repo}/commits/$sha/status") || { echo pending; return; }
local cr_total cr_bad cr_incomplete st_total st_state
cr_total=$(jq -r '.[0].total_count // 0' <<<"$cr")
cr_bad=$(jq '[.[].check_runs[]? | select(.status=="completed") | select(.conclusion!="success" and .conclusion!="neutral" and .conclusion!="skipped")] | length' <<<"$cr")
cr_incomplete=$(jq '[.[].check_runs[]? | select(.status!="completed")] | length' <<<"$cr")
st_total=$(jq -r '.total_count // 0' <<<"$st")
st_state=$(jq -r '.state // "pending"' <<<"$st")
if [ "$cr_total" -eq 0 ] && [ "$st_total" -eq 0 ]; then echo none; return; fi
if [ "$cr_bad" -gt 0 ] || [ "$st_state" = failure ] || [ "$st_state" = error ]; then echo failed; return; fi
if [ "$cr_incomplete" -gt 0 ] || { [ "$st_total" -gt 0 ] && [ "$st_state" = pending ]; }; then echo pending; return; fi
echo green
}
MAX_ATTEMPTS=10
ATTEMPT=0
EVER_SAW_CHECK=0
NO_CI_SIGNAL=0
while :; do
VERDICT=$(ci_verdict_for_sha "$HEAD_SHA")
if [ "$VERDICT" = green ]; then
echo "CI green for $HEAD_SHA — proceeding to merge."
break
fi
if [ "$VERDICT" = failed ]; then
echo "CI FAILED for $HEAD_SHA — refusing to merge. Failing checks:"
gh api --paginate "repos/{owner}/{repo}/commits/$HEAD_SHA/check-runs?per_page=100" \
--jq '.check_runs[] | select(.status=="completed") | select(.conclusion!="success" and .conclusion!="neutral" and .conclusion!="skipped") | " - \(.name): \(.conclusion)"'
gh api "repos/{owner}/{repo}/commits/$HEAD_SHA/status" \
--jq '.statuses[]? | select(.state=="failure" or .state=="error") | " - \(.context): \(.state)"'
exit 1
fi
[ "$VERDICT" = pending ] && EVER_SAW_CHECK=1
ATTEMPT=$((ATTEMPT + 1))
if [ "$ATTEMPT" -ge "$MAX_ATTEMPTS" ]; then
if [ "$EVER_SAW_CHECK" -eq 1 ]; then
echo "CI still pending — not merging (waited ${MAX_ATTEMPTS}×60s for $HEAD_SHA)."
exit 1
fi
echo "FLAG: merged with no CI signal — no checks ever reported for $HEAD_SHA."
NO_CI_SIGNAL=1
break
fi
if [ "$EVER_SAW_CHECK" -eq 1 ]; then
echo "CI pending for $HEAD_SHA (attempt $ATTEMPT/$MAX_ATTEMPTS) — waiting 60s..."
else
echo "No checks reported yet for $HEAD_SHA (attempt $ATTEMPT/$MAX_ATTEMPTS) — treating as pending, waiting 60s..."
fi
sleep 60
done
Step 5: Merge the PR
|| true is intentionally absent: a merge rejected by branch protection
(or any other gh error) must surface as an explicit failure carrying gh's
own error output, not be swallowed as success.
LATEST_HEAD=$(gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid')
if [ "$LATEST_HEAD" != "$HEAD_SHA" ]; then
echo "PR head moved ($HEAD_SHA → $LATEST_HEAD) since the CI gate — refusing to merge a commit CI hasn't cleared. Re-run /merge-pr to re-gate the new head."
exit 1
fi
if ! gh pr merge "$PR_NUMBER" --squash --delete-branch; then
echo "Merge command failed (see gh error above) — stopping."
exit 1
fi
STATE=$(gh pr view "$PR_NUMBER" --json state --jq '.state')
echo "PR state: $STATE"
[ "$STATE" = "MERGED" ] || { echo "Merge failed — stopping."; exit 1; }
Note: --delete-branch deletes the remote branch. The local branch deletion
will fail because the worktree is still checked out — this is expected. Step 0
of the next /merge-pr run (from any other session) will clean it up.
Step 6: Report status
PR #<N> merged to main (commit <sha>)
Current worktree retained (will be cleaned automatically when /merge-pr runs
from another session):
worktree: <path>
branch: <branch>
To clean up immediately, run /merge-pr from the main repo or another worktree session.
If the CI gate (Step 4) merged with the no-CI-signal flag set
(NO_CI_SIGNAL=1), prepend this prominent line to the report so it cannot be
missed. Omit it entirely on the normal CI-green path:
[FLAG] merged with no CI signal — repo has no CI checks configured
Notes
Worktree behaviour
- Never delete the current session's own worktree — it would make the
session CWD unreachable and disable all further Bash calls.
- Other sessions' worktrees are safe to remove (Step 0 does this).
git worktree remove --force with --force avoids stalling if the
directory has already been deleted manually.
|| true provides error tolerance for non-fatal cleanup operations.
General
- Only process the PR for the current conversation's branch; never merge
unrelated PRs.
- If the branch has only a local checkout with no remote PR, ask the user
how to proceed.
- Stop and report on merge failure; do not continue.