Fast-Forward vs Three-Way Merges

When you run git merge, Git doesn’t always create a new commit. Depending on how the two branches’ histories relate to each other, Git will either fast-forward — simply slide a branch pointer forward — or perform a three-way merge that weaves both histories together into a brand-new merge commit. Understanding which one you’re going to get, and how to control it, is essential for keeping your project history readable and for avoiding surprises when a merge you expected to be trivial suddenly asks you to resolve conflicts.

Overview / How it works

Every commit in Git is an object identified by a hash. A commit object points to a tree — a snapshot of the project’s directory structure — and the tree points to blobs, which store file contents, plus other trees for subdirectories. A commit also stores one or more parent pointers: a normal commit has one parent, the very first commit has none, and a merge commit has two (or more) parents. A branch like main is nothing more than a small file containing a commit hash — a lightweight, movable pointer. HEAD usually points at a branch (which in turn points at a commit); when HEAD points directly at a commit instead, you’re in “detached HEAD” state.

When you run git merge "<branch>" while checked out on, say, main, Git first finds the merge base — the most recent commit that is an ancestor of both branch tips — using the same graph-traversal logic as git merge-base. What happens next depends entirely on where that merge base sits.

Fast-forward merge: if the merge base is the same commit as the tip of the branch you’re on (in other words, main hasn’t moved since feature/login-page branched off it, so every commit on main is already contained in the feature branch), there is nothing to combine. Git simply moves the main pointer forward to match the tip of feature/login-page, updates the index and working tree to match that commit’s tree, and stops. No new commit object is created — the history stays perfectly linear, as if the feature branch’s commits had been made directly on main all along.

Three-way merge: if both branches have new commits since the merge base — main moved and feature/login-page moved — a fast-forward is impossible, because sliding the pointer forward would silently discard main’s own commits. Instead Git performs a genuine three-way merge: it compares three snapshots (the common ancestor, the tip of the current branch, and the tip of the branch being merged), computes what changed on each side relative to the ancestor, and combines both sets of changes into a new tree. Where the two sides changed different parts of a file (or different files entirely), Git merges them automatically. Where both sides changed the same lines, Git can’t guess which version you want and leaves conflict markers in the file for you to resolve by hand. Once the resulting tree is written, Git creates a brand-new commit object with two parents — the previous tips of both branches — and moves the current branch pointer to that new merge commit. Modern Git (2.34+) performs this using the ort merge strategy by default, a faster and more correct replacement for the older recursive strategy; you’ll see “Merge made by the ‘ort’ strategy.” in the output.

Syntax

git merge "<branch>"
git merge --no-ff "<branch>"
git merge --ff-only "<branch>"
git merge --squash "<branch>"
git merge --abort
Flag Meaning
--ff Default behavior: fast-forward when possible, otherwise create a merge commit.
--no-ff Always create a merge commit, even when a fast-forward is possible. Preserves the fact that a feature branch existed.
--ff-only Only merge if a fast-forward is possible; abort with an error otherwise. Useful in scripts and CI where you want to guarantee linear history.
-m "<message>" Supplies the merge commit message directly instead of opening an editor.
--squash Applies the other branch’s changes to your working tree and index as one set of changes, but does not commit and does not record parentage — you commit it yourself as a single plain commit.
--abort Cancels an in-progress merge (for example, after a conflict) and restores the pre-merge state.

Examples

Example 1: A clean fast-forward merge

Start a feature branch from main, commit some work on it, and merge it back while main hasn’t changed in the meantime.

git switch -c feature/login-page
echo "console.log('login form rendered');" >> login.js
git add login.js
git commit -m "feat: add login page skeleton"
git switch main
git merge feature/login-page

Output:

Updating 8a1f3c2..d94e7b1
Fast-forward
 login.js | 1 +
 1 file changed, 1 insertion(+)

Because main was still sitting exactly at the commit feature/login-page branched from, Git didn’t need to combine anything — it just moved the main pointer up to d94e7b1 and updated the working tree. No merge commit appears in the log; it looks exactly as if you had committed directly on main.

Example 2: A three-way merge when both branches moved

This time, add a commit to main while the feature branch keeps progressing, so the two branches diverge.

git switch main
echo "# Changelog" > CHANGELOG.md
git add CHANGELOG.md
git commit -m "docs: add changelog file"
git switch feature/login-page
echo "console.log('validate login fields');" >> login.js
git add login.js
git commit -m "feat: add login field validation"
git switch main
git merge feature/login-page

Output:

Merge made by the 'ort' strategy.
 login.js | 1 +
 1 file changed, 1 insertion(+)

A fast-forward wasn’t possible because main now has a commit (docs: add changelog file) that feature/login-page doesn’t have. Git located the merge base, combined the changelog addition from main with the validation code from the feature branch, and created a brand-new merge commit with two parents. Run git log --oneline --graph afterward and you’ll see the two branches join back together.

Example 3: Forcing merge behavior with –no-ff and –ff-only

Sometimes you want a merge commit even when a fast-forward would be possible, to keep a visible record that a feature branch existed. Other times you want the opposite guarantee — that a merge only proceeds if it’s a pure fast-forward.

git switch main
git merge --no-ff feature/hotfix-typo -m "merge: bring in hotfix-typo"

Output:

Merge made by the 'ort' strategy.
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

Even though main hadn’t moved and a fast-forward was available, --no-ff forced Git to create a merge commit anyway, preserving the branch as a visible unit in history. Now try the opposite request on a branch that has diverged:

git switch main
git merge --ff-only feature/login-page

Output:

fatal: Not possible to fast-forward, aborting.

