Git Merge, Rebase & Squash — Part 1: The Commit Graph and Every Kind of Merge

On this page
Abstract
Effective version control requires a rigorous mental model to manage parallel development histories without losing contextual boundaries. In Git, projects are fundamentally stored as a graph of commits rather than discrete file versions, which necessitates precise manipulation commands like merge, rebase, and squash. Focusing on the merge operation, the text delineates how developers can rejoin diverged branches by reshaping this commit graph. A fast-forward merge simply slides the branch pointer forward without generating a new merge commit, preserving a clean linear history while obscuring feature origins. Conversely, a three-way merge utilizes the common ancestor commit alongside both branch tips to construct a new merge commit with two parents, retaining the real history at the expense of a busier log. To control these outcomes, the --no-ff flag forces a merge commit to preserve feature boundaries, whereas --ff-only prevents accidental merges by aborting operations lacking a clean fast-forward. This article ultimately equips teams to predict branching histories and select optimal merge behavior.

This is Part 1 of a six-part series on how Git actually reorganises history. Before we can compare merge, rebase, and squash, we need one mental model that everything else hangs on:
Git doesn't store your project as file versions — it stores a graph of commits.
Every history command — merge, rebase, squash — is just a different way of reshaping that commit-history graph. In this part we build the graph model and then work through every kind of merge Git can perform: the fast-forward merge, the three-way merge, and the two flags that force Git's hand, --no-ff and --ff-only. By the end you'll be able to read a branching history, predict whether a given merge will create a merge commit, and choose the merge behaviour your team actually wants.
The mental model: a commit graph
Suppose we start from this history:
A---B---C main
We create a new branch:
git switch -c feature/login
and then develop on both branches in parallel:
feature/login (D–E) branches off C while main advances to F–G.Now we need to bring feature/login back into main. There are three main ways to do it:
- Merge — join the two lines of history together.
- Rebase — move the feature's commits so they sit on top of
main. - Squash — collapse many commits into a single one.
This part focuses on the first option, merge. Rebase and squash get their own parts later in the series (see the navigation above).
Merge
The concept
Merge means:
Take the state and history of two branches and combine them — normally without rewriting the existing commits.
The basic command:
git switch main
git merge feature/login
Merge comes in several forms, and the rest of this part walks through each one.
Fast-forward merge
The situation
Suppose main hasn't moved forward since the feature branch was created:
A---B---C main
\
D---E feature/login
When you run:
git switch main
git merge feature/login
Git simply slides the main pointer forward to E:
main simply slides forward — one linear chain, both branches point to E.No new merge commit is created. This is called a fast-forward merge.
Advantages
- History stays straight and clean.
- No unnecessary merge commit.
- Commit hashes don't change.
Disadvantages
Looking at the history later, it may not be obvious that commits D–E once formed a separate feature branch.
Forcing a fast-forward-only merge
git merge --ff-only feature/login
If a merge commit would be required, the command stops instead of merging automatically. This is a handy guard against creating an unintended history.
Three-way merge
The situation
Now both main and feature have new commits:
D---E feature/login
/
A---B---C---F---G main
When you run:
git switch main
git merge feature/login
Git uses three points to compute the merge:
- the common ancestor commit,
C; - the tip of
main, which isG; - the tip of
feature, which isE.
It then creates a new merge commit M:
main and E on the feature branch.M has two parents:
parent 1 = G
parent 2 = E
This is a three-way merge.
Advantages
- Preserves the real development history.
- Doesn't change any existing commit.
- Safe for shared branches.
- Makes it clear when the feature was merged.
Disadvantages
- The history can sprout many branch lines.
- There are lots of merge commits.
git logbecomes harder to read on large projects.
Merge with --no-ff
Even when Git could fast-forward, you can force it to create a merge commit:
git switch main
git merge --no-ff feature/login
Before the merge:
A---B---C main
\
D---E feature/login
After the merge:
--no-ff: Git records merge commit M even though a fast-forward was possible.Why use --no-ff
You want the history to record that:
DandEare part offeature/login.
This suits teams following a Git Flow-style approach:
feature/*
release/*
hotfix/*
For example:
git merge --no-ff feature/payment
Merge with --ff-only
git merge --ff-only feature/login
Meaning:
Only merge if the pointer can move straight forward, without creating a merge commit.
If the branches have diverged:
D---E
/
A---B---C---F---G
Git refuses:
fatal: Not possible to fast-forward, aborting.
This suits teams that want a linear history and don't want Git to create a merge commit by accident.
Key takeaways
- Git stores history as a graph of commits; merge, rebase, and squash are three different ways to reshape that graph.
- A fast-forward merge just slides the branch pointer forward — no merge commit, clean linear history, unchanged hashes — but the feature boundary disappears from the log.
- A three-way merge uses the common ancestor plus both branch tips to build a merge commit
Mwith two parents; it preserves real history and is safe for shared branches, at the cost of a busier log. - Use
--no-ffto force a merge commit and keep the feature boundary visible (Git Flow-style); use--ff-onlyto refuse any merge that isn't a clean fast-forward and keep history strictly linear.