Tutorials Ultimate Web Development Series Chapter 18

Pull Requests — The Workflow Real Teams Ship With

WebChapter 18 of the Ultimate Web Development Series26 minMay 27, 2026Beginner

If you've only ever committed straight to main, the first time you join a team or contribute to open source, you'll hit a wall of jargon: branch, fork, draft PR, conflict, rebase, squash, force-push, ready for review, approve, request changes. None of it is hard, but it's never explained in one place — people assume you picked it up from somewhere.

This chapter is that one place. By the end you'll know what a pull request actually is, why every team uses them, the four commands that make up the entire workflow, the three merge strategies and which to pick, how to fix a merge conflict without panicking, and what makes a PR description that a reviewer actually wants to read.

What a Pull Request Actually Is

A pull request (or PR — sometimes called a merge request or MR on GitLab) is a proposal:

"Here's a branch with some changes. Please review them and, if you approve, merge them into the main branch."

That's it. It's not a special git feature — it's a GitHub/GitLab/Bitbucket UI on top of two regular git concepts (branches and merges). The PR adds a place to:

The shape of every PR-based workflow is the same:

Loading diagram…

Figure 1 — The PR loop. Branch off main, push it, open a PR, get reviewed and CI-checked, merge back. The branch dies after merge; main moves forward by one commit (or one merge commit, depending on strategy).

Why Not Just Push to Main?

For a solo project on day one, you absolutely can. Most weekend hacks live their whole life with git commit && git push on main. There's no rule against it.

The reasons to switch to PRs as the project grows:

  1. You're not the only committer. Two people pushing to main will eventually push at the same time and one of you will be very confused.
  2. You want CI to gate the merge. Without a PR, CI runs after the broken commit is already on main. With a PR, CI runs before and blocks the merge.
  3. You want a second pair of eyes. Even brilliant developers ship bugs that a 30-second skim by a colleague would have caught.
  4. You want a paper trail. Six months from now you'll need to know why a function was changed. The PR description is where that lives.
  5. You're contributing to someone else's project. You can't push to their main — you can only open a PR.

For a one-person side project: skip PRs until the first of those reasons applies. For anything with a teammate, customer, or future-you who'll forget: PRs from day one.

The Four Commands That Are 95% of the Workflow

Open a terminal. Here's the full lifecycle of one PR using nothing but git + the gh CLI (Ch 15 installed it, otherwise brew install gh and gh auth login):

# 1. Start a new branch off latest main
git switch main
git pull
git switch -c fix/typo-in-header
 
# 2. Make changes, commit
# …edit files…
git add .
git commit -m "Fix typo in header"
 
# 3. Push the branch + open the PR
git push -u origin fix/typo-in-header
gh pr create --fill
 
# 4. Address review feedback, push more commits to the same branch
# …edit files…
git commit -am "Address review: rename variable"
git push

That's the whole core workflow. Let's name what each step does.

Step 1 — git switch -c: create a branch

A branch is a movable label pointing at a commit. main is a branch. fix/typo-in-header is now a new branch starting from the same commit main is on. Commits you make are added to whatever branch you're currently on.

git switch -c <name> creates a new branch and switches to it. (The older spelling git checkout -b does the same.)

Branch naming: most teams use type/short-description. Common types: feat/, fix/, chore/, refactor/, docs/. It's a convention, not a rule — pick what your team uses and stop worrying about it.

Step 2 — git commit: save a snapshot

A commit is a snapshot of every tracked file at one moment, plus a message and a parent commit. You'll have one or many commits on your branch.

Commit message convention worth adopting: write the subject line as if completing the sentence "If applied, this commit will __". So Fix typo in header, not Fixed typo or Header typo.

Step 3 — git push -u origin <branch> and gh pr create

git push uploads your local commits. The -u origin <branch> says "this is the first time, set up the upstream tracking so plain git push works from now on".

gh pr create --fill opens the PR using your last commit's message as title and body. For multi-commit branches, use gh pr create (no --fill) to write a proper description in an editor — covered later.

Step 4 — Address feedback by pushing more commits

This is the part that surprises beginners. Once a PR is open, pushing more commits to the same branch updates the PR automatically. There's no "submit again" button. The PR is the branch — push to the branch, the PR refreshes, CI re-runs.

So the review loop is:

  1. Reviewer leaves comments.
  2. You make changes locally, commit, push.
  3. PR shows the new commits and a "fixed" diff. Reviewer re-checks. Approves or asks for more.
  4. Once approved + CI green, merge.

Forks vs Branches — When You Need Each

Two ways to propose a change:

The PR mechanics are identical — only the origin is different. gh pr create figures out which one you're in and does the right thing.

Loading diagram…

Figure 2 — Two paths to the same PR. Team members push branches in the main repo. External contributors fork first and push to their own copy. Both end at "open a PR".

Draft PRs — "Look but Don't Merge Yet"

A draft PR is a PR explicitly marked as "work in progress". GitHub disables the merge button and (usually) tells reviewers not to review it yet.

Use one when:

Open as draft from the CLI:

gh pr create --draft

