Fast-Forward Merge

A fast-forward merge is the simplest kind of Git integration. If the checked-out branch is an ancestor of the branch being merged, Git does not need to combine snapshots or create a merge commit. It moves the current branch ref to a commit that already exists.

That description contains two separate claims, and both matter:

  • Git proves an ancestry relationship before it writes anything.
  • A successful fast-forward changes the destination ref, not the source ref or the commit graph.

The animation keeps those claims visible. The ancestry cursor follows real parent links. The refs panel shows which name moves. The proof strip reports the number of ref writes and commit objects from the generated trace.

The graph condition

Suppose main points to commit A, while feature points to C:

A ← B ← C
↑       ↑
main    feature

Each commit points to its parent, so Git starts at C and walks backward: C, then B, then A. Finding the current main tip in that chain proves that every commit reachable from main is already part of feature's history.

The condition is:

is_ancestor(current_tip, source_tip)

When it is true, the result is only:

refs/heads/main: A → C

refs/heads/feature remains at C. Commits A, B, and C keep the same IDs, parents, and tree references. No fourth commit appears.

This is why “merge” does not always mean “merge commit.” git merge names an integration operation. The graph decides whether that operation needs a new commit.

Fast-forward merge step by step

The default run builds a small local repository with empty commits:

git commit --allow-empty -m "base"
git branch feature
git switch feature
git commit --allow-empty -m "feature-1"
git commit --allow-empty -m "feature-2"
git switch main
git merge --ff-only feature

The lesson uses a fixed empty tree so file changes do not hide the ref mechanics. The commit IDs are deterministic Git-format object IDs rather than display-only labels.

  1. The first command writes the root commit and advances the unborn main ref to it.
  2. git branch feature creates a second ref at that same root. It does not copy a commit and it does not switch HEAD.
  3. After git switch feature, each empty commit writes a new child object and advances only feature. main stays at the root.
  4. git switch main changes symbolic HEAD. Neither branch ref moves during the switch.
  5. The merge resolves two inputs: the current tip from main and the source tip from feature.
  6. The ancestry cursor starts at the source tip and follows one parent link per beat. It eventually reaches the old main tip.
  7. Once that match is proven, main moves to the source tip. feature remains fixed.
  8. The final beat checks the object count. It is unchanged because the merge wrote one existing ref and no commit object.

The separation between the proof and the write is deliberate. A ref update before the ancestry check would risk discarding the current branch's independent history. Git must classify the graph first.

Edge Cases

Several merge shapes create no commit, but for different reasons.

Current is behind source

This is the fast-forward case. The current tip occurs in the source tip's parent chain, so the current ref advances to the source tip. There is one destination-ref write.

Both refs have the same tip

There is nothing to move. Git reports that the branch is already up to date. Both refs already contain the same commit ID, so the ref-write count is zero.

Source is behind current

Git may fail to find the current tip while walking backward from the source. It then checks the other direction. If the source tip appears in the current tip's ancestry, the current branch already contains all source history. The result is again “already up to date,” with zero ref writes.

The equal-tip case is technically ancestry in both directions. The engine handles it first because it is the clearest terminal state and requires no walk.

When --ff-only refuses

Now suppose both branches made a commit after their shared base:

    M ← main
   /
A
   \
    F ← feature

Walking from F never reaches M, and walking from M never reaches F. Neither tip is an ancestor of the other. The histories have diverged.

With git merge --ff-only feature, Git stops. It does not move main, move feature, or add a commit object. A regular merge could create a two-parent merge commit after reconciling the snapshots, but that operation belongs to the next lesson. --ff-only is useful precisely because it turns an unexpected divergence into an explicit failure instead of silently choosing another integration strategy.

What the source branch does

The source branch is an input to the merge. It tells Git which commit should be considered for integration. A fast-forward does not “join both pointers and move them together.” Only the checked-out destination branch is updated.

After the default merge, main and feature happen to point to the same commit. They are still independent refs. A later commit on main moves main alone; a later commit on feature moves feature alone. Their equality immediately after the merge is a shared value, not a permanent link between the names.

HEAD remains symbolic throughout this lesson. It names the current branch, so the destination of the merge is the branch selected by HEAD. The source is the name supplied to git merge --ff-only.

Common mistakes

“A fast-forward copies the source commits into the current branch.” There is no branch-owned copy to make. History is recovered by following parent links from a branch's tip. Moving main to the existing source tip makes that same history reachable from both names.

“Every merge creates a commit.” A new commit is needed only when Git must record a new snapshot or a multi-parent integration. A fast-forward reuses the source tip exactly as it is.

“The source branch moves too.” The source ref stays put. The successful default trace records one current-ref write and zero source-ref writes.

“A shared base is enough for a fast-forward.” Diverged branches also share a base. One tip must lie on the other tip's ancestry chain.

“Already up to date means both refs have the same tip.” That is one possibility. The current branch can also be ahead of the source while already containing it.

“The working tree proves the merge is safe.” Fast-forward eligibility is a commit-graph question. Real Git also checks working-tree and index safety before updating files, but matching file contents alone does not establish ancestry.

Sandbox cases worth trying

  • Ready to merge stops just before the command, leaving main behind feature. Choose feature and run merge yourself.
  • Same tip shows the zero-walk, zero-write boundary.
  • Source behind makes Git search the opposite ancestry direction and report already up to date.
  • Diverged demonstrates why a common ancestor is not enough for --ff-only.
  • Missing branch follows the honest error path without mutating the graph.
  • Add another commit after a successful fast-forward to see that the two equal refs remain independent names.

The visualizer accepts up to three branches, seven commits, and twenty commands. Those are layout limits, not Git limits.

What this model leaves out

The animation models one local, clean repository with attached HEAD and one-parent commits. Every demo commit reuses a fixed empty tree. It omits the working tree, index, checkout conflicts, untracked files, merge commits, content reconciliation, rename detection, remotes, upstream configuration, tags, reflogs, hooks, signatures, submodules, replace refs, concurrent ref updates, and garbage collection.

Real Git performs ref updates with lock files and checks whether local file changes would be overwritten. Those safeguards are outside this graph-focused model. The rule being taught remains exact: a fast-forward is possible when the current tip is an ancestor of the source tip, and its graph effect is a destination-ref update to an existing commit.

For the reference and object foundations, revisit Branches & HEAD and Commits as Snapshots. Git's own documentation covers the production command behavior in git merge, the ancestry test in git merge-base --is-ancestor, and the meaning of refs and parent links in gitglossary.