Merging: fast-forward, merge commit, squash
Intermediate Git CLI VS Code UI IntelliJ UI GitLab UI GitHub UI
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.
$ git switch main
$ git merge docs/12-windows-install-stepsUpdating 1a01760..5f181fc
Fast-forward
docs/getting-started.md | 4 ++++
1 file changed, 4 insertions(+)$ 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 trailguideBoth 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.
$ git merge --no-ff docs/33-faq-windowsMerge made by the 'ort' strategy.
docs/faq.md | 4 ++++
1 file changed, 4 insertions(+)$ 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.
$ git merge --squash feature/21-difficulty-filterUpdating 87c5ee6..059253c
Fast-forward
Squash commit -- not updating HEAD
docs/filter.md | 3 +++
1 file changed, 3 insertions(+)
create mode 100644 docs/filter.mdNote the last line of the message: not updating HEAD. Unlike the other two, --squash does not commit for you. It leaves the change staged:
$ git status -sA docs/filter.mdYou 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.
$ 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
$ git switch main
$ git pull
$ git merge docs/33-faq-windowsMerging 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.
- Switch to the receiving branch from the status bar, and pull.
- Command Palette (Ctrl+Shift+P / ⇧⌘P) → Git: Merge Branch…, then pick the branch to merge in.
- VS Code has no menu entry for
--no-ffor--squash; use the integrated terminal for those. - On a conflict the merge editor opens; Git: Abort Merge cancels the whole thing.
- Switch to the receiving branch (status bar, bottom right) and Update Project.
- Open the branches popup, point at the branch to merge in, and choose Merge into current.
- The same menu offers Merge into current (--no-ff) and, in recent versions, Squash into current.
- Conflicts open the three-way merge tool; Git → Abort Merge cancels.
You rarely merge by hand: the Merge button on a merge request does it on the server. Which method it uses is a project setting (Settings → Merge requests → Merge method):
- Merge commit — always a merge commit, the
--no-ffbehaviour. - Merge commit with semi-linear history — rebases the branch first, then a merge commit.
- Fast-forward merge — no merge commit; the branch must be up to date, so GitLab asks you to rebase first.
Separately, Squash commits when merge request is accepted turns the whole branch into one commit; the message is editable in the merge request before you press Merge. Ask which combination your project uses, or read it in that settings page.
The Merge pull request button has a dropdown with the three options, which repository administrators can enable or disable in Settings → General → Pull Requests:
- Create a merge commit — the
--no-ffbehaviour. - Squash and merge — one commit on
main, message editable in the dialog. - Rebase and merge — replays the branch's commits onto
mainwith new hashes and no merge commit (lesson 6.7).
The button remembers the last method used in the repository, so read it before clicking.
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
maininto 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 --abortreturns 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 statusbefore merging; if it happened, lesson 12.7 fixes it. git branch -drefuses after a squash merge. Expected:maindoes not contain those exact commits. Confirm the work is inmain, then use-D.- Expecting a merge commit and getting a fast-forward. Nothing had moved on
main. Use--no-ffif your team wants the mark. --squashleaving 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.
- From
main, createdocs/practice-ff, make one commit, switch back tomain, and merge it. Note the word in the output. - Create
docs/practice-noff, make one commit, switch back, and merge with--no-ff. Compare the graph. - Create
docs/practice-squash, make two commits, switch back, rungit merge --squash docs/practice-squash, thengit status -sandgit commit -m "docs: squashed practice". - Run
git log --oneline --graph --decorate -8and identify each of the three. - 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.