Skip to content

Fast-Forward vs Three-Way Merge in Git

Lesson 2 of 7Intermediate8 min readModern Git Workflows · MergingVerified: Git 2.43.0 on Ubuntu 24.04; every command and output in this lesson was run

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.

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:

Terminal window
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.

A---B---C---D
↑ ↑
main feature

main points at B. feature points at D. B is an ancestor of D, so main has nothing feature lacks.

Before: main is an ancestor of feature

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.

ABCDmainfeature

Merging moves the label:

Terminal window
git switch main
git merge feature
Updating 1ad6ec6..e811fc1
Fast-forward
app.txt | 1 +
1 file changed, 1 insertion(+)
After: the ref moved; no commit was created

The same line of commits A, B, C and D, now with both the main and feature labels attached to commit D.

ABCDmain, featureNothing was created. The main ref was rewritten to name commit D.

The word “Updating” in the output is literal: Git updated a ref file. Confirm the tip has a single parent:

Terminal window
git cat-file -p HEAD | grep -c parent
1

What 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.

A---B---C ← main
\
D---E ← feature

Now 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.

Before: both branches have unique commits

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.

ABCDEmainfeatureMerge base is B. Git compares B→C and B→E.
Terminal window
git switch main
git merge feature

Git creates a commit with two parents:

After: a merge commit joins both histories

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.

ABCMDEmainfeatureM's first parent is C (where you were); its second is E (what you merged in).
Terminal window
git cat-file -p HEAD | grep parent
parent 5f49aabec7815dc0cb237a91e678d27aa6b55606
parent abe666692988d8a2bed78d6a092fb9f1da4bd72c

What 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.

FlagFast-forward possibleDiverged
--ff (default)Fast-forwardMerge commit
--no-ffMerge commit anywayMerge commit
--ff-onlyFast-forwardRefuse

Forces an integration point even when the history is linear:

Terminal window
git merge --no-ff feature
--no-ff on linear history: a merge commit is created regardless

A 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.

ABMCDmainfeatureThe files are identical to the fast-forward result. Only the shape differs.

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”.

A safety flag. It refuses anything that is not trivial:

Terminal window
git merge --ff-only origin/main
hint: 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.

There is a case that is neither, and it confuses people who expect every merge to do something:

Terminal window
git merge feature
Already 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 fetch first.

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:

Terminal window
git log --oneline --first-parent main

Following 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:

Terminal window
git log --oneline --no-merges main

Every real change, with the integration commits filtered out.

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:

Terminal window
git bisect start --first-parent

which 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.

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.

Prefer fast-forwardPrefer --no-ff
History shapeLinear, easy to readBranch structure preserved
git bisectStraightforwardWorks, but must traverse merges
AuditingNo record a branch existedExplicit integration point
Reverting a whole changeRevert each commitOne git revert -m 1
Commit volume on mainLowerOne extra commit per branch
SuitsShort branches, small changesFeature 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.

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.

“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.

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.

  • Fast-forward is possible exactly when the target branch is an ancestor of the branch being merged.
  • git merge-base --is-ancestor tests 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-ff forces a merge commit; --ff-only refuses to do anything else.
  • pull.ff only surfaces unexpected divergence instead of silently merging.
  • Platform merge buttons choose for you, per repository configuration.
  1. Create a repository with one commit on main.
  2. Create ff-demo, add two commits, then switch back to main.
  3. Run the ancestry test:
    Terminal window
    git merge-base --is-ancestor main ff-demo && echo "can fast-forward"
  4. Merge. Confirm the output says Fast-forward and git cat-file -p HEAD | grep -c parent prints 1.
  5. Reset back: git reset --hard HEAD~2.
  6. Merge again with --no-ff. Predict: how many parents now?
  7. Compare git log --oneline --graph between the two runs.
  8. Finally, create a branch from HEAD~1, commit on both it and main, and run the ancestry test again. It should now fail — confirm with git 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.

Merge commits are the only commits with more than one parent, and that single structural difference has consequences for logs, reverts and reviews.