GitGroundworkGit
10 / 18 written chapters

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.

14 min read3 diagrams14 sections
14On this page
  1. Forks or branches
  2. Opening a good PR
  3. Draft PRs
  4. Review mechanics
  5. Branch protection and rulesets
  6. Required status checks, and their trap
  7. CODEOWNERS
  8. Merge queue
  9. The three merge buttons
  10. Linking issues
  11. The gh CLI
  12. Checking out a PR without gh
  13. GitHub Actions and PRs
  14. Stacked PRs
Fresher Mid Senior

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.

In one minute

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 repoFork
Who uses itTeam members with write accessAnyone, including people you don't know
Remotesorigin onlyorigin (your fork) and upstream (the real project)
CI secretsAvailable to workflowsWithheld, and the token is read-only, so untrusted code can't steal them
Maintainers can push fixesYesOnly if the author leaves "Allow edits by maintainers" ticked
Keeping currentgit pullgit 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.log before 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.

RuleWhat it prevents
Require a pull request, with N approvalsUnreviewed code on main
Dismiss stale approvals when new commits are pushedApprove, then sneak in a different change
Require approval of the most recent pushApproving your own last-minute push to someone else's PR
Require review from code ownersChanging billing code without the billing team seeing it
Require status checks to passMerging red CI
Require branches to be up to dateMerging something tested against an old main
Require conversation resolutionMerging with review threads still open
Require linear historyMerge commits on main (allows only squash and rebase)
Require signed commitsCommits whose author can't be verified
Block force pushes and deletionsRewriting or deleting main
Require merge queuePRs 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.

entry 1 entry 2 entry 3 main #101 main #101 #102 main #101 #102 #103 CI passes: merge #101 CI passes: merge #102 CI fails: remove #103 Each entry runs on a temporary branch, gh-readonly-queue/main/pr-N-<sha>, built on the entry ahead. If one fails, it leaves the queue and the entries behind it are rebuilt without it.
The entries run in parallel. Main only ever moves to a commit that has been tested exactly as it will exist.

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.

For seniors: what a merge queue costs

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.

BEFORE A B C D E main feature (PR #42) MERGE COMMIT A B C D E M main keeps C, D, E and adds merge M SQUASH AND MERGE A B S main one new commit S = C + D + E REBASE AND MERGE A B C′ D′ E′ main copies with new IDs
Green rings are commits GitHub creates at merge time. Only the merge commit keeps the original C, D and E; the other two leave them behind on the feature branch.
ButtonWhat lands on mainGoodWatch out for
Create a merge commitAll the PR's commits plus a merge commit, like git merge --no-ffFull 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 mergeOne new commit with the combined diff. A repository setting decides its message; the PR title and number is the common choiceMain reads like a changelog; one commit to revert or bisect per PRIntermediate commits are gone from main; the branch no longer looks merged to Git
Rebase and mergeEach commit replayed onto main, always with new IDsLinear history that keeps well-curated commitsMessy 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_request fires by default when a PR is opened, reopened or gets new commits (synchronize). Adding ready_for_review makes 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_group is what the merge queue fires. Leave it out and queued PRs never get their checks.
  • concurrency cancels the old run when you push again, which saves a lot of CI time.
  • permissions limits what the job's GITHUB_TOKEN can do. Start from read-only and add only what a job needs.
  • Forks are sandboxed. A pull_request run 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 is not a shortcut

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.

main PR 1: coupons-model → main PR 2: coupons-api → coupons-model PR 3: coupons-ui → coupons-api
Each arrow in a label is "head → base". Merge from the bottom up. When PR 1 merges and its branch is deleted, GitHub retargets PR 2 to main automatically.
$ 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.