Git Course 0%

Merging: fast-forward, merge commit, squash

Intermediate Git CLI VS Code UI IntelliJ UI GitLab UI GitHub UI ≈ 14 min

What you will learn

  • Why some merges create a commit and others do not
  • What --no-ff and --squash change, and what the history looks like afterwards
  • Which merge method the merge request button on GitLab and GitHub performs

After this lesson you can

  • I can read a branch graph and say which merge method produced it
  • I know what my team's merge button will do to my branch's commits

Why this matters

Merging is how work leaves a branch and becomes part of main. In practice you will click a button on a merge request rather than type the command, but that button offers two or three choices, and each one leaves a different history behind. Knowing which is which tells you why your five commits sometimes appear in main and sometimes collapse into one.

Fast-forward: no merge at all

When main has gained nothing since your branch started, there is nothing to combine. Git simply slides the main label forward to your branch's newest commit.

Fast-forward merge Before: main points at C3; the branch docs/12 has two more commits C4 and C5 after C3. After git merge docs/12 on main: no new commit; the main label simply moves to C5, where docs/12 already is. BEFORE AFTER: git merge docs/12 (on main) C3 C4 C5 main docs/12 main has nothing docs/12 lacks C3 C4 C5 main label slides forward docs/12 no merge commit, history stays a straight line
A fast-forward moves the label; no commit is created and no conflict is possible.
Terminal
$ git switch main
$ git merge docs/12-windows-install-steps
Updating 1a01760..5f181fc
Fast-forward
 docs/getting-started.md | 4 ++++
 1 file changed, 4 insertions(+)
