Skip to content
Log in

Theory

Merging on GitHub: merge commit, squash and merge, rebase

What merging does, the three options behind GitHub's merge button (merge commit, squash and merge, rebase and merge) and what each leaves in the history, a fast-forward merge in plain words, and how to check whether a change is really in main.

~11 min read · 10 exercises · 250 points

Before you start

The green button

News for CatChow's product team on the finance request: Daniel's pull request #133, raising the free-delivery threshold to 1000 UAH, has been approved in review. Its title is your commit message from module 1. One step is left: merging it.

In this lesson you'll see what merging does, the three options behind GitHub's merge button and what each leaves in the history, and how to check whether a change has really reached main.

What merging does

Back at CatChow, finance asked to raise the free-delivery threshold. Daniel's pull request #133, "Raise free-delivery threshold to 1000 UAH", has been approved in review. One step is left: merging it.

Merging means bringing the changes of one branch into another. A merge has a direction: here, from feature/free-delivery-1000 into main. Until it happens, the site keeps saying 800.

The branch has three commits: the new number in the config, the delivery page text and the hint in the cart. After the merge main contains all three changes together, whatever files they touch; nothing has to be merged file by file. The branch itself stays exactly as it was, and it's usually deleted afterwards.

Pull request #133 before the mergeThree commits on the branch, none of them in main yet.
  1. 8be2f70Add cat treats bundlemain
  2. 5c1d7e2Set freeDeliveryFrom to 1000
  3. a8f40b9Update delivery page text
  4. d36b9c5Update cart hint textfeature/free-delivery-1000

Commit graph, oldest first: Pull request #133 before the merge 1. commit 8be2f70 on main "Add cat treats bundle" 2. branch feature/free-delivery-1000 starts from commit 8be2f70 3. commit 5c1d7e2 on feature/free-delivery-1000 "Set freeDeliveryFrom to 1000" 4. commit a8f40b9 on feature/free-delivery-1000 "Update delivery page text" 5. commit d36b9c5 on feature/free-delivery-1000 "Update cart hint text"

Engineers write the direction the other way round in a terminal: they stand on the receiving branch and name the one coming in, git switch main and then git merge feature/free-delivery-1000. On GitHub the pull request states it plainly at the top, for example "wants to merge 3 commits into main from feature/free-delivery-1000".

  • Do
    "#133 merges feature/free-delivery-1000 into main: all three changes arrive together."
  • Don't
    "After the merge the branch got main's changes."

    The direction here is from the branch into main.

One button, three options

At the bottom of a pull request there's a green Merge pull request button with a small arrow next to it. The arrow opens three options:

  • Create a merge commit
  • Squash and merge
  • Rebase and merge

Which one gives the right result? All of them. After any of the three, the files in main are identical: the free-delivery threshold is 1000 either way, and the delivery page and cart hint read the same. What differs is the history you'll read afterwards: how many entries main gains, whether the branch's original commits appear, and whether the graph shows two lanes joining or one straight line.

Three differently shaped paths wind down a hill, one with a junction, one straight, one across stepping stones, and all reach the same front door.
The files end up identical; what differs is how the history reads.

Which options you see depends on the repository settings. An admin can switch any of them off, so some repositories offer only one. That setting is also how a team quietly enforces its chosen way.

For a PM the practical upshot is simple. You don't need to pick an option to know whether a change is in. But you do need to know which one your team uses before you count commits or read the graph.

  • Do
    "Whichever option Daniel picks, main will say 1000."
  • Don't
    "Use Rebase and merge, otherwise the text might come out wrong."

    The files are identical; only the history differs.

Option 1, a merge commit

Create a merge commit adds the branch's commits to main as they are and ties the two lines together with one extra commit, a merge commit.

On a graph you see two lanes joining. The merge commit is special: it has two parents, the previous commit on main and the last commit on the branch. GitHub writes its message itself, for example "Merge pull request #133 from catchow/feature/free-delivery-1000".

Option 1: a merge commitThe two lanes join in e4b7a90, which has two parents. The history of main gains four entries.
  1. 8be2f70Add cat treats bundle
  2. 5c1d7e2Set freeDeliveryFrom to 1000
  3. a8f40b9Update delivery page text
  4. d36b9c5Update cart hint textfeature/free-delivery-1000
  5. e4b7a90Merge pull request #133main

Commit graph, oldest first: Option 1: a merge commit 1. commit 8be2f70 on main "Add cat treats bundle" 2. branch feature/free-delivery-1000 starts from commit 8be2f70 3. commit 5c1d7e2 on feature/free-delivery-1000 "Set freeDeliveryFrom to 1000" 4. commit a8f40b9 on feature/free-delivery-1000 "Update delivery page text" 5. commit d36b9c5 on feature/free-delivery-1000 "Update cart hint text" 6. merge commit e4b7a90: feature/free-delivery-1000 merged into main "Merge pull request #133" (highlighted)

