/git-pr — put a branch up for review
Optional $ARGUMENTS, in any order:
- a bare branch name → override the base branch
--title "..."→ override the PR title--draft→ open as a draft--no-worktree→ skip the worktree step
Why this skill exists
A small pull request gets reviewed. A large one gets approved. That is not a joke about discipline; it is what reviewers actually do. Attention is finite, so a 40-line diff gets read line by line and a 2,000-line diff gets skimmed for anything obviously alarming and then waved through. The large PR feels like more progress and delivers less review — which is the entire point of opening it.
Large PRs also rot. They sit for days, the base moves, they conflict, the author rebases, the reviewer's earlier comments no longer apply, and the whole thing has to be re-read. A branch that lives a week is competing with everyone else's work instead of joining it. If your change is genuinely large, the answer is a sequence of small PRs, not one big one — and this skill will say so at Step 3.
The second reason this skill exists is narrower and mechanical: it must be impossible for this workflow to push to the base branch. Three independent checks enforce that (Steps 1, 4), because the cost of being annoying about it is tiny and the cost of getting it wrong once is a rewritten shared history.
Live state
Current branch:
!git branch --show-current
Working tree:
git status --short
Repo default branch, and what recent PRs actually target:
gh repo view --json defaultBranchRef --jq '"default: " + .defaultBranchRef.name' 2>/dev/null || echo "(gh unavailable)"
gh pr list --state all --limit 10 --json baseRefName --jq '"recent bases: " + ([.[].baseRefName] | unique | join(", "))' 2>/dev/null || echo ""
Step 1 — Resolve the base; get off a protected branch
BASE = the first bare token of $ARGUMENTS, else base from
.claude/best-practices-git.json, else the repo's default branch as reported
above. When recent PRs consistently target a different branch than the
repo default, prefer what the team actually does and say so in the report — a
repo's default-branch metadata goes stale, team habit does not.
PROTECTED = {main, master, develop, release, production, prod} ∪ {BASE}, or
the protectedBranches list from config. The set is deliberately generous: any
branch a reasonable reader would call a trunk belongs in it. A false positive
costs the user one rename; a false negative costs a shared branch.
CURRENT == BASE is not a separate case, and must not be handled as one. BASE
defaults to the repo's default branch, so "I'm on main, open a PR" is
CURRENT == BASE — the single most common way this skill gets invoked. Refusing
there on the grounds that a branch cannot be a pull request into itself would
send the user away to do by hand the exact move the rescue below performs for
them. BASE is a member of PROTECTED precisely so that standing on it takes
the rescue path like any other trunk.
If CURRENT is protected — BASE included: do not refuse. The user asked
to open a PR; they clearly meant to be on a branch. Move them:
- Take stock —
git status --shortandgit log --oneline @{u}..HEAD. If both are empty there is nothing to PR; stop and say so rather than creating an empty branch. - Ask for the branch name (
AskUserQuestionfor the type —feat,fix,chore,refactor— then the slug in chat). Follow the repo's existing branch convention ifgit branch -ashows one. git switch -c <type>/<slug>— non-destructive; uncommitted changes come along, and any local commits are now reachable from the new branch.- If the protected branch had local commits ahead of its remote, ask before
cleaning up, then
git branch -f <protected> origin/<protected>. Those commits are safe: they live on the new branch. Never do this silently.
From here CURRENT means the new branch — which is what makes standing on the
base survivable instead of fatal: after the switch CURRENT != BASE, and the
pull request is <new branch> → BASE, which is what the user meant all along.
The only branch that genuinely cannot be reviewed is one with nothing on it, and
that is caught on evidence — by the take-stock step above, before a branch is
cut, and again by Step 3 — never up front on a name comparison.
Step 2 — Commit whatever is uncommitted
If the tree is dirty, run the commit skill rather than inlining a
git commit here. There is exactly one definition of "a commit in this repo" and
it lives there — conventions inferred from history, the secrets screen, staging
by name, hooks. Duplicating those rules here guarantees the two drift, and the
one that goes stale is this one.
If commit stops (an unsplit changeset, a refused file, a failing hook), stop
too. Continue only once it reports a commit.
If the user says "PR what is already pushed, leave my working tree alone", respect that and skip to Step 3.
Step 3 — Confirm there is something to review, and that it is reviewable
git fetch origin <BASE>
git log --oneline origin/<BASE>..HEAD
git diff --stat origin/<BASE>...HEAD
Empty log → nothing to PR. Stop.
Now look at the size. If the diff is large (a few hundred changed lines across unrelated areas is the useful threshold, not a hard number) and the commits split cleanly along a seam — a refactor separable from the feature, a dependency bump, a formatting sweep, one package independent of another — say so in one line and offer to open the independent part as its own PR first. Offer once, then proceed with whatever the user chooses. This is advice, not a gate; the author knows things you do not.
Never pad a PR to look complete. A PR that does one thing and says so is finished.
Step 4 — Push the branch
One form, always:
git push -u origin <CURRENT>
Do not first check "does this branch have an upstream" and take a bare git push
if so. Having an upstream does not mean having its own upstream: a branch cut
from a remote ref (git checkout -b work origin/main, the correct move when your
local base is stale) has origin/main as its upstream. A bare git push then
does whatever push.default says — which, on the upstream setting, pushes your
commits to the base branch. The explicit form cannot push anywhere but the branch
you name, and it repairs the upstream on the way. There is nothing to decide.
Never --force or --force-with-lease in this skill at all. Force-pushing a
branch someone is reviewing throws away the review's anchor points; when it is
genuinely needed, it is a deliberate, separate act.
If the push is rejected by branch protection, surface the error verbatim and stop. Do not rename the branch to get around it.
Step 5 — Open the PR
5a. Check whether one already exists — carefully
gh pr list --head <CURRENT> --state all --json number,url,state
Use --head. A bare gh pr view resolves "the current branch's PR" through the
branch's tracking config, not its name — so on a branch cut from a remote ref
it cheerfully reports that other branch's PR as yours. The failure is silent
and expensive: you skip PR creation, report success, and leave the work pushed
with nothing open on it. The opposite mistake is harmless, because gh pr create
refuses to open a duplicate, loudly.
If a PR really exists for CURRENT, print its URL and go to Step 6.
5b. Compose the title and body
Title (≤70 characters): --title if given; else the subject of the single
commit; else the most informative recent subject. Same conventions as the commit
subjects — a PR title is read in the same lists.
Body. Derive it from the commits, then write it for a reviewer who has not seen any of this:
## What this changes
- <2–4 bullets, condensed from the commits — not one bullet per commit>
## Why
<the constraint, bug, or decision behind it — one short paragraph. Skip only
when the title genuinely says it all.>
## How to check it
- [ ] <the actual command or click-path a reviewer would use>
The "how to check it" section is the one reviewers use most and authors write
least. Derive it from the diff — the test command, the endpoint, the page. If
nothing honest comes to mind, leave - [ ] <fill in> for the human. Never invent
a test plan; a fabricated one is worse than none, because it gets trusted.
Append whatever footer pr.signature in the config specifies. If an agent wrote
the body but a human's account is posting it, say so there — a reader deserves to
know who is on the other end. Write the body in the author's voice, first person
or plainly impersonal. Never refer to the author in the third person ("Nic asked
for…"): under their own avatar it reads as a stranger narrating them.
5c. Assignee and reviewers
- Assignee: the author (
--assignee "@me"), so the PR has an owner in every board and filter. An unassigned PR is nobody's. - Reviewers: resolve
.github/CODEOWNERSfrom the base branch, not the working tree — GitHub enforces the base's copy, and a branch cut before CODEOWNERS landed will not have it locally, which would silently request nobody:
git diff --name-only origin/<BASE>...HEAD
git show origin/<BASE>:.github/CODEOWNERS
Apply it last-match-wins to each changed file, union the owners, and subtract the
author (GitHub refuses a request to review your own PR). If the base has no
CODEOWNERS, or the author owns everything they touched, request nobody and say
so in the report — do not invent a reviewer. If the config sets
pr.reviewers, use that instead.
5d. Create it
gh pr create --base <BASE> --head <CURRENT> \
--assignee "@me" \
--reviewer <owner1>,<owner2> \
--title "<title>" --body "$(cat <<'EOF'
<body>
EOF
)"
Include --reviewer only when the set is non-empty — never pass the placeholder.
Add --draft when asked, or when pr.draft is true in config. Opening as a
draft is the right default for a team whose review bots run on every PR: it says
"not yet" without a comment thread about it.
If gh rejects a reviewer, the PR may already exist — the reviewer step
failed after creation. Check with gh pr list --head <CURRENT> before retrying;
if it exists, fix it with gh pr edit --add-reviewer, and only re-run
gh pr create if nothing is open. Blindly retrying is how you end up with two
PRs for one branch.
Print the URL on its own line. The user wants to click it.
Step 6 — Move the PR into a worktree
This is the default, not a follow-up question. Opening the PR is what frees you to start the next thing; the worktree is what actually frees the checkout, and review comments will arrive on this branch for the rest of the day.
Skip only when --no-worktree was passed, pr.worktree is false in config, the
user has said they do not want one, or a worktree for this PR already exists.
If you are already in a linked worktree, rename it so the directory carries
the PR number — with five worktrees open, pr-412-fix-login is a working index:
node "$CLAUDE_PLUGIN_ROOT/scripts/worktree.mjs" rename <PR-NUMBER>
If you are in the main checkout, move it off the branch and create a worktree
for the PR (git worktree add refuses a branch that is checked out elsewhere —
a git invariant, not a policy):
git switch <BASE>
node "$CLAUDE_PLUGIN_ROOT/scripts/worktree.mjs" pr <PR-NUMBER>
If $CLAUDE_PLUGIN_ROOT is not set in this environment, the script sits at
../../scripts/worktree.mjs relative to this file — resolve it once and reuse
the path.
If the script fails, surface the error and continue to Step 7 with the worktree marked skipped. Do not try to repair worktree state from here.
Step 7 — Report
Six lines at most: PR URL and base; title; commit count; whether commit ran;
assignee and reviewers (or why there are none); worktree path; anything gh
warned about. No diff summary — they have the link.
Hard rules
- Never push to a protected branch. Three checks, no exceptions.
- Never
--force/--force-with-leasein this skill. - Never merge.
gh pr mergeis out of scope. Opening the PR is the contract; merging is a decision with an owner, and it is not this workflow. - Never bypass the
commitskill by inlininggit commit. - Never
--no-verify, on commit or on push. - Never move a protected branch's ref without explicit confirmation.