In Chapter 1 you learned that git history is a graph of snapshots and that branches are just movable labels. This chapter cashes that in. Because once you see history as a graph, the big scary trio — merge, rebase, squash — stops being three mysterious commands and becomes three different ways of rearranging the same graph.
This is also the chapter that explains what you've been seeing on your own repo: the #3, #4, #5 next to merged pull requests, and why your main history looks the way it does. Let's go.
Feature Branches: Why You Don't Work on main
Recall: a branch is a 40-byte label pointing at a commit, and creating one copies nothing. That cheapness is why the standard workflow is:
Never build a feature directly on
main. Branch off, do your messy work-in-progress commits on the branch, then bring the finished result back intomain.
git switch -c add-login # create + switch to a new branch off main
# ...edit, commit, edit, commit...
git switch main # back to main
# now: how do we bring add-login's work into main?That last question — how do we bring the branch back in — has three answers. They differ only in what shape the graph ends up.
Option 1: Merge (the honest one)
git merge integrates a branch into your current one. There are two flavours depending on whether main moved while you were working.
Fast-forward: when main didn't move
If nobody touched main since you branched, main is still pointing at the commit your branch grew from. Bringing the branch in is then trivial: git just slides the main label forward to your branch's tip. No new commit is created. This is a fast-forward.
Figure 1 — Fast-forward. main was on B; your branch added C and D. Since main never moved, merging is just moving the main label from B to D. History stays a clean straight line.
This is exactly what happens when you git push your local commits and the remote main hadn't changed — git fast-forwards the remote label. It's the simplest, tidiest outcome.
Merge commit: when both branches moved
If main did advance (someone else merged, or the auto-sync daemon committed) while your branch also advanced, the history has genuinely forked. Git can't just slide a label — it creates a brand-new merge commit with two parents, tying the two lines back together:
Figure 2 — A real merge. main advanced to C; your branch advanced to E; both grew from B. Git makes merge commit M whose two parents are C and E. History now shows the fork and the rejoin — the literal truth of what happened.
You can force a merge commit even when a fast-forward was possible with git merge --no-ff. Some teams do this so every feature shows up as a visible "merge bubble" in history. Others find those bubbles noisy — which is what rebase and squash are for.
Option 2: Rebase (the tidy one)
A rebase asks a different question: instead of tying two lines together with a merge commit, what if my branch had been started from the latest main in the first place? git rebase main replays your branch's commits, one by one, on top of main's current tip — producing a straight line with no merge commit.
Figure 3 — Rebase. Your commit E (which grew from B) is re-created as E′ on top of C. Same changes, new commit, new SHA — because (Ch 1) its parent changed, so its hash must change. The result is a clean linear history with no fork.
That "new SHA" detail is the whole catch, and it leads to the single most important rule in git:
Rebase shines for one thing: before merging your feature, git rebase main pulls in the latest main and lays your work cleanly on top, so the eventual merge is a trivial fast-forward and history reads as a straight, readable line.
Option 3: Squash (the one on your repo)
A squash collapses all the commits on your branch into a single new commit on main. Your branch might have ten messy "wip", "fix typo", "actually fix it" commits — squash-merging lands them as one clean commit on main.
Figure 4 — Squash-merge. Every commit from the feature branch is combined into one new commit S on main. The messy work-in-progress history is discarded; main gets one tidy, self-contained change.
This is what's been happening on your repo. When a pull request merges with the "Squash and merge" button, GitHub takes the whole branch, mashes it into one commit, gives it a message like web tutorials: add Chs 16-20 (#3), and that (#3) is the pull-request number. So your main log reads as a clean list of features — one commit per PR — instead of every tiny work-in-progress step. That's why squash is the most popular default for a tidy main.
Merge vs Rebase vs Squash — The Decision Table
| Merge | Rebase | Squash | |
|---|---|---|---|
| History shape | Forks + merge commits (true) | Straight line (rewritten) | One commit per feature |
| Rewrites commits? | No | Yes (new SHAs) | Yes (combines into one) |
| Keeps every WIP commit? | Yes | Yes (reordered) | No — collapsed |
| Best for | Preserving exact history; long-lived branches | Updating your branch with latest main; clean line | A tidy main where each PR = one commit |
| Watch out for | "Merge bubble" noise | The Golden Rule (never on shared history) | Loses granular history of the branch |
There's no universally correct answer — it's a team style choice. A common, sane default: squash-merge feature PRs into main (clean history, one commit per feature) and rebase your own branch to stay current while you work. That's the setup behind the #N commits you've been seeing.
Surviving a Merge Conflict
A conflict happens when two branches changed the same lines of the same file, and git can't know which you want. It's not an error — git is asking you to decide. You'll see this in the file:
<<<<<<< HEAD
const greeting = "Hello";
=======
const greeting = "Hi there";
>>>>>>> add-loginRead it as: "HEAD (your current branch) says Hello; the incoming branch (add-login) says Hi there." To resolve:
- Open the file and edit it to what you actually want — keep one side, the other, or a blend.
- Delete the marker lines (
<<<<<<<,=======,>>>>>>>). git add <file>to tell git "this one's resolved."git commit(for a merge) orgit rebase --continue(for a rebase).
That's the whole dance. The markers look alarming the first time; they're just git showing you both options and waiting. If it ever goes sideways, git merge --abort or git rebase --abort returns you to exactly where you started — no harm done.
The GitHub Merge Button Is Just These Three
When you click Merge pull request on GitHub, the dropdown offers exactly the three options from this chapter:
| Button option | What it does |
|---|---|
| Create a merge commit | A true merge (Figure 2) — preserves every branch commit + adds a merge commit. |
| Squash and merge | Squash (Figure 4) — the whole PR becomes one commit. The tidy default; makes your #N commits. |
| Rebase and merge | Replays the PR's commits onto main with no merge commit (Figure 3). |
Repo owners can set the default and even disable the ones they don't want (Settings → General → Pull Requests). Now you know precisely what each does to the graph.
Mental Model — Three Sentences
- Merge ties two lines of history together honestly — a fast-forward if
maindidn't move, or a two-parent merge commit if it did. - Rebase replays your commits on top of the latest
mainfor a clean straight line, but rewrites them (new SHAs) — so never rebase history others already have. - Squash collapses a whole branch into one commit, which is what GitHub's "Squash and merge" does to make those one-commit-per-PR
#Nentries on yourmain.
Try It Yourself (15 Minutes)
git switch -c playground, make two commits, thengit switch mainandgit merge playground. Rungit log --graph --oneline— see a fast-forward (straight line, no merge commit).- Now make a commit on
mainand a commit on a new branch, then merge. See the merge commit with two parents in the graph. - On a fresh branch with two commits, run
git rebase mainandgit logbefore/after — notice the commits have different SHAs after rebasing. - On GitHub, open any PR you've merged and look at the merge message — find the
(#N)and confirm it matches the PR number. - Read your repo's
git log --onelineand identify which commits came from squash-merged PRs (the#Nones) vs direct/auto-sync commits.
Where This Lands in the Series
You can now read and reshape git history deliberately — merge for truth, rebase for tidiness, squash for a clean main — and resolve conflicts without fear.
Next chapter moves from git (the tool) to GitHub (the platform): issues, branch protection, required checks, releases and versioning, and the gh command line — the "pro" features that turn a pile of commits into a coordinated project. The merge strategies you just learned are the foundation the whole PR workflow sits on.
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