Pull Requests and Code Review
The pull request lifecycle end to end, the three merge buttons and what each does to history, reviewing locally, git range-diff, force-pushing under review, CODEOWNERS and forks.
The pull request lifecycle end to end, the three merge buttons and what each does to history, reviewing locally, git range-diff, force-pushing under review, CODEOWNERS and forks.
Your branch is pushed, the tests are green, and now the work has
to survive other people. Most of the friction in team Git lives
there — not in the merge algorithm, but in the hours between "I
am done" and "it is on main".
By the end you will run a pull request from first commit to pruned branch, see what a colleague's force-push changed, and pick the right merge button for how your team writes commits.
Type git pull-request and Git will tell you it is not a
command. Nothing in .git/ represents a pull request, stores an
approval, or knows a review happened. A pull request is a
web page your host builds around two refs — here is my branch,
here is where I want it to land — plus a comment thread and a
button.
So branches, commits, push, fetch, merge, rebase and
range-diff behave identically everywhere, while approvals,
required checks, CODEOWNERS, draft state and the merge buttons
are host inventions, named and namespaced differently on each
(GitLab: merge request; Gerrit: change). The ref names below are
GitHub's.
Here is the whole loop; only the two gh lines and the merge
are host-specific.
git switch main && git pull # start from a current main
git switch -c feat/checkout-totals # branch
# ... edit, then ...
git commit -m "Round tax before summing line totals"
git push -u origin feat/checkout-totalsPrefer git switch to git checkout: checkout both moves
branches and restores files, and the day you type it meaning one
and get the other, uncommitted work is gone. The -u records
the upstream link between your branch and the remote one,
which makes every later push a bare git push and lets
git status say "ahead by 2 commits".
Git prints a link to open the pull request. Or stay in the terminal:
gh pr create --fill --base main # --fill uses your commits
gh pr view --web # open it in a browserReview comes back. Answer with ordinary commits, one per concern, so the reviewer reads your responses the way they read the original change. Once it is merged, clean up both sides:
git switch main && git pull
git branch -d feat/checkout-totals # local branch gone
git fetch --prune # dead remote-tracking refsA branch open for a week is not the branch you opened. main
moved, and you have two ways to catch up:
git fetch origin
git rebase origin/main # replay your commits on the new tip
# or
git merge origin/main # bring main's commits into your branchThe choice is not about which history is prettier, but about whether a human is currently reading your branch.
Rebase while nobody is reviewing — the pull request is a draft, or ten minutes old. You get a linear branch, and the diff a reviewer eventually opens is only your change.
Merge once review has started. Rebasing gives every commit a new hash, so your reviewer's comments hang off lines in commits that no longer exist: threads go outdated, some detach, and the reviewer loses any way to ask "what changed since I last looked". A merge commit is ugly; making someone re-read 400 lines is worse.
Rebasing a pushed branch leaves it diverged from the remote, so
a normal push is rejected. The reflex is --force, which tells
the server to take your history whatever is there. Unlearn it.
git push --force-with-lease # the minimum standard
git push --force-with-lease --force-if-includes--force-with-lease refuses the push unless the remote branch
still points where your remote-tracking ref says: it overwrites
only what you have actually seen. If a reviewer pushed a fixup
five minutes ago, plain --force deletes it silently; the lease
aborts and tells you to fetch.
No flag fixes the second cost. Anyone who checked your branch
out now holds commits that are gone from the remote, so their
next git pull makes a confusing merge or refuses, and anything
they committed on top has to come out of their reflog (lesson
9). Say so in the thread before rewriting.
A browser diff tells you what changed, not whether the thing runs, whether the test really fails without the fix, or how the code feels three files away. For anything non-trivial, pull the branch down. This needs no CLI and works for branches on forks, which you have no remote for:
git fetch origin pull/482/head:pr-482 # GitHub, PR number 482
git switch pr-482That fetches pull request 482's head ref and stores it as a
local branch. GitHub also publishes refs/pull/<n>/merge, the
result of merging it into the base, to test what would land.
The GitHub CLI does it in one command, with tracking, so you can push review fixes back if the author allowed it:
gh pr checkout 482
gh pr diff 482 # the diff, in your terminalThe same idea works on your own branch before you open it:
git diff origin/main...HEADThree dots, not two. Two dots compares the tips, so anything
that landed on main since you branched shows up as if you
deleted it. Three dots compares against the merge base, the
commit where you diverged — exactly the diff your reviewer sees.
Here is the situation that eats review time. You reviewed a branch on Monday; on Tuesday the author rebased it, fixed one comment, and force-pushed. Every hash is new, so the host's diff of the two versions is mostly rebase noise.
git range-diff is the tool for this. It does not diff two
trees — it diffs two series of commits, pairing them by
similarity and showing a diff of each pair's diffs.
git fetch origin
git range-diff main origin/feat/totals@{1} origin/feat/totalsThe arguments are a base and two branch versions: the commits
from main to the old tip, against those to the new tip. @{1}
is reflog syntax for "where this ref pointed before its last
update" — Git reflogs remote-tracking refs too, so after a fetch
you can still name yesterday's version.
The output pairs commits and marks each:
1: e5b2f1a = 1: a91c7d2 Extract rounding into a helper
2: 8c4d9f0 ! 2: 3fb08e5 Round tax before summing totals
@@ src/checkout/totals.ts: export function lineTotals(
- const tax = subtotal * rate
+ const tax = round2(subtotal * rate)
3: ---------- > 3: b71ea44 Test the 0.005 boundary case= means unchanged apart from the hash — rebase noise, skip it.
! means the content changed, and the indented block shows how.
> means a commit exists only in the new version; < means it
was dropped. So: two commits merely replayed, one line genuinely
edited, one new test added.
Your host offers three ways to land the branch, and they leave
genuinely different histories. Say yours has commits C, D
and E on top of main at B.
Create a merge commit keeps every commit and records the join, so the branch's shape survives:
main A---B---------------M M has two parents
\ /
C---D---E---'Squash and merge flattens the branch into one new commit;
C, D and E never appear on main:
main A---B---S S = C + D + E, one commit, new hashRebase and merge replays each commit onto main with no
merge commit. Same changes, new hashes:
main A---B---C'---D'---E'None of the trade-offs is free.
One line on main equals one change.
Trivial to revert, ideal for git bisect.
And it throws away the granularity that made the branch reviewable: a "refactor, then fix the bug" branch becomes one commit doing both. Right for "wip" and "fix again" branches.
Every commit kept, every hash rewritten.
The hashes your reviewer approved are not the hashes on
main, so any SHA quoted in a ticket now dangles.
Right when the commits were written to be read one at a time.
Preserves what actually happened.
Including which commits belonged together, which is information the other two destroy.
The cost is a graph you need git log --graph to read.
Review latency is mostly a function of size. A 60-line change gets read while someone's coffee cools; a 900-line change gets postponed, then skimmed and approved without real reading — worse than no review, because now it carries a signature.
So: one concern per pull request. If you refactored and fixed a bug, that is two of them, and the fix is a two-line diff instead of a needle in a haystack. When you cannot split the branch, split the commits and say which does what.
The description answers what the diff cannot: why the change exists and how to check it. Link the ticket, do not retell it.
A draft pull request is one marked not ready: CI runs and
the diff is visible, but it stays out of review queues and
usually cannot be merged. Open one to get the pipeline's
opinion before a human's, or to show direction on an approach
you are unsure of — gh pr create --draft, then gh pr ready.
Stacked pull requests answer a change too big to split in
time: branch the second piece off the first and target that
branch as its base, so each review stays small and they merge
bottom-up. GitHub supports the idea badly. With no stack-aware
UI, every rebase of the bottom branch means restacking the
branches above it with git rebase --onto and
force-pushing the lot; and if the bottom one is squash-merged,
the child still holds the original commits, so its diff explodes
until you rebase onto the squash. Keep stacks shallow, or use a
dedicated tool.
Because Git enforces nothing, hosts bolt on rules. A
protected branch rejects direct and force pushes to main,
so every change arrives through a pull request; that one setting
makes the others meaningful. On top of it you can require status
checks, require approving reviews, dismiss stale approvals when
commits are pushed, and require linear history, which
disables the merge-commit button. Turn on stale dismissal early,
or Monday's approval still counts after Friday's rewrite.
A CODEOWNERS file routes review requests by path, turning
tribal knowledge about who owns billing into a rule:
# .github/CODEOWNERS — last matching pattern wins
* @acme/platform
/billing/ @acme/payments
/infra/*.tf @acme/sre
*.sql @acme/data @acme/sreLast match wins, not first — the opposite of .gitignore
intuition, and the usual reason a CODEOWNERS file quietly does
nothing. With "require review from Code Owners" on, a change
touching /billing/ cannot merge without the payments team.
On someone else's open-source project you have no push access,
so the pull request comes from a fork, your own server-side
copy. Clone it, then add the original project as a second
remote, conventionally named upstream:
git clone https://github.com/you/project.git
cd project
git remote add upstream https://github.com/acme/project.git
git fetch upstream
git switch -c fix/parser-crash upstream/mainNow origin is your fork and upstream the real project.
Branch off upstream/main, not your fork's main: a fork does
not update itself, so its main is frozen where you clicked
Fork. Then push, and open the pull request against
upstream/main:
git push -u origin fix/parser-crashWhen maintainers take three weeks — they will — refresh the branch before nudging them:
git fetch upstream
git rebase upstream/main
git push --force-with-leaseHere rebasing is the norm: maintainers want a clean series, and nobody else has your fork's branch checked out, so the usual objection to rewriting does not apply.
git switch -c feat/x # branch off the current tip
git push -u origin feat/x # push and set upstream
gh pr create --fill --base main # open the pull request
gh pr create --draft # open it as a draft
gh pr ready # mark a draft ready
git diff origin/main...HEAD # self-review your own branch
git fetch origin # refresh remote-tracking refs
git rebase origin/main # catch up (nobody reviewing)
git merge origin/main # catch up (under review)
git push --force-with-lease # rewrite safely
git push --force-with-lease --force-if-includes # safer still
git fetch origin pull/482/head:pr-482 # check out a PR by number
gh pr checkout 482 # same thing, with the CLI
gh pr diff 482 # read the diff in a terminal
git range-diff main b@{1} b # what a force-push changed
git range-diff main <old> <new> # same, with explicit hashes
git rebase --onto main <old-base> feat/child # restack a PR
git remote add upstream <url> # the real repo, on a fork
git switch -c fix/x upstream/main # branch off upstream, not you
git rebase upstream/main # refresh a contribution
git branch -d feat/x # delete (-D after a squash)
git fetch --prune # clear deleted remote branches
git config --global fetch.prune true # prune on every fetchNext comes what happens after the merge: tags, releases, and versioning — how to mark a commit as a release, what an annotated tag carries that a lightweight one does not, and how semantic versioning turns "it merged" into a promise other teams can depend on.
Practice this on a real repository: open a small pull request
this week and self-review the diff first. Then take a throwaway
branch of three commits, merge copies of it with each button,
and read git log --graph --oneline after each — seeing the
three shapes side by side settles the choice.