From solopreneur
Merges an open PR for the current branch via GitHub CLI. Checks for uncommitted changes, consolidates plan files, and cleans up stale worktrees from prior sessions.
How this skill is triggered — by the user, by Claude, or both
Slash command
/solopreneur:merge-prThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
When a Claude Code session starts, it locks a primary working directory. After
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.
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)")
# List all worktrees with their branch names, excluding current and main repo
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 # Never delete own worktree (would break session CWD)
[ "$wt" = "$MAIN_REPO" ] && continue # Never delete main repo
[[ "$br" = "main" || "$br" = "master" ]] && continue
# Only remove if the branch has a merged PR and no currently open PR
# (open PR check guards against branch-name reuse: an older merged PR
# with the same name must not cause deletion of an active worktree)
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
# Prune stale entries in the worktree registry (directories deleted manually)
git worktree prune -v
echo "=== Step 0 complete ==="
git worktree list
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"
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
# Refusal: the new flow commits everything intentionally, so anything
# uncommitted at this point is unintentional and must be surfaced.
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
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.
# --- solopreneur config helpers (inlined from shared/config.md) ---
# Compute the canonical repo identity used as the key under `.repos` in
# solopreneur.json. Falls back to git toplevel path, then $PWD.
solopreneur_repo_key() {
local url root
url=$(git remote get-url origin 2>/dev/null || true)
if [ -n "$url" ]; then
# Strip protocol schemes (https/http/ssh/git) and user prefixes (git@)
# in either order — origin URLs come in many shapes:
# https://github.com/owner/repo.git
# http://github.com/owner/repo.git
# ssh://[email protected]/owner/repo.git
# git://github.com/owner/repo.git
# [email protected]:owner/repo.git
url="${url#https://}"; url="${url#http://}"
url="${url#ssh://}"; url="${url#git://}"
url="${url#git@}"
url="${url%.git}"
# Replace the first `:` with `/` — the scp-style `git@host:owner/repo`
# form. Bash `${var/pattern/replacement}` parses the second `/` as the
# delimiter; the chars after it (`/` here) are the replacement, so this
# produces a single slash, not double. (Tested.)
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 a feature subtree from solopreneur.json with the 5-layer cascade:
# 1. primary .repos[<repo-key>].<feature>
# 2. primary .default.<feature>
# 3. fallback .repos[<repo-key>].<feature>
# 4. fallback .default.<feature>
# 5. legacy top-level .<feature> (primary then fallback)
# First non-null wins. Each layer is checked inline (no nested helper
# function — bash function declarations are global, even nested ones, and
# would pollute the user's shell namespace).
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
# Layer 1: primary .repos[<repo-key>].<feature>
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
# Layer 2: primary .default.<feature>
out=$(jq -r --arg fk "$key" '.default[$fk] | values' "$primary" 2>/dev/null)
if [ -n "$out" ]; then printf '%s\n' "$out"; return; fi
fi
# Layers 3 + 4: fallback file, only if different from primary
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
# Layer 5: legacy top-level — primary then fallback
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 a feature subtree to .default.<key> in the primary file.
# Sibling keys are preserved (atomic read-modify-write).
# Usage: write_solopreneur_config greenlight '{fallback_order:["codex-bot","gemini"]}'
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 a feature subtree to .repos[<repo-key>].<key> in the primary file.
# Sibling repos AND sibling features within the same repo are preserved.
# Usage: write_solopreneur_repo_config preview '{path:"docs/preview"}'
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"
}
# --- end solopreneur config helpers ---
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.
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')
# 1. Update status line in first 20 lines
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)
# 2. Find all Handoff Context sections for this branch
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
# 3. Rename the latest (last in file) to Final Progress
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):]
# 4. Delete older same-branch Handoff Context sections (all except latest)
# After rename, re-find remaining old headings (indices may have shifted)
for old_start, old_heading in reversed(matches[:-1]):
# Find the section extent: from heading to just before the next ## heading
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)
# Delete the old section (trim leading newlines from what follows)
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')
# 1. Update status line in first 20 lines
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)
# 2. Find all Handoff Context sections for this branch
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
# 3. Rename the latest (last in file) to Final Progress
# Safe: matches are sorted ascending by position; latest_start is always the highest
# offset, so the rename does not affect byte offsets of earlier (old) headings.
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):]
# 4. Delete older same-branch Handoff Context sections (all except latest)
# Iterate in reverse so higher-offset deletions don't shift lower-offset positions.
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"
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
}
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
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
gh pr merge $PR_NUMBER --squash --delete-branch 2>&1 || true
# Verify the merge succeeded
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.
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.
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.npx claudepluginhub p/hanamizuki-solopreneur-plugins-solopreneurMerges reviewed PRs when triggered by /pr-merge or merge commands. Handles squash/rebase, worktrees, integration branches, and auto-merge for CI gating.
Merges a PR, removes a Git worktree, and deletes the associated state file. Final phase of a multi-step local branch/PR workflow.
Creates or updates a GitHub PR or GitLab MR using Conventional PR format with intent, summary, changes, rationale, and test plan. Supports multi-repo workspaces.