G10 · Part 2 · Working together
Pull requests and GitHub
How a change gets from your branch into main on GitHub: forks, reviews, protection rules, merge queues, the three merge buttons and the gh CLI.
14On this page
A pull request is not a Git feature. Git has branches and merges;
GitHub (like GitLab, where it is called a merge request) adds a page
around a proposed merge where people can discuss it, review it line by
line, and run checks against it before anything reaches
main. Knowing where Git ends and GitHub begins makes both
easier to reason about. Everything on this page is GitHub's layer on
top of the Git you already know.
You push a branch, then open a pull request (PR) asking to merge
it into main. Teammates read the diff and comment,
automated checks run your tests, and once it is approved and
green someone presses the merge button. You fix review comments
by pushing more commits to the same branch; the PR updates
itself. After the merge, delete the branch and start the next
task from a fresh main.
Forks or branches
A PR always compares two branches: the base (where the change will land) and the head (the change). The head can live in the same repository or in a fork, which is your own server-side copy of someone else's repository.
| Branch in the same repo | Fork | |
|---|---|---|
| Who uses it | Team members with write access | Anyone, including people you don't know |
| Remotes | origin only | origin (your fork) and upstream (the real project) |
| CI secrets | Available to workflows | Withheld, and the token is read-only, so untrusted code can't steal them |
| Maintainers can push fixes | Yes | Only if the author leaves "Allow edits by maintainers" ticked |
| Keeping current | git pull | git fetch upstream then rebase or merge, or gh repo sync |
$ gh repo fork acme/shop --clone
$ cd shop
$ git remote -v
origin https://github.com/priya/shop.git (fetch)
origin https://github.com/priya/shop.git (push)
upstream https://github.com/acme/shop.git (fetch)
upstream https://github.com/acme/shop.git (push)
$ git fetch upstream
$ git switch -c fix/tax-rounding upstream/main
Opening a good PR
Reviewers are the bottleneck in almost every team, so a good PR is one that is cheap to review. That mostly comes down to size and context.
- One change per PR. A bug fix, a refactor and a dependency bump are three PRs. A common rule of thumb is to stay under about 400 changed lines of real code. Review quality drops sharply beyond that.
- A title that reads as a changelog line. With squash merges, it becomes the commit message on main.
- A description that answers why. What problem, what you changed, how you tested it, what could go wrong, and how to roll it back. Screenshots or a short recording for anything visual.
- Review it yourself first. Read the diff on GitHub before requesting review. You will find the leftover
console.logbefore someone else does. - Leave comments on your own diff where a reviewer would otherwise have to guess, such as "this move is mechanical, the real change is on line 80".
A file at .github/pull_request_template.md pre-fills the
description of every new PR, which is the easiest way to make these
habits the default.
Draft PRs
A draft PR says "this is not ready, but look if you like". It can't be merged, and code owners are not asked to review it. Open drafts early: CI runs, the team sees what you are doing, and you can ask for feedback on direction before you have polished the wrong approach. Mark it ready when it is.
$ gh pr create --draft --fill
$ gh pr ready 412
Review mechanics
A review ends in one of three states. Comment gives feedback without a verdict. Approve counts towards the required number of approvals. Request changes blocks the merge (when reviews are required) until that same reviewer approves, or someone with permission dismisses their review.
Batch your comments with "Start a review" rather than posting them one at a time, so the author gets one notification and a complete picture. For small fixes, write a suggestion instead of describing the change. In a line comment:
```suggestion
const tax = roundHalfEven(line.amount * rate);
```
The author sees a diff with a Commit suggestion button, and can collect several suggestions into a single commit with "Add suggestion to batch". Useful conventions: prefix optional comments with "nit:", say explicitly whether a comment blocks approval, and resolve conversations once they are addressed, since branch protection can require every conversation to be resolved before merging.
Branch protection and rulesets
Protection rules make the process mandatory. Without them, anyone with write access can push straight to main, force-push over it, or merge a PR with failing tests. GitHub has two systems. Branch protection rules are the classic one: one rule per branch name pattern, configured in the repository settings. Rulesets are the newer one. Several can apply to the same branch and they stack, with the strictest setting winning. They can target branches and tags, can be switched off without being deleted, have an explicit bypass list (roles, teams or apps), and are visible to everyone with read access, so contributors can see why a push was refused. On some plans they can be set across a whole organisation.
| Rule | What it prevents |
|---|---|
| Require a pull request, with N approvals | Unreviewed code on main |
| Dismiss stale approvals when new commits are pushed | Approve, then sneak in a different change |
| Require approval of the most recent push | Approving your own last-minute push to someone else's PR |
| Require review from code owners | Changing billing code without the billing team seeing it |
| Require status checks to pass | Merging red CI |
| Require branches to be up to date | Merging something tested against an old main |
| Require conversation resolution | Merging with review threads still open |
| Require linear history | Merge commits on main (allows only squash and rebase) |
| Require signed commits | Commits whose author can't be verified |
| Block force pushes and deletions | Rewriting or deleting main |
| Require merge queue | PRs that pass alone but break main together |
Required status checks, and their trap
A required check is named by the job name that reports it, for example
test or ci / test. Two things catch almost
every team. First, if a workflow is skipped by a paths:
filter, its check never reports, and a required check that never
reports stays "Expected" forever and blocks the merge. A job skipped by
an if: condition, on the other hand, reports as passing.
Second, renaming a job silently changes the check's name, and the old
required name waits forever.
The usual fix for both is a single summary job, added under
jobs:, that always runs, depends on everything else, and
is the only required check.
all-green:
if: always()
needs: [lint, test, build]
runs-on: ubuntu-latest
steps:
- run: test "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "false"
CODEOWNERS
A CODEOWNERS file maps paths to the people or teams
responsible for them. GitHub reads it from .github/, the
repository root, or docs/, on the PR's base branch. Owners
are requested for review automatically, and with "require review from
code owners" turned on, their approval becomes mandatory for the files
they own.
* @acme/platform
/apps/billing/ @acme/billing
*.sql @acme/dba @priya
/.github/workflows/ @acme/devops
/.github/CODEOWNERS @acme/platform-leads
Patterns work mostly like .gitignore, and the
last matching line wins, so general rules go at the
top and specific ones below. Every owner must have write access to the
repository, or they are not requested for review. Protect the CODEOWNERS file itself
as the last line does, or anyone could remove the owners of a folder in
the same PR that changes it.
Merge queue
"Require branches to be up to date" is correct but slow: every time main moves, every open PR has to update and rerun CI, and on a busy repository they race each other forever. A merge queue replaces it. You click "Merge when ready", the PR joins a queue, and GitHub tests it on top of main plus every PR ahead of it, merging only what passes.
Your workflows must listen for the merge_group event as
well as pull_request, or the queue waits for checks that
never start. The queue's settings control the merge method, how many
entries build at once, and whether several PRs can be grouped into one
build to save CI time. Merge queues are available for
organisation-owned repositories, with the plan deciding which private
repositories get them.
A queue trades CI minutes for a green main. Each entry is tested on top of every entry ahead of it, so one failure throws away the speculative builds behind it and they run again. Grouping several PRs into one build saves minutes when things pass and costs extra runs when a group fails, because the queue has to work out which PR broke it. Flaky tests multiply all of this: a one-in-fifty flake becomes a regular queue stall at a hundred merges a day. Before turning a queue on, make the required checks fast and deterministic, and move slow suites to run after merge.
The three merge buttons
Each repository chooses which of these to allow. They produce very different histories from the same PR.
| Button | What lands on main | Good | Watch out for |
|---|---|---|---|
| Create a merge commit | All the PR's commits plus a merge commit, like git merge --no-ff | Full history; the PR is one revertable unit (git revert -m 1) | "wip" and "fix typo" commits land on main; the graph gets busy |
| Squash and merge | One new commit with the combined diff. A repository setting decides its message; the PR title and number is the common choice | Main reads like a changelog; one commit to revert or bisect per PR | Intermediate commits are gone from main; the branch no longer looks merged to Git |
| Rebase and merge | Each commit replayed onto main, always with new IDs | Linear history that keeps well-curated commits | Messy commits land individually; commits can't be matched back to the branch by ID |
Squash is the most common default. It credits other contributors with
Co-authored-by: trailers, and it pairs well with "require
linear history". Its side effect catches people out locally: the
squash commit is new, so your branch's commits are not ancestors of
main. git branch -d feature refuses with "not fully
merged", and git branch --merged doesn't list it. Once
GitHub shows the PR as merged, git branch -D is safe. And
don't keep committing on a squash-merged branch; start a new one from
main, or the next PR will show the old commits again.
Linking issues
A closing keyword followed by an issue reference, in the PR description or in a commit message, links the two and closes the issue when the PR merges.
Fixes #231
Closes #231, closes #240
Resolves acme/api#77
The keywords are close, closes,
closed, fix, fixes,
fixed, resolve, resolves and
resolved. Each issue needs its own keyword, as in the
second line. They only act when the PR targets the repository's
default branch. A PR into release/3.1
or into another PR's branch links nothing, which surprises people
using stacked PRs. Saying "Related to #231" or "Part of #231" links
without closing.
The gh CLI
GitHub's command-line tool does almost everything the website does,
without leaving the terminal. Authenticate once with gh auth
login. These are the commands worth knowing by heart.
$ gh pr create --fill --base main
$ gh pr create --title "fix: round tax per line" --body-file notes.md --reviewer acme/payments --draft
$ gh pr list --author "@me"
$ gh pr status
$ gh pr checkout 412
$ gh pr view 412 --comments
$ gh pr view 412 --web
$ gh pr diff 412
$ gh pr checks 412 --watch
$ gh pr review 412 --approve
$ gh pr review 412 --request-changes --body "Needs a test for negative totals"
$ gh pr merge 412 --squash --delete-branch
$ gh pr merge 412 --auto --squash
--fill takes the title and body from your commits.
gh pr checkout fetches the PR's branch, even from a fork,
and switches to it. --auto merges as soon as reviews and
checks pass, and joins the merge queue if the branch requires one.
For the Actions side:
$ gh run list --branch fix/tax-rounding
STATUS TITLE WORKFLOW BRANCH EVENT ID ELAPSED AGE
X fix: round tax per line ci fix/tax-rounding pull_request 9876543210 2m41s about 4 minutes ago
✓ fix: round tax per line lint fix/tax-rounding pull_request 9876543201 38s about 4 minutes ago
$ gh run watch
$ gh run view 9876543210 --log-failed
$ gh run rerun 9876543210 --failed
Checking out a PR without gh
GitHub stores every PR under special refs in the base
repository, so you can fetch any PR, including one from a fork you have
never added as a remote. refs/pull/412/head is the PR's
latest commit. refs/pull/412/merge is the test merge of
that commit into the base branch, the same commit CI tests, which only
exists while the PR can be merged without conflicts.
$ git fetch origin pull/412/head:pr-412
From github.com:acme/shop
* [new ref] refs/pull/412/head -> pr-412
$ git switch pr-412
$ git config --add remote.origin.fetch "+refs/pull/*/head:refs/remotes/origin/pr/*"
$ git fetch origin
$ git switch -c pr-412 origin/pr/412
The second form fetches every open and closed PR's head on each fetch, which is convenient on small repositories and very slow on big ones. These refs are read-only: to push fixes to someone's PR you push to their branch, which for a fork needs "Allow edits by maintainers".
GitHub Actions and PRs
A workflow is a YAML file in .github/workflows/. For PRs,
what matters is which event starts it and what it is allowed to do.
name: ci
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
merge_group:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test:
if: github.event.pull_request.draft != true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
pull_requestfires by default when a PR is opened, reopened or gets new commits (synchronize). Addingready_for_reviewmakes CI start when a draft is marked ready, which the draft check above needs. The checkout is the test merge commit,refs/pull/N/merge, so you test what main would become.merge_groupis what the merge queue fires. Leave it out and queued PRs never get their checks.concurrencycancels the old run when you push again, which saves a lot of CI time.permissionslimits what the job'sGITHUB_TOKENcan do. Start from read-only and add only what a job needs.- Forks are sandboxed. A
pull_requestrun from a fork gets no secrets and a read-only token, and first-time contributors need a maintainer to approve the run.
pull_request_target runs in the context of the base
branch, with secrets and a write token. That is safe only as
long as it never runs the PR's code. A workflow that checks out the PR
head and then runs npm install or the tests under
pull_request_target hands your secrets to anyone who opens
a PR. It is one of the most common ways real repositories get
compromised.
Stacked PRs
Big features don't have to be one big PR. Split the work into a stack: each PR is based on the branch of the one below it, so each shows only its own slice of the diff and can be reviewed on its own.
$ git switch -c coupons-model main
$ git commit -m "feat: coupon model and migration"
$ gh pr create --base main --fill
$ git switch -c coupons-api
$ git commit -m "feat: coupon endpoints"
$ gh pr create --base coupons-model --fill
$ git switch -c coupons-ui
$ git commit -m "feat: coupon field at checkout"
$ gh pr create --base coupons-api --fill
$ git switch coupons-ui
$ git rebase -i --update-refs main
$ git push --force-with-lease origin coupons-model coupons-api coupons-ui
The hard part is keeping the stack consistent. Fix a review comment in
coupons-model and the two branches above it are now based
on an old commit. git rebase --update-refs (Git 2.38 and
later) solves this: rebase the top branch, and every branch in the
stack that points at a rewritten commit is moved along with it. Set
rebase.updateRefs true to make it the default.
Squash merges add one more step. When PR 1 is squashed into main, main
gets a new commit, and PR 2 still carries PR 1's original commits. Your
local coupons-model still points at the old tip, so replay
only what comes after it:
$ git fetch origin
$ git switch coupons-ui
$ git rebase --update-refs --onto origin/main coupons-model
$ git push --force-with-lease origin coupons-api coupons-ui
Tools such as Graphite, ghstack, spr and git-town automate this bookkeeping. Stacks work best when each layer is small and genuinely reviewable on its own. A stack of five PRs that only make sense together is just one big PR that is harder to read.
Finished reading?
0 of 18 chapters read in this series