So for Daniel's three-commit branch, the history of main grows by four entries: his three commits, with their original hashes, plus the merge commit. Nothing is rewritten, and you can always see where a branch started and where it came back. In a terminal engineers get the same result with git merge --no-ff feature/free-delivery-1000, which always creates a merge commit.

The price is a busier history. Every small step of every branch ends up in main, including commits like "oops, typo", and a week with ten pull requests leaves a tangle of lanes to read.

  • Do
    "r3 has two parents, r2 and t2, so it's a merge commit."
  • Don't
    "After a merge commit, main has just one new entry."

    The branch's commits come along too.

Option 2, squash and merge

Squash and merge packs all the commits of a pull request into one new commit on main. Every change from the branch arrives together as a single step in the history.

Three small parcels are packed into one bigger box with a single blank address sticker, which rides a conveyor into a warehouse.
A squash merge turns a branch's steps into one commit on main.

On a graph the branch connects to that commit with a dashed line: the branch's commits themselves did not become part of main's history; only their combined changes did. GitHub adds the pull request number to the commit title by itself, as in "Raise free-delivery threshold to 1000 UAH (#133)", so the way back to the discussion is one click. Before confirming, the person merging can edit the message; by default it uses the pull request title and lists the original commit titles underneath.

Option 2: squash and mergeOne new commit on main, 6f2c8a1, with a dashed link to the branch. The three original commits are not in the history of main.
  1. 8be2f70Add cat treats bundle
  2. 5c1d7e2Set freeDeliveryFrom to 1000
  3. a8f40b9Update delivery page text
  4. d36b9c5Update cart hint textfeature/free-delivery-1000
  5. 6f2c8a1Raise free-delivery threshold to 1000 UAH (#133)main

Commit graph, oldest first: Option 2: squash and merge 1. commit 8be2f70 on main "Add cat treats bundle" 2. branch feature/free-delivery-1000 starts from commit 8be2f70 3. commit 5c1d7e2 on feature/free-delivery-1000 "Set freeDeliveryFrom to 1000" 4. commit a8f40b9 on feature/free-delivery-1000 "Update delivery page text" 5. commit d36b9c5 on feature/free-delivery-1000 "Update cart hint text" 6. commit 6f2c8a1: feature/free-delivery-1000 squash-merged into main "Raise free-delivery threshold to 1000 UAH (#133)" (highlighted)

CatChow uses squash. That's why main looks like a straight line: one pull request, one commit, and "oops, typo" never reaches main.

Where are the original steps? In the pull request's Commits tab, which keeps them even after the branch is deleted. In a terminal, git merge --squash prepares the same combined change for one commit.

  • Do
    Missing commits? Click (#133) in the commit title → the pull request → Commits
  • Don't
    "Two of Daniel's commits got lost on the way to main."

    They were squashed; the pull request keeps them.

Fast-forward, the merge with no new commit

You'll hear engineers say "fast-forward". It's the simplest merge of all, and it happens when main hasn't changed since the branch started.

Remember that a branch is just a name pointing at a commit. If main still points at the commit where the branch began, the branch is simply further along the same line. There's nothing to combine, so Git can move the name main forward to the last commit of the branch. No new commit, no second parent: the name just catches up.

That's what git merge feature/free-delivery-1000 does by default in a terminal in that situation. If the team wants a visible merge commit anyway, they add --no-ff; if they want to refuse anything but a fast-forward, --ff-only.

One catch for GitHub users: as of 2026 GitHub's merge button doesn't do a plain fast-forward. Create a merge commit always adds a merge commit, and Rebase and merge always re-creates the commits with new hashes. So a fast-forward in the history usually means someone merged from a terminal.

  • Do
    main didn't move → git merge fast-forwards: the name catches up, no new commit
  • Don't
    Expect GitHub's green button to fast-forward

    It always adds a merge commit or re-creates the commits.

Option 3, rebase and merge

Rebase and merge replays the branch's commits on top of main, one by one. There's no merge commit and no squashing.

On the graph main simply continues in a straight line with three new commits that carry the same messages as the branch's commits but different hashes. They're copies: rebase re-creates each commit in a new place, and a re-created commit always gets a new hash. The originals stay on the branch until it's deleted. In a terminal the same idea is git rebase main on the branch, followed by a fast-forward of main.

Option 3: rebase and mergeThree copies of the commits, with new hashes, continue the straight line of main. The originals stay on the branch until it is deleted.
  1. 8be2f70Add cat treats bundle
  2. 5c1d7e2Set freeDeliveryFrom to 1000
  3. a8f40b9Update delivery page text
  4. d36b9c5Update cart hint textfeature/free-delivery-1000
  5. 1f6a3d8Set freeDeliveryFrom to 1000
  6. 2b9e5c4Update delivery page text
  7. 7d0c8f1Update cart hint textmain

Commit graph, oldest first: Option 3: rebase and merge 1. commit 8be2f70 on main "Add cat treats bundle" 2. branch feature/free-delivery-1000 starts from commit 8be2f70 3. commit 5c1d7e2 on feature/free-delivery-1000 "Set freeDeliveryFrom to 1000" 4. commit a8f40b9 on feature/free-delivery-1000 "Update delivery page text" 5. commit d36b9c5 on feature/free-delivery-1000 "Update cart hint text" 6. commit 1f6a3d8 on main "Set freeDeliveryFrom to 1000" 7. commit 2b9e5c4 on main "Update delivery page text" 8. commit 7d0c8f1 on main "Update cart hint text" (highlighted)

Put the four pictures side by side:

  • Merge commit: two lanes joining in a commit with two parents.
  • Squash: one new commit on main, with a dashed link to the branch.
  • Rebase: copies of the branch's commits, one after another on main.
  • Fast-forward (terminal only): no new commit; the name main moves to the tip of the branch.

If you can tell these four apart on a graph, you can read any team's history.

  • Do
    Same messages, new hashes, straight line → Rebase and merge
  • Don't
    Search main for the branch's original hashes after a rebase

    Rebase creates copies with new hashes.

Why a team picks one way

If the files end up the same, why do teams argue about merge options? Because they read history every day, and each option makes it read differently.

  • Squash fans want each entry in main to be one finished piece of work with a link to its pull request. Undoing it means reverting one commit.
  • Merge commit fans want the full record: every commit with its original hash and the exact points where branches joined.
  • Rebase fans want every step too, but in a straight line.

There's no winner. What matters is that the team agrees on one way, so the history reads the same from week to week.

For a PM this changes how you count. In a squash repository one commit can hide a week of work. In a merge-commit repository ten commits can be one small feature. So "how many commits went into this release?" is a trick question until you know how the team merges; "how many pull requests?" is usually the better question. And when you read a graph, recognise the option first: straight line with copies means rebase.

  • Do
    "How many pull requests went into this release?"

    Means the same whichever merge option the team uses.

  • Don't
    Compare two teams' productivity by counting commits in main

    Squash and merge commits give very different counts for the same work.

Is the change in `main`?

The question you'll hear most often as a PM is "is it in?". The answer is on the badge at the top of every pull request:

  • Open (green): proposed and under discussion; the changes are still on the branch.
  • Draft (grey): the author is still working; not ready for review, let alone main.
  • Merged (purple): the changes went into the target branch. The line next to the badge says which one and when.
  • Closed (red): closed without merging; the changes never got in.
Pull requests in catchow/website
  • Raise free-delivery threshold to 1000 UAH#133

    Merged into main today: the changes are in main

  • Rewrite delivery page copy#132

    Open: Emma's changes are still on her branch

  • Fix checkout button after cart edits#134

    Draft: Daniel is still working on it

So in a list with #129 and #133 Merged, #132 Open, #134 Draft and #127 Closed, only #129 and #133 have their changes in main.

Read the line next to Merged, too. A pull request can target a branch other than main, and "merged into release/2.5" is not the same as "merged into main". The pull requests list has filters such as is:merged and is:open if you need to check many at once.

  • Do
    Purple Merged + "merged into main" → the change is in main
  • Don't
    Count a red Closed pull request as delivered

    Closed means it was dropped without merging.

Merged is not the same as live

A purple Merged badge is good news, but it's a fact about the past. Before you tell anyone "it's live", ask three questions:

  1. Merged into what? The line next to the badge names the target branch. Only a merge into main heads for CatChow's site.
  2. When, and has it shipped? At CatChow a merge into main is deployed automatically within minutes. Other teams collect merges and ship them later as a release, so merged can mean "waiting for Thursday's release". Module 4 covers releases.
  3. Is it still there? Daniel's checkout redesign, pull request #97, still says Merged, although #98 reverted it ten minutes later. A revert doesn't change the old badge.
A parcel with a delivered stamp sits on a doorstep while a courier walks away carrying an identical parcel back to the van.
Merged into what, when, and is it still there?

When in doubt, skip the badges and open the file in main: if config/prices.json says 1000, the change is in. For "is it live?", check the site itself or ask the team how deployment works.

The habit protects you from the most common PM mistake on GitHub: promising customers something that was merged, then undone.

  • Do
    "#133 merged into main today, and prices.json in main says 1000."

    Badge, target and the file itself agree.

  • Don't
    "#97 says Merged, so the new checkout is live."

    It was reverted by #98 ten minutes later.

Ready to practise?

Answer the lesson's exercises and earn 250 points the first time you finish it.

Start the exercises