DevFox Labs
HomeLearningToolsAboutContact
intermediate35 minby DevFox

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.

  • Git
  • DevOps

On this page

  • A pull request is not a Git feature
  • The lifecycle, with the Git at each step
  • Keeping the branch current mid-review
  • Force-pushing a branch under review
  • Reviewing a pull request on your own machine
  • Seeing what a force-push actually changed
  • The three merge buttons
  • Writing a pull request that gets reviewed
  • Draft and stacked pull requests
  • The guardrails your host adds
  • Forks, upstream, and the open-source loop
  • A cheat sheet to keep
  • Where to go next
DevFox Labs

Structured lessons and courses for developers who care about craft.

Platform

HomeLearningAll lessonsAboutContact

Legal

Terms of ServicePrivacy PolicyCookie Policy

© 2026 DevFox Labs. All rights reserved.

    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.

    A pull request is not a Git feature

    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.

    The lifecycle, with the Git at each step

    Here is the whole loop; only the two gh lines and the merge are host-specific.

    bash
    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-totals

    Prefer 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:

    bash
    gh pr create --fill --base main   # --fill uses your commits
    gh pr view --web                  # open it in a browser

    Review 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:

    bash
    git switch main && git pull
    git branch -d feat/checkout-totals  # local branch gone
    git fetch --prune                   # dead remote-tracking refs

    Prune on every fetch, permanently

    Pointers to branches the host has deleted pile up until, a year in, git branch -r is a graveyard. Run git config --global fetch.prune true once and every fetch clears them.

    Keeping the branch current mid-review

    A branch open for a week is not the branch you opened. main moved, and you have two ways to catch up:

    bash
    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 branch

    The 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.

    Force-pushing a branch under review

    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.

    bash
    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.

    A bare fetch can void the lease

    Any fetch refreshes the remote-tracking ref the lease trusts, including the background ones your editor runs, quietly reducing the lease to a plain --force. Adding --force-if-includes (Git 2.30+) closes that gap.

    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.

    Reviewing a pull request on your own machine

    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:

    bash
    git fetch origin pull/482/head:pr-482   # GitHub, PR number 482
    git switch pr-482

    That 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:

    bash
    gh pr checkout 482
    gh pr diff 482            # the diff, in your terminal

    The same idea works on your own branch before you open it:

    bash
    git diff origin/main...HEAD

    Three 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.

    Seeing what a force-push actually changed

    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.

    bash
    git fetch origin
    git range-diff main origin/feat/totals@{1} origin/feat/totals

    The 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:

    text
    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.

    Name the versions explicitly

    The reflog form only reaches one step back. When you have the hash you reviewed — saved, or shown in the host's force-push event — name it instead: git range-diff main abc1234 origin/feat/totals.

    The three merge buttons

    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:

    text
    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:

    text
    main  A---B---S     S = C + D + E, one commit, new hash

    Rebase and merge replays each commit onto main with no merge commit. Same changes, new hashes:

    text
    main  A---B---C'---D'---E'

    None of the trade-offs is free.

    Squash and merge

    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.

    Rebase and merge

    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.

    Create a merge commit

    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.

    Choose by how disciplined your team's commits genuinely are, not how disciplined you wish they were

    Squash and rebase both break git branch -d

    git branch -d refuses to delete a branch whose commits are not reachable from your history, and after a squash or rebase-and-merge they are not: the content landed under different hashes. That is the one case where -D is correct rather than reckless — check the host says "merged", then force.

    Writing a pull request that gets reviewed

    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.

    Draft and stacked pull requests

    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.

    The guardrails your host adds

    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:

    text
    # .github/CODEOWNERS — last matching pattern wins
    *                @acme/platform
    /billing/        @acme/payments
    /infra/*.tf      @acme/sre
    *.sql            @acme/data @acme/sre

    Last 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.

    Forks, upstream, and the open-source loop

    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:

    bash
    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/main

    Now 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:

    bash
    git push -u origin fix/parser-crash

    When maintainers take three weeks — they will — refresh the branch before nudging them:

    bash
    git fetch upstream
    git rebase upstream/main
    git push --force-with-lease

    Here 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.

    A cheat sheet to keep

    bash
    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 fetch

    Where to go next

    Next 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.