Fast-Forward vs Three-Way Merge in Git
Git chooses between a fast-forward and a three-way merge based on one question: is the branch you are merging into an ancestor of the branch you are merging in?
If yes, there is nothing to reconcile and Git moves the ref forward. If no, the histories have diverged and Git must combine them, producing a merge commit. Ancestry decides — not the size of the change, not how many commits are involved, not your preference.
The deciding condition
Section titled “The deciding condition”A fast-forward is possible when the target branch’s tip is the merge base. That is another way of saying the target has no commits the other branch lacks.
You can test it directly before merging:
git merge-base --is-ancestor main feature \ && echo "fast-forward possible" \ || echo "diverged — a merge commit is required"What it doesExits successfully if the first commit is an ancestor of the second, and with a non-zero status otherwise. It prints nothing.
Why we run itIt answers “can this fast-forward?” without performing the merge, which is useful in scripts and before deciding how to integrate.
Expected resultNo output. Use the exit status — the shell idiom below makes it visible.
Fast-forward
Section titled “Fast-forward”A---B---C---D ↑ ↑ main featuremain points at B. feature points at D. B is an ancestor of D, so main has nothing feature
lacks.
A single line of commits A, B, C and D. The label main is attached to commit B and the label feature to commit D. Commits C and D descend from B, so main is an ancestor of feature.
Merging moves the label:
git switch maingit merge featureUpdating 1ad6ec6..e811fc1Fast-forward app.txt | 1 + 1 file changed, 1 insertion(+)The same line of commits A, B, C and D, now with both the main and feature labels attached to commit D.
The word “Updating” in the output is literal: Git updated a ref file. Confirm the tip has a single parent:
git cat-file -p HEAD | grep -c parent1What you gain: a perfectly linear history. git log reads as a straight sequence.
What you lose: any record that a branch existed. Commits C and D are indistinguishable from
commits made directly on main.
Three-way merge
Section titled “Three-way merge”A---B---C ← main \ D---E ← featureNow main has C, which feature does not. feature has D and E, which main does not. Neither is
an ancestor of the other, so the merge base is B and Git must reconcile.
A trunk lane labelled main with commits A, B and C. A branch lane labelled feature leaves main after commit B with commits D and E. Neither branch tip is an ancestor of the other.
git switch maingit merge featureGit creates a commit with two parents:
A trunk lane labelled main with commits A, B, C and a merge commit M. A branch lane labelled feature leaves main after commit B with commits D and E, which merges into M. The merge commit M has two parents, C and E.
git cat-file -p HEAD | grep parentparent 5f49aabec7815dc0cb237a91e678d27aa6b55606parent abe666692988d8a2bed78d6a092fb9f1da4bd72cWhat you gain: the branch is visible in the history, with a record of when it was integrated.
What you lose: linearity. git log now shows commits from both lines interleaved by date unless you
ask for --first-parent.
The three flags
Section titled “The three flags”| Flag | Fast-forward possible | Diverged |
|---|---|---|
--ff (default) | Fast-forward | Merge commit |
--no-ff | Merge commit anyway | Merge commit |
--ff-only | Fast-forward | Refuse |
--no-ff
Section titled “--no-ff”Forces an integration point even when the history is linear:
git merge --no-ff featureA trunk lane labelled main with commits A, B and a merge commit M. A branch lane labelled feature leaves main after commit B with commits C and D, merging into M. Even though a fast-forward was possible, a merge commit was created.
Teams adopt this so that every branch appears as a unit in main’s history. It makes
git log --first-parent main a list of integrations, and it makes reverting a whole branch a single
git revert -m 1.
The cost is a merge commit per branch, which on a busy repository is a lot of commits whose only content is “these two lines of development joined here”.
--ff-only
Section titled “--ff-only”A safety flag. It refuses anything that is not trivial:
git merge --ff-only origin/mainhint: Diverging branches can't be fast-forwarded, you need to either:hint:hint: git merge --no-ff...fatal: Not possible to fast-forward, aborting.This is what you want when updating a local branch from its upstream. If your main has diverged from
origin/main, that is information you want surfaced rather than silently resolved with a merge commit.
The third outcome: already up to date
Section titled “The third outcome: already up to date”There is a case that is neither, and it confuses people who expect every merge to do something:
git merge featureAlready up to date.This means the branch you are merging is already an ancestor of yours — the reverse of the fast-forward
condition. Everything on feature is already reachable from main, so there is nothing to bring in.
It is common and usually correct. It is also what you see if you merge a branch twice, or if someone else already integrated it. If you expected changes and got this message, the likely explanations are:
- The branch was already merged, possibly by someone else.
- You are on the wrong branch — check
git status. - You are merging a stale remote-tracking branch and need
git fetchfirst.
Reading history afterwards
Section titled “Reading history afterwards”The shape you chose determines how history reads, and there are commands for both preferences.
On a history full of merge commits, the default git log interleaves commits from every branch by date,
which is rarely what you want:
git log --oneline --first-parent mainFollowing only the first parent of each merge gives one entry per integration — effectively “what landed
on main, in order”, ignoring the internal commits of each branch. On a --no-ff history this reads very
much like a squash-merged history, while retaining the detail underneath for when you need it.
The inverse is also useful:
git log --oneline --no-merges mainEvery real change, with the integration commits filtered out.
Bisecting
Section titled “Bisecting”git bisect works on both shapes, but they behave differently. On a linear history it steps through
commits one at a time, and every commit is one someone intended to be complete.
On a merged history it also traverses commits inside branches, which may be intermediate states that do
not build. The usual mitigations are git bisect skip when a commit will not build, or:
git bisect start --first-parentwhich restricts the search to the mainline, identifying the merge that introduced a problem rather than the individual commit inside it. That is often the more useful answer anyway, since it names the branch responsible.
Interaction with protection rules
Section titled “Interaction with protection rules”Hosting platforms can require a linear history on a branch. That setting rejects any push that adds a
merge commit, which in effect forbids --no-ff and forces every integration to be a fast-forward, a
squash or a rebase.
If your team wants merge commits as integration records, do not enable it. If your team wants a strictly
linear main, enabling it is how you make that structural rather than a convention people forget.
The related setting — requiring branches to be up to date before merging — pushes work in the opposite direction. It guarantees that a branch has been brought current before it lands, which makes fast-forwarding possible more often and means CI tested the exact combination being merged.
Choosing between them
Section titled “Choosing between them”| Prefer fast-forward | Prefer --no-ff | |
|---|---|---|
| History shape | Linear, easy to read | Branch structure preserved |
git bisect | Straightforward | Works, but must traverse merges |
| Auditing | No record a branch existed | Explicit integration point |
| Reverting a whole change | Revert each commit | One git revert -m 1 |
Commit volume on main | Lower | One extra commit per branch |
| Suits | Short branches, small changes | Feature branches as reviewable units |
Common team positions:
Fast-forward where possible. Often combined with rebasing branches before integration, producing a
fully linear main. Suits teams that value git log main reading as a simple sequence.
Always --no-ff. Every branch is visible. Suits teams that want to see integration boundaries, and
those in regulated contexts where “when was this integrated, and by whom” matters.
Squash instead. A third option that sidesteps the question: one commit per branch, no merge commit, linear history. Squash Merging covers the trade-offs.
What the hosting platform does
Section titled “What the hosting platform does”Platform merge buttons make this choice for you, and it is worth knowing which.
GitHub’s “Create a merge commit” option uses --no-ff, so it always produces a merge commit even when the
branch could have fast-forwarded. Its “Squash and merge” and “Rebase and merge” options produce no merge
commit at all, by different means.
Which options are available is a repository setting. If your main is full of merge commits you did not
expect — or conspicuously lacks them — the repository’s merge-method configuration is the place to look,
not your local Git config.
Common mistakes
Section titled “Common mistakes”“My merge did not create a merge commit, so it failed.” A fast-forward is a successful merge. Check
git log — the changes are there.
Expecting --no-ff to change the files. It changes only the shape of the history. The tree is
identical.
Using --ff-only on a branch you know has diverged and treating the refusal as a bug. It is doing its
job; you now choose merge or rebase.
Assuming a linear history means no branches were used. Branches that fast-forwarded, were rebased, or were squashed all leave a linear history. The absence of merge commits says nothing about how the work was developed.
Configuring pull.rebase and pull.ff without understanding the interaction. pull.rebase true
takes precedence — the fast-forward setting only applies when not rebasing. Pick one deliberately.
Mental Model
Section titled “Mental Model”Fast-forward is what happens when there is nothing to merge.
If your branch has not moved since the other one branched off, “combining” the two is just agreeing that the other one is further along. Git moves the label and stops.
A merge commit is only necessary when both sides have moved, because then something genuinely has to reconcile them — and the commit records that reconciliation.
What You Learned
Section titled “What You Learned”- Fast-forward is possible exactly when the target branch is an ancestor of the branch being merged.
git merge-base --is-ancestortests this in advance.- A fast-forward creates no commit; it rewrites a ref.
- A three-way merge creates one commit with two parents, first parent being where you were.
--no-ffforces a merge commit;--ff-onlyrefuses to do anything else.pull.ff onlysurfaces unexpected divergence instead of silently merging.- Platform merge buttons choose for you, per repository configuration.
Try It Yourself
Section titled “Try It Yourself”- Create a repository with one commit on
main. - Create
ff-demo, add two commits, then switch back tomain. - Run the ancestry test:
Terminal window git merge-base --is-ancestor main ff-demo && echo "can fast-forward" - Merge. Confirm the output says
Fast-forwardandgit cat-file -p HEAD | grep -c parentprints1. - Reset back:
git reset --hard HEAD~2. - Merge again with
--no-ff. Predict: how many parents now? - Compare
git log --oneline --graphbetween the two runs. - Finally, create a branch from
HEAD~1, commit on both it andmain, and run the ancestry test again. It should now fail — confirm withgit merge --ff-only.
Step 5 is safe here because the repository is disposable and nothing is uncommitted. Do not reach for
reset --hard casually outside a lab.
Next Lesson
Section titled “Next Lesson”Merge commits are the only commits with more than one parent, and that single structural difference has consequences for logs, reverts and reviews.