Tutorials Git & GitHub Pro Series Chapter 2

Merge, Rebase, or Squash? Branches and Rewriting History

Git + GitHubChapter 2 of the Git & GitHub Pro Series30 minMay 28, 2026Beginner

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 into main.

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.

Loading diagram…

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:

Loading diagram…

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.

Loading diagram…

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.

Loading diagram…

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

MergeRebaseSquash
History shapeForks + merge commits (true)Straight line (rewritten)One commit per feature
Rewrites commits?NoYes (new SHAs)Yes (combines into one)
Keeps every WIP commit?YesYes (reordered)No — collapsed
Best forPreserving exact history; long-lived branchesUpdating your branch with latest main; clean lineA tidy main where each PR = one commit
Watch out for"Merge bubble" noiseThe 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-login

Read it as: "HEAD (your current branch) says Hello; the incoming branch (add-login) says Hi there." To resolve:

  1. Open the file and edit it to what you actually want — keep one side, the other, or a blend.
  2. Delete the marker lines (<<<<<<<, =======, >>>>>>>).
  3. git add <file> to tell git "this one's resolved."
  4. git commit (for a merge) or git 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 optionWhat it does
Create a merge commitA true merge (Figure 2) — preserves every branch commit + adds a merge commit.
Squash and mergeSquash (Figure 4) — the whole PR becomes one commit. The tidy default; makes your #N commits.
Rebase and mergeReplays 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

  1. Merge ties two lines of history together honestly — a fast-forward if main didn't move, or a two-parent merge commit if it did.
  2. Rebase replays your commits on top of the latest main for a clean straight line, but rewrites them (new SHAs) — so never rebase history others already have.
  3. Squash collapses a whole branch into one commit, which is what GitHub's "Squash and merge" does to make those one-commit-per-PR #N entries on your main.

Try It Yourself (15 Minutes)

  1. git switch -c playground, make two commits, then git switch main and git merge playground. Run git log --graph --oneline — see a fast-forward (straight line, no merge commit).
  2. Now make a commit on main and a commit on a new branch, then merge. See the merge commit with two parents in the graph.
  3. On a fresh branch with two commits, run git rebase main and git log before/after — notice the commits have different SHAs after rebasing.
  4. On GitHub, open any PR you've merged and look at the merge message — find the (#N) and confirm it matches the PR number.
  5. Read your repo's git log --oneline and identify which commits came from squash-merged PRs (the #N ones) 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.

Ch 1: How Git Actually WorksCh 3: GitHub Like a Pro
DeliveryModern Delivery PipelineCI/CD, review, runner, and deploy workflows for teams shipping apps and websites safely.Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.WebUltimate Web Development SeriesWeb development tutorials for HTML, CSS, JavaScript, Next.js, Workers, databases, and production shipping.

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