Pull Requests and Code Review

What a pull request is in Git terms, the three merge buttons and the histories they produce, and how to structure a change so review is fast rather than polite.

intermediate 20 min lesson hands-on task included

Git has no concept of a pull request. It is a forge feature — a request to merge branch A into branch B, plus a conversation and some gates. Understanding which parts are Git and which parts are GitHub or GitLab is what lets you debug the thing when it misbehaves.


Topic 1: The Lifecycle

A PULL REQUEST IS A REQUEST TO MERGE A BRANCH — GIT ITSELF HAS NO SUCH CONCEPT 1 branch from an up-to-date main 2 commit small, reviewable, message explains WHY 3 push to your fork or the shared repo 4 open PR CI runs, CODEOWNERS are requested 5 review comments → new commits, never a force-push mid 6 merge one of the three buttons below THE THREE MERGE BUTTONS PRODUCE THREE DIFFERENT HISTORIES Merge commit keeps every commit + adds a merge honest history, noisy graph Squash one commit on main, branch history dropp clean main, lost granularity Rebase & merge replays commits, no merge commit linear, new hashes Pick ONE per repository and enforce it. Mixing all three is why a repo's history becomes unreadable.
Steps 1–3 are pure Git; steps 4–6 are the forge. The three boxes at the bottom are the same merge in Git terms and three different histories afterwards — which is why a team should pick one and enforce it.
git switch main && git pull            # start from current main
git switch -c fix/token-expiry         # branch
# ...work, commit in logical pieces...
git push -u origin fix/token-expiry    # publish
# open the PR in the forge, or:
gh pr create --fill --base main

Then: CI runs, reviewers are requested (often automatically via CODEOWNERS), comments arrive, you push more commits, approval lands, and someone presses a merge button.

The forge adds the parts Git has no opinion about: required approvals, required status checks, branch protection, merge queues, automatic branch deletion. All of them are policy, and all of them are configured per repository — which is why “why can’t I push to main” is a forge question, not a Git question.


Topic 2: The Three Merge Buttons

Each produces a different history. Mixing them at random is the main reason a repository’s log becomes unreadable.

Merge commit — every commit from the branch, plus a merge commit joining the two lines. Keeps: the true sequence of work, and the fact that these commits were one feature. Costs: a busy repository’s graph becomes hard to follow. Reverting the whole feature: one git revert -m 1.

Squash and merge — all the branch’s commits become one commit on main. Keeps: a clean, linear main where each commit is one reviewed change. Costs: the intermediate commits are gone; a bisect lands on a large commit; git branch -d will report the branch as unmerged because no ancestry link exists. Best when: branches are short and contain “wip” commits nobody needs to keep.

Rebase and merge — commits are replayed onto main with no merge commit. Keeps: linear history and individual commits. Costs: every commit gets a new hash, so the commits on main are not the ones that were reviewed; and each replayed commit must apply cleanly. Best when: the team already writes clean, self-contained commits.

Choose one per repository, configure the forge to allow only that one, and enable automatic branch deletion on merge. The decision matters less than the consistency.


Topic 3: Making a Change Reviewable

Review quality collapses with size. The strongest predictors of a useful review are small diffs and a clear description — and both are the author’s job.

Size. Under ~400 lines gets real review; over ~1,000 gets “LGTM”. If a change is genuinely large, split it:

  • Mechanical change first (rename, move, format), on its own, marked as such.
  • Then the behavioural change, which is now readable.
  • Or ship behind a feature flag in several PRs, each individually safe.

Description. The template that works:

## What
One paragraph. What changes for a user or a caller.

## Why
The reason. Link the ticket, but do not delegate the explanation to it.

## How
Only the non-obvious parts. Why this approach and not the obvious one.

## Testing
What you ran. What you could not test and why.

## Risk / rollback
What could break, how you would notice, how to undo it.

The “risk / rollback” section is the one that separates a reviewed change from a rubber-stamped one, and it takes two lines.

Commits within the PR. Each commit should build and pass tests. git rebase -i --exec 'make test' verifies that before you ask anyone to look.


Topic 4: Responding to Review Without Destroying It

The mistake that wastes the most reviewer time: force-pushing a rebased branch mid-review. Every comment is now attached to commits that no longer exist, and the “what changed since I last looked” view is destroyed.

Do this instead:

git commit --fixup=a1b2c3        # a new commit, marked as fixing an earlier one
git push                          # no force needed — reviewer's diff still works
# ...after approval:
git rebase -i --autosquash main
git push --force-with-lease       # once, at the end

The reviewer sees incremental changes during the review; main receives a clean series. If the repository squashes on merge anyway, you can skip the final rebase entirely — the forge does it.

Reviewing other people’s code, briefly, because it is half the workflow:

  • Pull the branch and run it when the change is non-trivial. Reading a diff is not the same as using the thing.
  • Distinguish blocking from non-blocking. Prefix suggestions with “nit:” so the author knows what actually stops the merge.
  • Review the tests as carefully as the code. A test that cannot fail is worse than no test.
  • Ask questions rather than issuing instructions when you are not certain. “What happens if this is nil?” is faster than a wrong assertion.
  • Approve when it is good enough to ship, not when it is what you would have written.

Topic 5: Merge Queues and Semantic Conflicts

Two PRs can each be green against main and broken together. One renames a function; the other adds a caller. Git reports no conflict — the text does not overlap — and CI passed on both branches, each tested against a main that predates the other.

This is a semantic conflict, and it is the reason merge queues exist. A merge queue tests each PR against main plus the other PRs ahead of it in the queue, and only merges if that combination is green.

Without a queue, the cheap approximations:

  • Require branches to be up to date with main before merging (the forge can enforce this).
  • Run CI on the merge result, not on the branch tip — most CI systems do this by default; check yours.
  • Keep branches short, which reduces the window in which a semantic conflict can form.

Topic 6: Forks, Upstreams and Contributing Outward

The fork model is how open source works and how some companies isolate write access:

# after forking on the forge
git clone git@github.com:you/repo.git
cd repo
git remote add upstream git@github.com:org/repo.git

# keep your fork current
git fetch upstream
git switch main
git merge --ff-only upstream/main
git push origin main

# work on a branch, never on main
git switch -c fix/typo-in-readme
git push -u origin fix/typo-in-readme
gh pr create --repo org/repo --base main

Why never work on your fork’s main: it makes syncing with upstream a merge instead of a fast-forward, and every subsequent PR carries your unrelated commits.

Etiquette that gets a contribution merged, which is mostly project-specific and always worth checking first: read CONTRIBUTING.md, match the existing commit message style, sign off if the project uses DCO (git commit -s), keep the PR to one concern, and open an issue first for anything large. A maintainer’s scarcest resource is attention, and a 900-line unsolicited refactor consumes it without permission.

Try it yourself: merge three trivial PRs into a scratch repository — one with each button — then run git log --graph --oneline --all. The three shapes are unmistakable, and seeing them side by side makes the team-standard conversation concrete rather than theoretical.

Common mistake: treating a PR as a checkpoint at the end of the work rather than a communication tool during it. Open it as a draft on the first commit. CI starts running, the direction is visible before three days are spent on it, and the eventual review is against something nobody is surprised by.