· 4 min read

Fast-forward vs Three-way Merge in Git: What `git merge` Actually Does

Why does `git merge` sometimes quietly move a pointer and other times create a new commit? Walk through both cases fast-forward and three-way (ort) merge — step by step, with diagrams.

Most students learn git merge as a single command. It isn’t. Git silently picks between two very different behaviors depending on the shape of your history and which one runs decides whether you get a clean linear log or a new merge commit with two parents.

This post walks through both, step by step, with the branch diagram redrawn at every step. By the end you should be able to predict which behavior Git will pick before you hit enter.

TL;DR

Fast-forward mergeThree-way merge (ort)
When it runsTarget branch hasn’t moved since you branchedBoth branches have new commits
New commit?No -> just moves the branch pointerYes —> a merge commit with two parents
History shapeLinearA loop that joins back together
Strategy usedn/a (no merge to compute)ort (Git’s default since 2.34)

Case 1: Fast-forward merge

Start with two commits on main. main points at C2.

    C1 ── C2          ← main

Create a feature branch and check it out. Both branches now point at the same commit.

git checkout -b feature
    C1 ── C2          ← main, feature

Make one commit on feature. Now feature is one commit ahead of main.

    C1 ── C2          ← main
            \
             C3       ← feature

Switch back to main and merge:

git checkout main
git merge feature

Here’s the question Git asks itself: is main an ancestor of feature? It is — C2 sits on feature’s direct line back to the root. So Git doesn’t need to invent a new commit; it just slides the main pointer forward to C3.

    C1 ── C2 ── C3    ← main, feature

That’s a fast-forward. No new commit. No merge commit. The log looks like you committed C3 straight onto main.

Case 2: Three-way merge (the ort strategy)

Same setup, but this time main also gets a commit while you were working on feature.

    C1 ── C2 ── C4    ← main
            \
             C3       ← feature

Now run the same merge:

git checkout main
git merge feature

Git asks the same question: is main an ancestor of feature? No — C4 lives on main and isn’t anywhere on feature’s line. Neither branch is “behind” the other; they’ve diverged. A pointer move can’t capture that.

So Git does a three-way merge: it finds the common ancestor (C2), looks at what changed on each side since then, combines those changes, and packages the result into a brand-new merge commit that has two parents.

    C1 ── C2 ── C4 ── M    ← main
            \        /
             C3 ────         ← feature (still at C3)

M is the merge commit. Its parents are C4 (the tip of main) and C3 (the tip of feature). feature doesn’t move.

What “ort” actually is

ort (short for Ostensibly Recursive’s Twin) is the algorithm Git uses to compute what goes inside that merge commit M. Since Git 2.34 it’s the default; before that, the default was called recursive. For everyday merges, the result is the same — ort is just faster and handles edge cases more cleanly.

You’ll almost never type ort yourself. It’s the engine, not the command.

How to force one or the other

You can override Git’s choice:

# Refuse to merge if it can't fast-forward.
# Useful in CI to enforce a linear history.
git merge --ff-only feature

# Always create a merge commit, even when a fast-forward is possible.
# Useful when you want the merge to be visible in the log.
git merge --no-ff feature

Default config (git config merge.ff) controls the project-wide preference. Many teams set it to --no-ff for the main branch so every feature merge leaves a trace.

The mental model to keep

Before running git merge, ask yourself one question:

Has the target branch moved since I branched off?

  • No → fast-forward. Pointer slides. No new commit.
  • Yes → three-way merge. New commit with two parents. ort does the work.

That’s the whole decision. Everything else — strategies, flags, edge cases — sits on top of this one fork in the road.

Try this yourself

Run these in a scratch folder before the next class:

  1. Reproduce the fast-forward case. Confirm with git log --oneline --graph that history is linear.
  2. Reproduce the three-way case. Confirm you see a merge commit with two parents.
  3. Redo the three-way case with git merge --ff-only feature. Git should refuse. Read the error message — it tells you exactly why.
  4. Redo the fast-forward case with git merge --no-ff feature. Compare the log to step 1.

If you can predict the output of git log --graph before running it in all four cases, you’ve got it.

Cohort Notes — monthly

What we shipped, what the cohort is building, and when the next batch starts. One short email a month. No spam.