Or click the dropdown arrow next to "Create pull request" on GitHub. When ready, click "Ready for review" — that flips it to a normal PR.

Anatomy of a Good PR Description

A PR is a half-tweet, half-changelog, half-spec. There's no universal format, but the structure that consistently works:

## Summary
One paragraph: what this PR changes and why.
 
## Changes
- Bullet list of the noteworthy edits
- Don't list every file — link to the diff for that
 
## Why
Context the diff doesn't show. The bug report, the design doc,
the prod incident, the customer ask.
 
## Test plan
- How you tested this manually
- Any new automated tests added
- Any cases reviewers should specifically check
 
## Screenshots / Demo
For UI changes only.

The most undervalued section is Why. The diff shows what changed. The branch name and commit messages show roughly what. But why this fix and not the obvious other one? Why now? Why this approach over the simpler one a reviewer might suggest? That's the part that saves five round-trips of "but couldn't we just…".

GitHub remembers your last PR description as a template. To make a real one per repo, drop .github/pull_request_template.md with the shape above — every new PR will start with that skeleton.

Keeping Your Branch Up to Date

Long-lived PRs eventually fall behind main. Reviewers ask "can you rebase?". You have two options.

Option A: Merge main into your branch

git switch fix/typo-in-header
git fetch origin
git merge origin/main
# resolve any conflicts (see next section)
git push

Adds a merge commit to your branch. History becomes a small diamond. Safe and reversible. Some teams prefer this for ongoing collaboration where multiple people share a branch.

Option B: Rebase your branch on top of main

git switch fix/typo-in-header
git fetch origin
git rebase origin/main
# resolve any conflicts
git push --force-with-lease

Replays your commits one by one on top of latest main. Result: a linear history with no merge commits — looks like you just wrote your changes today against the freshest main.

The cost: rebase rewrites your commits' SHAs, so the branch on your machine no longer matches the branch on the server. You have to force-push. Always use --force-with-lease, not --force — it refuses to overwrite if someone else pushed while you were rebasing, saving you from clobbering their work.

Which to use? Personal preference per project. The common modern default: rebase your feature branch, merge into main — feature branches stay clean, the merge into main is a single squash or merge commit.

Fixing a Merge Conflict (Without Panic)

A conflict happens when two branches changed the same line of the same file. Git can't decide which version wins, so it pauses and asks you.

After a conflicting git merge or git rebase, your file looks like this (the marker lines are indented here for annotation — in your real file they sit flush against the left margin, at column 1, with no indentation):

  function getUser() {
    <<<<<<< HEAD                                        ← start of YOUR version
      return fetchUser({ id, includeAvatar: true });
    =======                                             ← divider
      return fetchUser({ id, includeProfile: true });
    >>>>>>> origin/main                                 ← end of THEIR version
  }

Three markers:

You manually pick the right thing — often a combination — and delete the markers:

function getUser() {
  return fetchUser({ id, includeAvatar: true, includeProfile: true });
}

Then:

git add path/to/the/file.js
git commit              # for merge
# OR
git rebase --continue   # for rebase

Repeat for each conflict. VS Code/Cursor/most editors show inline buttons ("Accept current", "Accept incoming", "Accept both") that handle the markers for you.

The one rule: when in doubt, ask the person whose code you're conflicting with. They wrote it for a reason. Five minutes of "can you check this merge?" beats half a day of debugging a silent merge mistake.

Merge, Squash, or Rebase — Picking a Merge Strategy

When the PR is approved + green, GitHub gives you three buttons on the merge dropdown:

StrategyWhat happensBest for
Create a merge commitKeeps every commit on the branch + adds a merge commit on mainLong-lived feature branches with meaningful commit history you want to preserve
Squash and mergeCollapses all branch commits into one new commit on mainMost cases. PR title becomes the commit message. Clean history.
Rebase and mergeReplays branch commits onto main without a merge commitBranches with already-clean per-commit history (rare in practice)

For 90% of teams: default to squash and merge. Reasons:

If you set the default in repo Settings → General → Pull Requests, the button auto-picks the right strategy and your team stops thinking about it.

Commit SHAs — Why Every Commit Has a 40-Character Hash

Open git log. Every commit has a long hexadecimal string next to it:

