gh-pr
Open a pull request from the current branch with a title and body that reflect what actually changed, not just the last commit message. The goal is a PR a reviewer can understand without reading the diff first.
Use gh for everything. It handles auth, the API, and returns a URL at the end. Do not script raw API calls.
The shape of the work
Three phases, in order. The hard rule that drives the ordering: nothing gets pushed until the work is confirmed to be on the right branch. Phase 2 is entirely local and reversible; the first irreversible-ish action (push) is the top of Phase 3.
Phase 1 Can I run? local gates + gh auth
any gate fails -> stop
|
v
Phase 2 Right place? base -> assess -> branch-fit -> settle tree
all local, nothing pushed yet
|
v
Phase 3 Open the PR push -> compose -> gh pr create -> url
PHASE 1 · Can I run?
Step 0: cheap local gates
All local — no network, no push — so they fail in milliseconds before any expensive work. Order matters: cheapest and most fundamental first.
Hard exits — nothing downstream can recover, so bail immediately with a clear message:
- Not a git repo.
git rev-parse --is-inside-work-tree. Fastest check; run it first. ghnot installed.command -v gh. The cheap, local half of the auth check — keep it separate fromgh auth status(network, see Preflight). "Not installed" and "not logged in" need different messages.- No
originremote.git remote get-url origin(non-zero exit = noorigin). This skill assumes a single remote namedoriginand pushes the branch there; with nooriginthere's nowhere to push and nothing forghto target, so stop here. Fork workflows are out of scope — pushing to a fork while basing the PR against anupstreamrepo (--head owner:branch) is deliberately not handled. If the only remote is named something else (e.g.upstream), stop and ask rather than guessing. - Remote isn't GitHub.
git remote get-url origin, check the host. GitLab/Bitbucket/anything else →ghcan't help, stop here. - Detached HEAD or unborn branch.
git symbolic-ref -q HEAD(empty = detached; also catches a fresh repo with no commits). No branch means nothing to PR.
Confirm and stop — possible but almost always a mistake, so pause rather than refuse:
- On the default branch. A PR from
mainintomainis almost always wrong. Cheap path: ifgit symbolic-ref refs/remotes/origin/HEADresolves locally (it only exists when the clone setorigin/HEAD, e.g. viagit remote set-head origin -a), compare against it here. If it's missing, skip the check here — Preflight resolves the default branch authoritatively and re-checks. - Mid-operation. Check for
MERGE_HEAD,.git/rebase-merge,.git/rebase-apply,.git/CHERRY_PICK_HEAD,.git/REVERT_HEAD. A rebase/merge/cherry-pick/revert in flight means the tree is half-applied — a PR now is garbage. - Zero commits ahead of base. A smoke test only — base isn't resolved yet (that's Phase 2). Compare against the locally-resolved default branch (the
origin/HEADtarget from Step 0's default-branch check; skip if that ref is missing) usinggit rev-list --count origin/<default>..HEAD. Before treating zero as a bail, checkgit status --porcelain: a dirty tree means the work exists but is uncommitted — don't exit; carry on to Phase 2 and let "Settle uncommitted changes" commit it. Only zero commits + clean tree is a true hard exit. Phase 2 authoritatively determines the base (including any user-specified base) and supersedes this check; a non-zero count here is not a guarantee against the real base, andorigin/<default>may be locally stale.
Once Step 0 passes, git branch --show-current gives the head branch for everything below.
Preflight (network)
Costs a round trip, so it runs only after Step 0 clears.
gh auth status— confirm the CLI is authenticated. If not, point the user atgh auth login; do not log in for them.Resolve the default branch once, here. Everything downstream (the Step 0 default-branch check, the base in Phase 2) refers back to this single value. Fallback chain — first hit wins:
git symbolic-ref --short refs/remotes/origin/HEAD→ strip theorigin/prefix (local, no round trip).gh repo view --json defaultBranchRef --jq .defaultBranchRef.name(authoritative, one round trip).
If Step 0's local check was skipped because
origin/HEADwas missing, re-check now that the head branch isn't this default.
PHASE 2 · Is the work in the right place?
Everything in this phase is local. Resolve all of it before pushing.
Determine the base branch
Reuse the default branch already resolved in Preflight — don't resolve it again. Use that default unless the user named a different base. Stacked PRs (a feature branch targeting another feature branch) are real but rare — the user usually says so. When in doubt, ask rather than assume main.
Assess local state
Gather the facts once; they feed both the branch-fit decision and the PR body.
First resolve the upstream once, because the default first-PR path has none — the branch has never been pushed, so origin/<branch> doesn't exist and any origin/<branch>..HEAD comparison errors out:
git rev-parse --abbrev-ref @{u} 2>/dev/null # upstream, or empty
- Upstream exists → unpushed commits are
git log --oneline @{u}..HEAD. - No upstream (first PR) → there's nothing to compare against; treat every commit since base as unpushed and skip the
origin/<branch>comparisons entirely.
git status --porcelain # dirty tree?
git log --reverse --format='%s%n%b' origin/<base>..HEAD # commits since base
git diff --stat origin/<base>...HEAD # files + scope
gh pr list --head <branch> --state open --json number,url,title # open PR?
The local-only delta = unpushed commits + uncommitted changes. That's the work that isn't yet captured anywhere — and it's the thing the branch-fit check reasons about. On the first-PR path every commit since base is part of that delta.
Branch-fit decision
The failure mode: the user finished feature A (maybe already PR'd), started feature B without switching branches, and is now about to push B's work onto A.
Do not anchor on "does the branch name match the diff." Generic/personal names (dev, wip, dzh/misc) match nothing and false-positive constantly, and branches legitimately grow scope. Anchor on whether the branch already has a PR and whether there's local-only work on top of it:
open PR on branch? local-only delta? -> action
-----------------------------------------------------------------
no either open a fresh PR
yes none nothing new -> stop
yes some STOP and ask (below)
Only the last row stops a routine flow. In that case summarize the delta only (not the whole branch), e.g. "Branch <branch> already has open PR #123. You have 2 unpushed commits and 4 changed files since then — do these belong on this branch, or should they move to their own branch?"
Keep this gate high-precision. If it interrupts routine PRs with "are you sure you're on the right branch?", the user learns to mash through it and it stops being useful. Only the bottom-right cell stops the flow.
If the work needs to move
The remedy forks on committed vs uncommitted, because the mechanics and risk differ sharply:
move the work off this branch:
uncommitted -> git switch -c <new> origin/<base> safe, just do it
(then continue — PR opens from <new>)
committed -> git switch -c <new> origin/<base> you do this
cherry-pick the stray commits onto <new>
...then reset old branch + force-push user's call — STOP
The committed path stops short of touching the old branch on purpose: cleaning the stray commits off it rewrites history, which needs a force-push — and this skill never force-pushes automatically, least of all on a branch that already has an open PR. Do steps 1–2, then hand the rewrite to the user.
The critical detail in both paths: fork from origin/<base>, not the current tip. git switch -c <new> from where you're standing bases the new branch on top of the old one, and its PR would then drag in all of the old branch's commits.
Settle uncommitted changes
Now that the work is on the right branch:
git status --porcelain
If the tree is dirty, surface the files and stop. Do not commit on the user's behalf — they may be mid-thought, and a silent commit buries decisions they wanted to make. Ask: commit, stash, or leave out. If they ask you to commit, write the message as a Conventional Commit (see Phase 3) — pick the type from what changed, not a blanket chore.
PHASE 3 · Open the PR
Push the branch
git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null
- No upstream →
git push -u origin <branch>. - Upstream exists, local ahead →
git push. - Never
--forceor--force-with-lease. If the push is rejected, report it and let the user decide.
Compose the title and body
Optional: refresh the base before writing the body.
Run git fetch origin <base> to update origin/<base> so the diff commands from Phase 2 (git diff --stat origin/<base>...HEAD, git log ... origin/<base>..HEAD) reflect the true merge-base rather than a potentially stale local copy. Do this when the base may have moved since the last fetch — merges, rebases, or a shared branch with active teammates. Skip it when speed matters or you know the base is fresh (e.g. you just pulled it). It costs one network round-trip.
Check for project conventions first:
- PR template — GitHub resolves templates from three root locations in priority order:
.github/,docs/, and the repo root. In each location look forpull_request_template.md,PULL_REQUEST_TEMPLATE.md, or aPULL_REQUEST_TEMPLATE/directory. So the full set of candidates is:.github/pull_request_template.md,.github/PULL_REQUEST_TEMPLATE.md,.github/PULL_REQUEST_TEMPLATE/,docs/pull_request_template.md,docs/PULL_REQUEST_TEMPLATE.md,docs/PULL_REQUEST_TEMPLATE/,pull_request_template.md,PULL_REQUEST_TEMPLATE.md,PULL_REQUEST_TEMPLATE/. If one exists, fill its sections rather than inventing your own structure. The maintainers chose that shape for a reason. - Linked issues — scan the branch name and commits:
- GitHub issues like
#123/fixes #123. - Linear keys like
eng-412, often in branch names (dzh/eng-412-token-refresh). Linear auto-links PRs whose title or body contains the key, so make sure it lands in one. - If the branch clearly maps to an issue but no closing keyword is present, add one (
Closes #123/Fixes ENG-412) unless the change only partially addresses it.
- GitHub issues like
Title: one line, imperative, describing the change as a whole — if the branch has five commits, summarize all of them, not commit #5. Conventional Commit format by default, but match an evident repo convention when one clearly exists (e.g. a commitlint config, or consistent existing PR/commit titles) — forcing CC against a repo's settled style can clash with commitlint or squash-merge tooling. Type and scope reflect the net change.
Conventional Commit format
Used for the PR title and for any commit this skill creates:
type(optional-scope): short imperative description
- Types:
feat,fix,docs,style,refactor,perf,test,build,ci,chore,revert. Pick the most specific that fits; don't default tochore. - Scope is optional but useful — the package/module/area touched, e.g.
feat(auth):. - Description is lowercase, imperative, no trailing period, under ~72 chars.
- Breaking changes: append
!after type/scope (feat(api)!: ...); for a commit, add aBREAKING CHANGE:footer.
If the change spans several types, pick the dominant one rather than inventing a compound type.
Body (when no template exists) — short and skimmable:
## What
<one or two sentences: which files, what kind of change>
## Why
<one or two sentences: what made this necessary>
## Notes for review
<the contestable parts only>
<Closes #N / Fixes ENG-N if applicable>
Target under ~300 words plus at most one table. Past that, the extra is almost always restatement.
"Notes for review" is the section that goes wrong. It is not a summary of the diff — the diff is right there. It is the short list of things a reviewer could disagree with: an inference you drew, a judgement call, a number you're asserting, something you got wrong earlier in the branch. If none of that exists, drop the section.
Rules that keep it short:
- One headline finding, stated first. Open with the single thing a reviewer must not skim past, in bold. Everything else supports it. Three co-equal bold blocks means you haven't decided what matters.
- State an implication once. If a bullet already says what the finding means, don't add a paragraph explaining what that means in turn.
- Point into the artifact, don't copy it. When the diff already carries the full detail, abbreviate and say "full table in the record". The body is a reading order, not a second copy.
- Table rows are distinct behaviours, not distinct commands you ran. Collapse rows sharing an outcome and a cause. Eight verification runs producing four behaviours is a four-row table.
- Cut negative results unless a reviewer would otherwise assume the thing broke.
- Scope the body to the diff. Open questions the change surfaced but doesn't touch belong in an issue, not here.
Rules that keep it useful. Never cut these to save words:
- Where you were wrong earlier in the branch, and what you corrected.
- Why a superseded decision was right at the time. "X was recorded as not-Y, which was true at merge; Y landed afterwards" reads as drift rather than as an error to hunt for.
- Exact identifiers: policy IDs, error strings, versions, flags. They tell a reviewer where to look.
Don't pad it.
Draft vs ready
Default to ready for review. Switch to --draft automatically if the branch looks unfinished — commit subjects containing wip, tmp, fixup!, or squash!. Always respect an explicit request for either (e.g. the user passes "draft" in $ARGUMENTS).
Open
gh pr create \
--base <base> \
--head <branch> \
--title "<title>" \
--body "<body>" \
[--draft]
Do not add reviewers, assignees, labels, or milestones unless asked — auto-assigning spams people and auto-labeling guesses at taxonomy you don't control.
If gh pr create says a PR already exists, don't open a second one — surface the existing URL and ask whether to update it (gh pr edit). (Phase 2 normally catches this first.)
Finish
Print the PR URL on its own line. That's what the user wants. Don't summarize the diff back to them — they just wrote it.
Then, on the next line, print a one-line auto-merge hint: enable auto-merge: gh pr merge <number> --auto --squash (substitute the PR number; pick the merge method the repo uses — --squash, --merge, or --rebase). The skill never runs this — it only surfaces the option so the user can opt in explicitly. Skip the hint for draft PRs (not mergeable yet).
Hard limits
- Never force-push.
- Never auto-merge or enable auto-merge.
- Never commit uncommitted changes without explicit confirmation.
- Never open a PR from the default branch into itself.
- Never add reviewers/labels/assignees unprompted.
- Never push before the branch-fit decision is resolved.