The pull request as designed artifact
Designing GitHub pull requests as the team's unit of change, sized small, reviewable, and reversible, with descriptions and review conventions a teammate can act on.
So far you’ve opened pull requests as a formality. You finish a branch, you click the green button, you merge. That’s exactly the right instinct when you’re the only person who will ever read the code. The moment a second engineer joins, it stops being right, not because the mechanics change, but because the pull request stops being your checkpoint and becomes the team’s unit of change. It’s the thing a teammate reads before it lands, the thing CI runs its checks against, the thing the preview deployment binds to so reviewers can click around the actual feature, and the thing the squash-merge commit records as one line in main’s history. Everyone downstream of you sees the change through this one object.
When that object is built carelessly, here is the failure it causes. A reviewer opens your pull request , sees a nine-hundred-line diff, scrolls for ninety seconds, can’t hold any of it in their head, types “LGTM,” and approves. The bug sitting in line 600 ships to production. Nobody caught it, because nobody could. The PR was un-reviewable, so it didn’t get reviewed; it got rubber-stamped.
The fix is not to review harder. The fix is to treat the pull request as something you design. In the trunk-based loop you learned to keep one PR per logical change; this lesson is the reason behind that rule and the method for carrying it out well. By the end you’ll be able to size a change so it reviews in one sitting, write a description a reviewer can act on without pulling you into a call, and run the everyday review loop smoothly from both the author’s seat and the reviewer’s. That loop uses fixup commits and squash-merge, both of which you met in the rescue toolkit lesson. The whole lesson hangs on three words: small, reviewable, reversible.
A PR moves through checkpoints
Section titled “A PR moves through checkpoints”Before the individual parts, you need the map. A pull request is not a single event; it’s a short pipeline of checkpoints, and the goal at every one of them is the same: this change lands in one round of review. Once you can see the whole pipeline, every later section of this lesson has a place to fit. You’ll know that fixup commits belong to the discussion stage, that CODEOWNERS acts at the reviewer-request stage, and so on.
Here is the full lifecycle. Scrub through it; each step highlights the stage that’s active and names who is doing the work.
Most of those stages are things you do with a single command. Here is the bare happy path of push, propose, merge, using the GitHub CLI, one command per line, so the stages have real commands attached. Don’t worry about memorizing the CLI yet; it gets its own short reference near the end.
git push -u origin feat/invoice-statusgh pr create --fillgh pr merge --squash --delete-branchYou just saw the lifecycle drawn out. Before moving on, fix the order in your mind by rebuilding it yourself. The following exercise gives you the stages shuffled; drag them back into sequence.
Order the stages a pull request moves through, from the first push to the merged branch. Drag the items into the correct order, then press Check.
main main Small, reviewable, reversible
Section titled “Small, reviewable, reversible”These three words are the heart of the lesson, so slow down here. They are not three nice-to-haves; they are three constraints that reinforce each other, and almost every other decision in this lesson follows from them. Take each in turn.
Small. Roughly four hundred changed lines is the ceiling for a review that someone can actually do in one sitting. That’s not a lint rule and not a hard gate; it’s a number about human attention. A reviewer can hold a few hundred lines in their head, trace each change to the code that calls it, and form a real opinion. Push past that and attention degrades. Somewhere north of eight hundred lines the review quietly turns into skimming, and skimming is just rubber-stamping with extra scrolling. The failure mode is the eight-hundred-line PR that gets a thumbs-up nobody could have honestly given.
Reviewable. One logical change per PR. A reviewer should be able to say what the PR does in a single sentence, such as “adds a status filter to the invoices list,” and have that sentence cover every line in the diff. What breaks this is the drive-by edit: you’re in a file fixing a bug, you notice the formatting is off, and “while I’m in here” you reformat the whole module. Now the diff is a bug fix wearing a hundred lines of whitespace churn, and the reviewer can’t tell which lines are load-bearing and which are noise. The load-bearing lines are exactly the ones that need scrutiny, and you’ve hidden them.
Reversible. The change should be self-contained enough that a single git revert (which you met in the rescue toolkit lesson) undoes it cleanly, with nothing else coming along for the ride. This is the constraint that pays off weeks later, when something breaks in production and you need to back out just the change that caused it. If the PR that introduced the regression also bundled two unrelated features, reverting it to stop the bleeding tears out two things that were working fine. A reversible PR is one you can pull out of main like a single Jenga block.
Now see how the three chain together, because this is the part most people miss. Small enables reviewable: a small diff is almost forced to be about one thing. Reviewable enables reversible: a PR that’s genuinely one logical change is, by construction, one thing you can revert. The three aren’t independent goals you balance against each other; they’re a single property viewed from three angles. This is the real difference between a junior and an experienced engineer here. The junior optimizes for fewer PRs, because batching work feels efficient, while the experienced engineer optimizes for each PR fitting in one head, and ships more, smaller PRs as a result.
The following figure makes “small” concrete. It’s a ladder of size bands. Notice that the bands aren’t a cliff but a slope: review quality degrades gradually as the diff grows, which is exactly why four hundred is a heuristic and not a gate.
A reviewer holds the whole diff in their head and traces every change to the code that calls it.
Attention thins. Some lines get a glance instead of a read, and the read turns into a skim.
Too big to hold. The approval is a thumbs-up nobody could honestly give — the bug ships.
Splitting a big change into a stack
Section titled “Splitting a big change into a stack”The obvious objection to “keep it small” is that some changes are genuinely big. Renaming a core type across the codebase, or shipping a feature that needs a refactor and a new helper and the user-facing wiring, is not a hundred-line change, and pretending it is just means cramming. The senior move isn’t to give up on small; it’s to split the big change into a stack of small PRs.
A stack is a dependency chain. Each PR is built on the branch of the one before it instead of on main, and each link is small enough to review on its own. Say you’re adding a status filter to the invoices list, and doing it right means reshaping the query first. You’d ship it as a chain of four: a pure refactor with no behavior change, then a small extraction, then the call-site swap, then the feature itself.
PR 1 feat/filter-refactor Reshape the invoice query (no behavior change)PR 2 feat/filter-helper Extract a reusable status-filter helperPR 3 feat/filter-callsite Use the helper where the list is builtPR 4 feat/filter-ui Add the status dropdown to the list UIEach of those reviews in one sitting. Each merges in order: PR 1 first, then PR 2 once PR 1 is in, and so on. The pure-refactor PR is especially valuable on its own, because a reviewer can approve it fast precisely because it changes no behavior, and that confidence is only possible when it isn’t tangled with the feature.
The mechanic is one flag. Instead of opening a PR against main, you point it at the previous branch:
git switch -c feat/filter-helpergh pr create --base feat/filter-refactor--base tells GitHub the PR merges into feat/filter-refactor, not main, so the diff only shows this link’s changes rather than everything in the stack. There is a bookkeeping cost: when PR 1 merges, you re-point PR 2’s base to main, and so on down the chain. Tools like Graphite, git-spice, and Sapling automate exactly that re-pointing, but you don’t reach for them on day one. The honest threshold is to pick up stack tooling when you’re routinely juggling three or more dependent PRs at once. Below that, chaining --base by hand is fine, and it’s what most two-to-five-person SaaS teams in 2026 actually do.
The description is the argument for the diff
Section titled “The description is the argument for the diff”A diff is a perfect record of what changed and a terrible record of why. It shows every line that moved and not one word about the bug that prompted it, the approach you rejected, or the thing you want the reviewer to look at hardest. Leave the description blank and you’ve handed the reviewer a puzzle: reverse-engineer your intent from the code, then judge whether the code matches the intent you guessed. That’s slow, and it’s where reviewers miss things, because they spend their attention reconstructing what you meant and have none left for whether you’re right.
Here is the reframe to keep in mind for the rest of this lesson: a pull request is a proposal with an argument attached. The diff is the proposal, the what you want to merge. The description is the argument, the why it’s correct and safe. The review is the negotiation, and the squash-merge is the agreed result that becomes one line of main’s history. Seen that way, a blank description is a proposal submitted with no case made for it, and you’re asking the reviewer to build the argument themselves before they can even judge it.
So make the case. A good argument has a predictable shape of six sections. Read the whole thing first as one artifact, the way a reviewer would, then walk it section by section.
## WhatAdds a status filter to the invoices list. Users can now narrow thelist to Draft, Sent, Paid, or Archived invoices from a dropdown abovethe table.
## WhyCloses INV-412. Support has been fielding "where did my paid invoicesgo" tickets because the list mixes every status together with no wayto focus. This is the smallest fix that addresses the complaint.
## HowThe filter is driven by a URL search param (`?status=`) so a filteredview is shareable and survives a refresh. I considered client-sidefiltering of the full list but rejected it — the list is paginatedserver-side, so filtering on the client would silently miss rows onother pages.
## Test plan- Added a unit test covering each status case and the "all" default.- Manually filtered to each status against seeded data; confirmed counts match the badge totals.- Checked an empty result (no archived invoices) renders the empty state, not a blank table.
## ScreenshotsBefore / after of the list with the new dropdown. (Once previewdeployments are wired, a link to the live preview goes here instead.)
## Risks / rollbackRead-only change — no schema or data migration. If the filtermisbehaves, a single `git revert` removes the dropdown and restoresthe unfiltered list with no cleanup.One paragraph on the user-visible change. The reviewer reads this first, and everything after is checked against it. State what a user can now do, not how the code does it.
## WhatAdds a status filter to the invoices list. Users can now narrow thelist to Draft, Sent, Paid, or Archived invoices from a dropdown abovethe table.
## WhyCloses INV-412. Support has been fielding "where did my paid invoicesgo" tickets because the list mixes every status together with no wayto focus. This is the smallest fix that addresses the complaint.
## HowThe filter is driven by a URL search param (`?status=`) so a filteredview is shareable and survives a refresh. I considered client-sidefiltering of the full list but rejected it — the list is paginatedserver-side, so filtering on the client would silently miss rows onother pages.
## Test plan- Added a unit test covering each status case and the "all" default.- Manually filtered to each status against seeded data; confirmed counts match the badge totals.- Checked an empty result (no archived invoices) renders the empty state, not a blank table.
## ScreenshotsBefore / after of the list with the new dropdown. (Once previewdeployments are wired, a link to the live preview goes here instead.)
## Risks / rollbackRead-only change — no schema or data migration. If the filtermisbehaves, a single `git revert` removes the dropdown and restoresthe unfiltered list with no cleanup.Link the ticket or decision and state the problem solved. This is the part the diff can never contain, and it’s what lets a reviewer judge whether the change is even the right thing to build.
## WhatAdds a status filter to the invoices list. Users can now narrow thelist to Draft, Sent, Paid, or Archived invoices from a dropdown abovethe table.
## WhyCloses INV-412. Support has been fielding "where did my paid invoicesgo" tickets because the list mixes every status together with no wayto focus. This is the smallest fix that addresses the complaint.
## HowThe filter is driven by a URL search param (`?status=`) so a filteredview is shareable and survives a refresh. I considered client-sidefiltering of the full list but rejected it — the list is paginatedserver-side, so filtering on the client would silently miss rows onother pages.
## Test plan- Added a unit test covering each status case and the "all" default.- Manually filtered to each status against seeded data; confirmed counts match the badge totals.- Checked an empty result (no archived invoices) renders the empty state, not a blank table.
## ScreenshotsBefore / after of the list with the new dropdown. (Once previewdeployments are wired, a link to the live preview goes here instead.)
## Risks / rollbackRead-only change — no schema or data migration. If the filtermisbehaves, a single `git revert` removes the dropdown and restoresthe unfiltered list with no cleanup.Only the non-obvious choices and the alternatives you rejected. Skip what the diff already makes plain, since narrating obvious code wastes the reviewer’s time. The rejected alternative is often the most valuable line here.
## WhatAdds a status filter to the invoices list. Users can now narrow thelist to Draft, Sent, Paid, or Archived invoices from a dropdown abovethe table.
## WhyCloses INV-412. Support has been fielding "where did my paid invoicesgo" tickets because the list mixes every status together with no wayto focus. This is the smallest fix that addresses the complaint.
## HowThe filter is driven by a URL search param (`?status=`) so a filteredview is shareable and survives a refresh. I considered client-sidefiltering of the full list but rejected it — the list is paginatedserver-side, so filtering on the client would silently miss rows onother pages.
## Test plan- Added a unit test covering each status case and the "all" default.- Manually filtered to each status against seeded data; confirmed counts match the badge totals.- Checked an empty result (no archived invoices) renders the empty state, not a blank table.
## ScreenshotsBefore / after of the list with the new dropdown. (Once previewdeployments are wired, a link to the live preview goes here instead.)
## Risks / rollbackRead-only change — no schema or data migration. If the filtermisbehaves, a single `git revert` removes the dropdown and restoresthe unfiltered list with no cleanup.What you actually ran, not what you intend to run: the seeded test, the manual flow, the edge case you checked. This is the author’s evidence that the change works.
## WhatAdds a status filter to the invoices list. Users can now narrow thelist to Draft, Sent, Paid, or Archived invoices from a dropdown abovethe table.
## WhyCloses INV-412. Support has been fielding "where did my paid invoicesgo" tickets because the list mixes every status together with no wayto focus. This is the smallest fix that addresses the complaint.
## HowThe filter is driven by a URL search param (`?status=`) so a filteredview is shareable and survives a refresh. I considered client-sidefiltering of the full list but rejected it — the list is paginatedserver-side, so filtering on the client would silently miss rows onother pages.
## Test plan- Added a unit test covering each status case and the "all" default.- Manually filtered to each status against seeded data; confirmed counts match the badge totals.- Checked an empty result (no archived invoices) renders the empty state, not a blank table.
## ScreenshotsBefore / after of the list with the new dropdown. (Once previewdeployments are wired, a link to the live preview goes here instead.)
## Risks / rollbackRead-only change — no schema or data migration. If the filtermisbehaves, a single `git revert` removes the dropdown and restoresthe unfiltered list with no cleanup.For any UI change, before/after images so the reviewer sees the result without checking out the branch. A later chapter replaces this with a link to the per-PR preview deployment.
## WhatAdds a status filter to the invoices list. Users can now narrow thelist to Draft, Sent, Paid, or Archived invoices from a dropdown abovethe table.
## WhyCloses INV-412. Support has been fielding "where did my paid invoicesgo" tickets because the list mixes every status together with no wayto focus. This is the smallest fix that addresses the complaint.
## HowThe filter is driven by a URL search param (`?status=`) so a filteredview is shareable and survives a refresh. I considered client-sidefiltering of the full list but rejected it — the list is paginatedserver-side, so filtering on the client would silently miss rows onother pages.
## Test plan- Added a unit test covering each status case and the "all" default.- Manually filtered to each status against seeded data; confirmed counts match the badge totals.- Checked an empty result (no archived invoices) renders the empty state, not a blank table.
## ScreenshotsBefore / after of the list with the new dropdown. (Once previewdeployments are wired, a link to the live preview goes here instead.)
## Risks / rollbackRead-only change — no schema or data migration. If the filtermisbehaves, a single `git revert` removes the dropdown and restoresthe unfiltered list with no cleanup.What could break and how to undo it, tied to git revert from the rescue toolkit lesson. A change with a clear rollback is one a reviewer can approve with less fear.
You won’t type those six headings from memory every time. The repo ships a file, .github/pull_request_template.md, that pre-fills the description box with this skeleton the moment you open a PR. It’s a one-time setup that makes the good structure the path of least resistance for everyone. Authoring that template in depth, and the broader discipline of shipping docs inside the PR, is a later chapter’s subject; for now, know the file exists and that it holds exactly the six sections you just walked.
One point of senior judgment is worth stating plainly, because beginner-facing advice often gets it wrong. Resist the urge to bolt a checkbox onto that template, the ”☐ I ran the tests” kind. A self-attested checkbox enforces nothing: anyone can tick it in two seconds without having run anything, so it looks like a gate but isn’t one. The real enforcement of “the tests passed” is the CI status check that runs against the PR, which is the next chapter’s topic. Reserve the description for the argument; let the machine enforce the facts.
Review your own PR first
Section titled “Review your own PR first”One habit costs you sixty seconds and saves an entire review round, yet almost nobody does it early in their career. Before you request a single reviewer, open your own PR’s “Files changed” tab and read the diff as if it were a stranger’s. The shift in posture is the whole trick: reading to approve surfaces things that reading while writing never does. You’ll catch roughly half the comments a reviewer would have left, such as the debug console.log you forgot, the variable you renamed everywhere but one place, or the test you meant to add.
And where the diff genuinely needs context, leave the comment yourself, on your own PR. A line like “intentionally not handling the archived case here; it’s a separate ticket, INV-419” pre-answers the exact question the reviewer was about to type, and turns a round-trip into nothing. Beyond the time saved, self-review signals to the team that you don’t outsource the first pass of caring about your own work. Do this right after you’ve written the description and right before you request review; it’s the author’s last step.
Draft PRs and when they earn their weight
Section titled “Draft PRs and when they earn their weight”GitHub lets you open a PR in draft status: it shows a “Draft” badge, can’t be merged, and signals that you want eyes, not approval, because the work isn’t done. It’s a genuinely useful mode, and it’s also one of the easiest defaults to get wrong, so treat it as a conditional tool with a specific trigger.
A normal, non-draft PR gets attention: reviewers see it in their queue and pick it up. A draft gets the opposite, because people learn that drafts aren’t ready and skip them, so a PR you open as a draft “just to be safe” can sit ignored for days. That’s the trade-off to weigh. Open a draft only when one of three things is actually true:
- The approach is uncertain, and a reviewer glancing at the direction now could save you a day of building the wrong thing.
- The PR depends on another that hasn’t merged yet, and you want it visible as in-progress without inviting an approval it can’t act on.
- You’re using CI as an early-warning signal, pushing to watch the checks run, and reviewers genuinely shouldn’t read it yet.
If none of those hold, open it as a normal PR and let it get attention. Opening it as a draft is one line:
gh pr create --draftConversational review from both seats
Section titled “Conversational review from both seats”Review is a conversation, and you’ll sit on both sides of it constantly: author one hour, reviewer the next. You need enough working vocabulary to take part competently in each seat, and that working level is what follows. The deep review methodology, with its layered review stack and formal severity taxonomy, is a later chapter’s whole subject; here we’re after fluency in the everyday exchange.
The comment styles that keep a review moving
Section titled “The comment styles that keep a review moving”A reviewer who only knows one kind of comment, “change this,” creates friction on every line, because not every observation is a demand. Four distinct shapes do most of the work, and naming them keeps a review from stalling:
- Suggestion. GitHub has a
suggestioncode fence that renders as a one-click “apply” button on the author’s side. Reach for it on the small stuff, such as a typo, a clearer variable name, or a tiny refactor, where writing the fix is faster than describing it and the author accepts in a single click. - Question. “Why this approach and not that one?” A real question is legitimate and valuable when the reviewer is missing context the author has. It becomes a problem when it’s a demand in disguise: “have you considered not doing it this way?” is an instruction wearing a question mark, and everyone can tell.
- Blocking comment. “This has to change before I approve.” The key is to make it visible rather than bury it in a thread the author might miss. Attach it to the Request changes review action, which flips the PR’s review state to “changes requested” for everyone to see. (Turning that state into a hard merge block takes a ruleset, which is the next lesson’s job; here it’s the signal that matters.)
- Nit. A comment you explicitly mark as non-blocking: “nit: I’d inline this, but take it or leave it.” It tells the author you noticed and you care about the bar, but you won’t hold the PR hostage over style. Naming a nit as a nit is what keeps it from reading as a demand.
The suggestion fence is worth seeing once, since it’s a concrete bit of syntax rather than just a label. You write it as a fenced block with the word suggestion, containing the exact replacement for the lines you’re commenting on:
```suggestionconst statuses = ['draft', 'sent', 'paid', 'archived'] as const;```That covers the reviewer’s side. The author has exactly one habit to internalize: respond to every comment. Every single one, even if the response is just “good catch, fixed” or “deferring this to follow-up issue #418.” A comment left with no reply reads as dismissal, because the reviewer can’t tell whether you disagreed, missed it, or silently complied, and that ambiguity erodes the trust the whole loop runs on.
The 60-second pass and the 30-minute pass
Section titled “The 60-second pass and the 30-minute pass”When you’re the reviewer, you work in two gears, and confusing them is a classic mistake. New reviewers tend to do one undifferentiated pass: either a slow careful read of a PR that turns out to be dead on arrival, or a fast skim of a PR that deserved real scrutiny. The fix is to run two distinct passes with two different jobs.
The 60-second pass is triage. Read the description, scan the diff, and flag only the things that make the PR dead on arrival: no tests where there obviously should be some, a missing migration, a secret committed by accident, a description that says “see commits.” This pass exists so that a fundamentally not-ready PR gets bounced in a minute instead of consuming thirty.
The 30-minute pass is the actual review. You re-read with full attention, you follow each change to the code that calls it, you open the preview deployment and click through the real feature, and then you leave your review. The 60-second pass decides whether the PR is worth the 30-minute pass; the 30-minute pass is where the real findings come from. Knowing which gear you’re in keeps you from spending thirty minutes on something that should have been bounced in one, and from waving through something that needed the full read.
Exercise: be the reviewer
Section titled “Exercise: be the reviewer”Reading about review only goes so far. The fastest way to internalize what makes a diff reviewable is to be the reviewer: to look at a real diff with the reviewer’s eye and leave the comments you’d leave. The following exercise is a small feat/invoice-status PR with three planted defects, each one a thing this lesson primed you to catch. Open each file, click the lines that deserve a comment, and write what you’d actually say. Submit when you’ve left your review, and you’ll see which of the three you caught.
You're reviewing a teammate's PR that adds a status filter to the invoices list. Leave a comment on every line that deserves one. Click any line to leave a review comment, then press Submit review.
import { and, asc, eq } from 'drizzle-orm';import { eq, and, asc } from 'drizzle-orm';import { db } from '@/db';import { invoices } from '@/db/schema';
const FILTERABLE_STATUSES = ['draft', 'sent', 'paid'];
export async function listInvoices(orgId: string, status?: string) { const where = [eq(invoices.orgId, orgId)]; if (status && FILTERABLE_STATUSES.includes(status)) { where.push(eq(invoices.status, status)); } return db .select() .from(invoices) .where(and(...where)) .orderBy(asc(invoices.createdAt));}import { listInvoices } from './list-invoices';
test('filters invoices by status', async () => { const draft = await listInvoices('org_1', 'draft'); const sent = await listInvoices('org_1', 'sent'); const paid = await listInvoices('org_1', 'paid'); expect([...draft, ...sent, ...paid].length).toBeGreaterThan(0);});The only thing this PR is supposed to do is add a status filter, yet the import line got reshuffled too. That reordering changes no behaviour — it’s pure churn — but it shows up in the diff as a changed line, so a reviewer has to stop and confirm nothing meaningful moved. On a real PR this kind of “while I was in here” edit is exactly what hides the load-bearing lines: the reviewer’s attention gets split between the change that matters and the noise that doesn’t. The senior move is to revert the reorder here and, if it’s worth doing at all, ship it as its own tiny PR. That keeps this one small and reviewable — one logical change a reviewer can state in a sentence.
There are four invoice statuses — draft, sent, paid, and archived — but FILTERABLE_STATUSES only lists three.
archived isn’t in the array, so the .includes guard on line 10 treats it as not-filterable and the branch is skipped… except the real damage is subtler: any user who does select a status sees a correctly filtered list, so the feature looks fine. The bug is that there’s no way to filter to archived at all, and that gap throws no error and prints no warning.
Silent data omission is the worst class of bug precisely because nothing complains — it only surfaces when a user asks “where did my archived invoices go?” Add 'archived' to the list (and a test for it).
This is the catch that only the slow, attentive 30-minute pass finds — and only because the diff was small enough to actually read line by line.
This assertion only checks that something came back across three calls — it never verifies that each call returned rows of the right status, never touches the archived case, and never tests the no-filter default.
A test this thin would pass even with the archived bug in place, and it would keep passing if someone later broke the filter entirely, as long as a single row leaked through.
That breaks reversibility: an untested branch is one you can’t revert or refactor with confidence, because nothing tells you whether the behaviour you removed was the behaviour that mattered.
And it contradicts the PR description, which claimed the test plan covered “each status case and the all default.” Reviewer’s job here is to hold the diff to the argument the author made for it.
Three planted defects, one per word of the spine. The import reorder breaks reviewable — it’s a drive-by that hides the lines that matter. The thin test breaks reversible — an untested branch can’t be safely backed out. And the omitted archived status is the correctness bug that only surfaces when the diff is small enough to read closely.
Notice the division of labour: the 60-second triage pass is enough to spot the scope churn and the suspiciously thin test, but catching the dropped status takes the full 30-minute read. That’s the whole point of running two gears — cheap problems get bounced fast, and the expensive problem gets the attention it actually needs.
The everyday loop: fixup commits and squash-merge
Section titled “The everyday loop: fixup commits and squash-merge”Now connect the conversation back to the Git mechanics from the previous two lessons, because the everyday review loop is where all three lessons converge. The cycle goes like this: a reviewer leaves a comment, you make the fix, you push, the reviewer looks again, they approve, you squash-merge. The only open question is how you make that fix, and the answer is the reason the earlier lessons chose the defaults they did.
When a reviewer asks for a change, you have two ways to commit the fix.
git commit -m "address review"git pushWorks fine. The squash-merge will absorb this commit, so main ends up clean either way. The cost shows up during review: “address review” is a meaningless commit subject, and the fix floats free of the original commit it’s correcting, so the history while the PR is open reads as noise.
git commit --fixup=a1b2c3dgit pushThe senior habit. --fixup=<sha> (from the rescue toolkit lesson) marks this commit as a fix for commit a1b2c3d. The fix is now logically attached to the line it corrects, so a reviewer reading the branch sees exactly which original change each fix belongs to. If the team ever uses a non-squash merge, git rebase -i --autosquash folds every fixup into its target automatically before merge.
With a squash-merge, both of those collapse to the identical clean commit on main, so the difference is entirely about legibility while the PR is open. The --fixup version keeps the intent of each fix visible during review; the plain version doesn’t. That’s a small win on any single PR and a real one across a team that does this all day.
The reviewer side has its own small efficiency here. They don’t re-read the whole diff after you push fixes, because GitHub offers a “changes since your last review” filter on the Files changed tab that shows only the commits added since they last looked. A five-line fix is then a five-line re-review, not a re-read of four hundred lines.
Now the payoff lands. This is why the trunk-based lesson made squash-merge the default and why the rescue toolkit lesson taught --fixup. During review, the reviewer sees every incremental change, including the work-in-progress commits, the fixups, and the back-and-forth, which is exactly what they need to follow your reasoning. At merge, the squash collapses all of it into one clean commit, so the messy in-progress history never reaches main. The PR page keeps the full conversation forever, for anyone who later asks why a given line is there. Review gets the detail, main gets the summary, and nothing is lost. That’s the two disciplines from the earlier lessons doing their job together.
gh, CODEOWNERS, and the squash-merge setting
Section titled “gh, CODEOWNERS, and the squash-merge setting”The workflow you’ve built so far is mostly habit and judgment. A few pieces of tooling and a couple of repo settings turn that judgment into something the repo enforces, so the right thing happens whether or not everyone remembers to do it. Three of them are worth knowing now. Each has a deeper enforcement story that belongs to the next lesson; here you’re learning the tool and the shape of its data, not the rules that lock it down.
The gh CLI, one line per command
Section titled “The gh CLI, one line per command”The course default for opening and reviewing PRs is the GitHub web UI, where the diff, the conversation, and the merge button all live, and it’s the right place to learn. But the gh CLI gets faster than the UI once the muscle memory lands, and it composes with shell aliases and scripts in ways the web UI can’t. Treat the following as a recognition list, not a tutorial. You want to recognize these when you see them, and reach for them when clicking starts to feel slow:
gh pr create # open a PR from the current branchgh pr view --web # open the current branch's PR in the browsergh pr checkout 123 # check out a teammate's PR locally to test itgh pr review # approve, comment, or request changesgh pr merge --squash --delete-branch # squash-merge and clean up the branchCODEOWNERS routes reviewers automatically
Section titled “CODEOWNERS routes reviewers automatically”When changes to the billing code should always be seen by the billing team, you don’t want to rely on whoever opened the PR remembering to add them. A CODEOWNERS file solves that. It’s a plain file at .github/CODEOWNERS that maps file-path globs to GitHub usernames or teams, and when a PR touches an owned path, GitHub automatically requests a review from the matching owner.
# Default owner for everything in the repo* @org/eng
# Money paths get the billing leads on every change/src/lib/billing/ @org/billing-leads
# Schema changes go through a database owner/src/db/schema.ts @org/dbaTwo things to hold onto. First, the file is data, not enforcement. On its own, CODEOWNERS only auto-requests the right reviewers, and an auto-requested review is still ignorable: nothing stops a merge if the owner never looks. Turning “requested” into “required” takes a ruleset, which is the next lesson’s job. Keep the distinction crisp: the file is the data, the rule is the enforcement.
Second, a real gotcha: the globs are gitignore-style, and the last matching line wins, not the most specific one. So you write the file general-to-specific, top to bottom, with the catch-all * first and the narrow paths below it. Put a broad pattern after a narrow one and it silently overrides the narrow rule you cared about, which is the single most common way a CODEOWNERS file goes wrong. As for what gets an owner: the high-stakes zones such as auth, billing, schema, and infra earn one, while ordinary UI and feature work usually doesn’t need the ceremony.
Squash-and-merge as the repo’s only merge button
Section titled “Squash-and-merge as the repo’s only merge button”The trunk-based lesson made squash-merge a discipline. One repo setting makes it structural, so nobody can deviate even by accident. In the repo’s Settings → General → Pull Requests, enable Squash and merge and turn off both Allow merge commits and Allow rebase merging. Now the merge button offers exactly one choice, and the result is guaranteed: one commit per PR on main, every time, with no undocumented “I picked the merge-commit button this once” exceptions polluting the history. Pair it with Automatically delete head branches so merged feature branches don’t pile up into a heap of stale branches nobody dares delete.
Two more settings are worth knowing by name, though you won’t enable them by default:
- Allow auto-merge queues a PR to merge itself the moment its checks pass and its reviews land. It’s useful when a PR is approved but you’re waiting on a slow CI run and don’t want to babysit the button. Reach for it once you trust your CI.
- Merge queue serializes merges: it rebases and re-checks each PR against the latest
mainbefore landing it, which prevents the race where two PRs each pass CI alone but breakmaintogether. It’s a tool for larger teams, and below a certain volume it’s just overhead. We name it here but leave it out of the 2026 SaaS-startup minimum.
Where the PR becomes the spine of everything
Section titled “Where the PR becomes the spine of everything”Step back and look at what you’ve actually built. The pull request stopped being a button you click and became the team’s unit of change: small, reviewable, reversible, argued for in a description, negotiated in review, and collapsed into one clean line of main’s history. That object is the assumption nearly every chapter from here on quietly builds on.
The next chapter wires CI to run on every PR, turning those checks into required gates instead of advisory ones. A later chapter binds a preview deployment to each PR, with its own URL and its own database branch, which is what finally fills the screenshots section of your description with a live link. The next lesson in this chapter takes the review and squash-merge habits you just learned and makes them mechanically enforced with rulesets, so they hold even on the day someone’s in a hurry. Later still, schema migrations ship as a series of small PRs exactly because you now know how to keep each one small. And the deep review methodology, the layered stack you’ve only seen the working surface of, gets its own chapter. Every one of those builds on the same thing: the PR as the hub the whole workflow turns around.
The canonical reference for the PR lifecycle, drafts, and review states.
CODEOWNERS syntax, glob rules, and the last-match-wins ordering in full.
Every gh command and flag, including the pr subcommands from this lesson.
External resources
Section titled “External resources”The references below go deeper on the three pillars of this lesson, sizing, stacking, and review etiquette, from teams that pioneered each.
Google's canonical case for one-logical-change reviews — the doctrine behind 'small, reviewable, reversible.'
Why Meta and Google ship features as stacks of small PRs, and the tooling that automates the chain.
A tiny spec for labelling review comments — nit, suggestion, issue, question — so intent is never ambiguous.