461f450e9c8a8f9b1d7c…  web tutorials: add Chs 16-19
32be3848…              Retire legacy /api/credits/add endpoint (#2)
b4d690f2…              Centralize website Stripe links (#1)

That string is the commit's SHA — short for SHA-1 hash, the cryptographic algorithm git uses to fingerprint every commit. (Newer git versions are migrating to SHA-256; same idea, longer string.) The full hash is 40 characters; most tools show the first 7-10 because that's enough to be unique in any normal repo.

What it's a hash of

Git feeds these inputs through SHA-1 to produce the commit's ID:

Change any one of those — even a single space in the message — and you get a completely different SHA. That's the whole point: the SHA is the commit. Two commits with the same SHA are literally the same commit.

What the SHA is used for

A SHA is an unforgeable, globally-unique pointer to one specific snapshot of the codebase. Once you have one, you can:

git show 461f450e                # see the full commit
git checkout 461f450e            # check out that exact snapshot (detached HEAD)
git diff 461f450e 32be3848       # diff between two commits
git revert 461f450e              # undo just that commit
git cherry-pick 461f450e         # apply that commit to your current branch
git blame -L 10,20 file.js       # see which SHA last touched each line

This is why PRs link to SHAs instead of "the third commit Alice made on Tuesday". 461f450e means one thing forever. Branch names (fix/typo) and human descriptions are mutable; SHAs are not.

Where you'll see SHAs in a PR

Open any PR on GitHub. You'll see SHAs in three places:

When CI fails or someone files a bug "we noticed weird behaviour after 461f450e", that SHA is the link back to exactly the change that caused it.

Short SHAs and uniqueness

git log --oneline shows 7-character prefixes (461f450e). Git accepts any unambiguous prefix — as few as 4 characters often works on small repos. The risk: collisions become possible on huge repos (the Linux kernel auto-uses 12 characters for this reason). Always copy from git log rather than typing from memory.

"Detached HEAD" — what happens when you check out a SHA directly

git checkout 461f450e jumps you to that exact snapshot. But you're no longer on a branch — git calls this detached HEAD and warns about it. Useful for debugging ("does the bug exist at this old SHA?"). Don't commit there — the commit would be on no branch and easy to lose. To work from that point, branch off it: git switch -c investigate-bug.

SHAs in commit messages

A common convention: when you fix a bug introduced in a specific commit, mention the SHA in the message:

Fix off-by-one in pagination
 
Introduced by 461f450e9c8a, which mis-counted the offset
for the last page.

That gives the next person reading git blame an immediate breadcrumb back to the original context — much faster than "fix bug".

After the Merge — Cleaning Up

Once your PR is merged:

git switch main
git pull
git branch -d fix/typo-in-header   # delete local branch

GitHub usually deletes the remote branch automatically (Settings → General → "Automatically delete head branches"). Turn that on — there's no reason to keep merged branches around, they just clutter the dropdown.

Reverting a PR

Production caught fire. You need to undo a PR.

gh pr view 123  # find the merge commit SHA
git revert <merge-sha> -m 1
git push

-m 1 tells git "when reverting a merge commit, treat the first parent (main) as the canonical side". This produces a new commit that exactly undoes the PR.

GitHub also has a Revert button on every merged PR. Click it, GitHub opens a new PR with the revert. Review it, merge it, you're back to the previous good state. The original PR remains in history but its effects are undone.

This is much safer than git reset --hard or force-pushing. Always revert by adding a commit, never by deleting one.

The PR Etiquette Nobody Tells You

A handful of small habits that make teams much happier to merge your PRs:

The Mac App Workflow — One Concrete Example

The SimpleAppShipper repo uses PRs for anything non-trivial. The flow is exactly the chapter:

  1. git switch -c feat/aso-keyword-research off main.
  2. Build the feature across 3-5 commits.
  3. gh pr create with a description that links the design notes.
  4. CI runs lint, type-check, build, tests. Branch protection blocks merge until green.
  5. One reviewer approves.
  6. Squash and merge. Branch auto-deleted.
  7. npm run cf:build && npm run cf:deploy to ship.

That's it. Same loop for every feature shipped in 2026. The whole point of a workflow is that it becomes invisible — you stop thinking about steps and start thinking about the work.

Mental Model — Three Sentences

  1. A pull request is a branch + a web UI for reviewing and gating its merge into main — the unit of work every modern codebase changes by.
  2. The core workflow is four commands: git switch -c, git commit, git push -u, gh pr create — then more commits + pushes until approved.
  3. Squash-and-merge is the default modern strategy because one PR becoming one commit on main makes history readable, blames meaningful, and reverts cheap.

Try It Yourself (15 Minutes)

  1. In any repo (yours or a fork of a friend's), create a branch: git switch -c try/my-first-pr.
  2. Edit one file. Commit. Push with -u.
  3. gh pr create --fill. Open the URL it prints.
  4. On GitHub, click "Files changed" to see the diff. Add a comment on any line.
  5. Push another commit. Watch the PR refresh.
  6. Squash-and-merge. Watch your branch disappear.
  7. Locally: git switch main && git pull && git branch -d try/my-first-pr. You've done the entire cycle.

Repeat 50 times and you're a senior. The mechanics never change; only the size and stakes of what you put inside the PR.

Where This Lands in the Series

Ch 15-17 covered the robots in the pipeline — CI, lint, tests. This chapter covers the humans — how a change gets proposed. The next chapter, Code Review, covers the other side of that conversation: how to read a PR somebody else opened and leave feedback that actually helps.

Ch 17: Unit Testing with VitestCh 19: Code Review
Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.Astro + Next.jsAstro & Next.js SeriesStatic and hybrid web app patterns with Astro, Next.js, MDX, dynamic routes, and Cloudflare deploys.CloudflareCloudflare Feature FocusFocused Cloudflare tutorials for Workers, R2, Stream, Durable Objects, and edge deployment.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access