Terminal
$ git log --oneline --graph --decorate -3
* 5f181fc (HEAD -> main, origin/docs/12-windows-install-steps, docs/12-windows-install-steps) docs: add Windows steps to installation guide (#12)
* 1a01760 (origin/main, origin/HEAD) feat: add Coast Walk trail (#29)
* 8fa922e (tag: v1.2.0) Initial import of trailguide

Both labels now sit on the same commit, and the history is a straight line. The word "Fast-forward" in the output is how you recognize it.

Merge commit: joining two lines

When both branches moved, Git combines the two snapshots and records the result as a commit with two parents.

Merge commit Before: from C3, main advanced to C6 while the branch docs/12 advanced to C4 and C5. After git merge docs/12 on main: a new merge commit M with two parents, C6 and C5; main points at M; docs/12 still points at C5. BEFORE: both moved on AFTER: git merge docs/12 (on main) C3 C6 C4 C5 main docs/12 C3 C6 C4 C5 M main docs/12 M has two parents: "Merge branch 'docs/12'"
A merge commit has two parents and is the only kind of commit that joins two lines of work.
Terminal
$ git merge --no-ff docs/33-faq-windows
Merge made by the 'ort' strategy.
 docs/faq.md | 4 ++++
 1 file changed, 4 insertions(+)
Terminal
$ git log --oneline --graph --decorate -4
*   87c5ee6 (HEAD -> main) Merge branch 'docs/33-faq-windows'
|\  
| * 3008743 (docs/33-faq-windows) docs: add Windows question to FAQ (#33)
|/  
* 5f181fc (origin/docs/12-windows-install-steps, docs/12-windows-install-steps) docs: add Windows steps to installation guide (#12)
* 1a01760 (origin/main, origin/HEAD) feat: add Coast Walk trail (#29)

The |\ opens the fork and the |/ closes it: two lines that separated and rejoined. "ort" is the name of the algorithm Git used; it appears in every merge and means nothing you need to act on.

--no-ff forces a merge commit even when a fast-forward was possible, which is why this example has one although only the branch had moved. Many teams set it as the rule for main precisely so that every merge request leaves a visible mark in the history saying "this group of commits arrived together".

Squash: one commit for the whole branch

Squashing takes everything the branch changed and stages it as a single change, discarding the branch's individual commits.

Squash merge Before: main at C6; the branch feature/21 has three small commits C4, C5 and C7 branching from C3. After git merge --squash and a commit: main gets one new commit S containing all the branch's changes together; the branch commits are not part of main's history and the branch is usually deleted. BEFORE: three "wip" commits AFTER: merge --squash, then commit C3 C6 C4 C5 C7 main feature/21 C3 C6 S C4 C5 C7 main branch commits stay outside main S is one commit, "feat: add difficulty filter (#21)", containing all three changes
A squash merge puts the branch's whole change into one new commit on main; the branch's own commits stay outside.
Terminal
$ git merge --squash feature/21-difficulty-filter
Updating 87c5ee6..059253c
Fast-forward
Squash commit -- not updating HEAD
 docs/filter.md | 3 +++
 1 file changed, 3 insertions(+)
 create mode 100644 docs/filter.md

Note the last line of the message: not updating HEAD. Unlike the other two, --squash does not commit for you. It leaves the change staged:

Terminal
$ git status -s
A  docs/filter.md

You write the message yourself, which is the point: the branch's three commits were "wip", "wip 2" and a real one, and main should record one sentence instead.

Terminal
$ git commit -m "feat: add difficulty filter docs (#21)"
$ git log --oneline --graph --decorate -3
* b6ae751 (HEAD -> main) feat: add difficulty filter docs (#21)
*   87c5ee6 Merge branch 'docs/33-faq-windows'
|\  
| * 3008743 (docs/33-faq-windows) docs: add Windows question to FAQ (#33)
|/  

The branch's commits are not in main's history at all, which has one practical consequence: Git no longer sees the branch as merged, so git branch -d refuses it and you need -D (lesson 6.8).

The three side by side

Fast-forward Merge commit Squash
Command git merge <branch> when possible git merge --no-ff <branch> git merge --squash <branch> then commit
New commit on main none one, with two parents one, with one parent
Branch's commits in main yes, as they were yes, as they were no, replaced by one
History shape straight line visible fork and join straight line
Good for trivial or private branches keeping the record of what arrived together branches with messy work-in-progress commits
Afterwards git branch -d works works refuses; use -D

How to do it

Terminal
$ git switch main
$ git pull
$ git merge docs/33-faq-windows

Merging happens on the receiving branch: switch to main first, update it, then merge the other branch in. Add --no-ff to force a merge commit, or --squash (followed by git commit) to flatten. If the merge stops with a conflict, Section 11 takes over; git merge --abort always gets you back.

Do and don't

Do

  • Merge into the branch that receives the work: switch to main, then merge the feature branch.
  • Update main (git pull) immediately before merging, so you merge into the current state.
  • Let the merge request button do it on shared branches; it records who merged and when.

Don't

  • Don't merge main into a branch and call it merging the branch; that direction is lesson 6.4.
  • Don't squash a branch whose individual commits are the point, such as a series of carefully separated refactors.
  • Don't panic at a conflict; git merge --abort returns everything to the state before the merge.

Common mistakes

  • Merging while on the wrong branch. Everything lands in the branch you were on. Read the first line of git status before merging; if it happened, lesson 12.7 fixes it.
  • git branch -d refuses after a squash merge. Expected: main does not contain those exact commits. Confirm the work is in main, then use -D.
  • Expecting a merge commit and getting a fast-forward. Nothing had moved on main. Use --no-ff if your team wants the mark.
  • --squash leaving you with staged changes and no commit. That is by design; write the message and commit.

Try it yourself

Goal: produce all three histories and recognize them in the graph.

  1. From main, create docs/practice-ff, make one commit, switch back to main, and merge it. Note the word in the output.
  2. Create docs/practice-noff, make one commit, switch back, and merge with --no-ff. Compare the graph.
  3. Create docs/practice-squash, make two commits, switch back, run git merge --squash docs/practice-squash, then git status -s and git commit -m "docs: squashed practice".
  4. Run git log --oneline --graph --decorate -8 and identify each of the three.
  5. Delete the three branches; note which one needs -D.

Expected result: step 1 says Fast-forward; step 2 says Merge made by the 'ort' strategy and the graph forks; step 3 stages the changes without committing; the squashed branch is the one that needs git branch -D.

Show solution

The graph shows a straight segment (fast-forward), a fork and join (merge commit), and another straight segment whose single commit contains two commits' worth of change (squash). git branch -d docs/practice-squash refuses because those two commits are not in main; the change is, but Git compares commits, not content.

Check yourself

1. git merge printed "Fast-forward". What does that tell you?
2. Your team squashes on merge. Your branch has six commits. What appears in main?
3. git merge --squash my-branch finished and git status shows staged changes. What now?

Key terms

Merge Fast-forward Squash Merge request (MR) main Branch