Because the two branches had diverged, Git refuses to merge at all under --ff-only rather than silently creating a merge commit. This flag is popular in CI pipelines that enforce linear history: if the merge can’t fast-forward, the pipeline fails and asks the author to rebase instead.

How it works step by step

  1. Git computes the merge base with the same algorithm as git merge-base main feature/login-page — it walks both branches’ ancestry graphs backward from their tips until it finds the best common ancestor commit.
  2. If that ancestor equals the current branch’s tip (fast-forward case): Git reads the tree object of the target commit, updates every blob in the working tree and the index to match that tree, and rewrites the main ref file to point at the target commit’s hash. No commit object is created, no parent list changes — you’ve just relabeled which commit main means.
  3. If the branches have diverged (three-way case): Git computes the diff from the ancestor to your current tip (“ours”) and the diff from the ancestor to the other tip (“theirs”). For every path, if only one side changed it, that change is applied; if both sides changed it identically, it’s applied once; if both sides changed it differently, Git writes conflict markers into the working tree file and leaves that path unmerged in the index instead of resolving it.
  4. If there were no conflicts, Git writes the resulting file states as new blob and tree objects, creates a new commit object whose parent list is [previous main tip, feature branch tip], and moves main to point at that new commit.
  5. If there were conflicts, Git stops mid-merge: the working tree contains the conflict markers, the index has “unmerged” entries for the conflicted paths, and MERGE_HEAD is set to the incoming commit. You resolve the markers by hand, git add the fixed files, and run git commit (no message needed — Git pre-fills one) to complete the merge commit. git merge --abort at this point throws everything away and restores the pre-merge state.

Common Mistakes

Mistake 1: Expecting a merge commit but silently getting a fast-forward

A team that wants every feature to leave a visible “bump” in history sometimes forgets that a plain git merge feature/x will fast-forward silently if main hasn’t moved. The feature’s commits get folded into main‘s linear history with no trace that they came from a branch.

git switch main
git merge feature/small-fix

Fix: use git merge --no-ff feature/small-fix whenever you want the branch’s existence recorded, or configure it as the team default with git config merge.ff false.

Mistake 2: Force-pushing after a merge conflict resolution went wrong

After resolving a messy three-way merge conflict on a shared branch, it’s tempting to fix it up with git reset --hard and push over the top.

git reset --hard HEAD~1
git push --force origin main

A bare --force push overwrites whatever is on the remote, even if a teammate pushed new commits since you last fetched — those commits vanish for everyone. Fix: use git push --force-with-lease origin main, which refuses to push if the remote has commits you haven’t seen, or better, commit a new fix on top instead of rewriting history at all.

Mistake 3: Committing with conflict markers still in the file

After a conflicted three-way merge, it’s easy to resolve most of the file, forget one marker block, and commit anyway.

git add login.js
git commit
<<<<<<< HEAD
function validateLogin() { return true; }
=======
function validateLogin() { return isValid; }
>>>>>>> feature/login-page

Git happily commits this — it has no idea <<<<<<< isn’t intentional code — and now broken syntax lands on main. Fix: always re-read the full file (or run your test suite and linter) after resolving conflicts, before staging and committing, and search for <<<<<<< as a final check.

Best Practices

  • Use git log --oneline --graph --all before merging to see whether the merge will be a fast-forward or will need to combine diverged history.
  • On shared or collaborative branches, prefer --no-ff (or your GitHub pull request’s “Create a merge commit” option) so the log keeps a record that a feature branch existed, especially when branches map to tickets people search for later.
  • On solo or throwaway branches where you don’t care about preserving branch structure, a plain fast-forward keeps history simple and linear.
  • Prefer --ff-only in automated scripts or release tooling so an unexpected diverged merge fails loudly instead of quietly creating an unreviewed merge commit.
  • Always prefer git push --force-with-lease over bare --force when you must overwrite remote history.
  • Never rebase or force-push a branch that others have already pulled or built work on top of — that’s the golden rule of history rewriting.
  • Run your build and tests immediately after resolving a merge conflict, before committing, to catch stray conflict markers or logic mistakes.

Practice Exercises

  1. Create a new repository, make an initial commit, branch off with git switch -c feature/nav-bar, commit a change there, then switch back to main and merge. Confirm the output reports Fast-forward and that git log --oneline --graph shows a single straight line.
  2. Starting from the same setup, add a new commit directly on main before merging feature/nav-bar. Merge again and confirm you now get a “Merge made by the ‘ort’ strategy” message with a two-parent commit. Try editing the same line on both branches first, so the merge produces a real conflict, then resolve it and complete the commit.
  3. Repeat exercise 1, but this time merge with git merge --ff-only feature/nav-bar after diverging main on purpose (as in exercise 2). Confirm the merge is refused, then fix it by rebasing the feature branch onto main with git rebase main before retrying the fast-forward-only merge.

Summary

  • A branch is just a movable pointer to a commit; a commit points to a tree snapshot and has one parent normally, or two for a merge commit.
  • Git finds the merge base — the common ancestor — before deciding how to merge.
  • If the current branch’s tip is the merge base, Git fast-forwards: it just moves the pointer, and no new commit is created.
  • If both branches added commits since the merge base, Git performs a three-way merge, combining the ancestor, “ours,” and “theirs” snapshots into a new commit with two parents.
  • --no-ff forces a merge commit even when a fast-forward is possible; --ff-only refuses to merge unless a fast-forward is possible.
  • Resolve conflicts by editing the marked file, running git add, and completing the commit — or bail out entirely with git merge --abort.
  • Prefer --force-with-lease over bare --force, and never rewrite the history of a branch others have already pulled.