Skip to content

Git Merge Explained: Fast-Forward, Three-Way and Conflicts

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

git merge integrates another branch’s history into your current branch. It does this by finding the merge base — the most recent commit both branches share — then combining what each side changed since then.

Depending on the shape of the history, one of two things happens: Git either moves your branch pointer forward (a fast-forward), or creates a new commit with two parents (a three-way merge). Which one you get is determined entirely by ancestry, not by preference.

Everything starts here.

Terminal window
git merge-base main feature

What it doesPrints the most recent commit reachable from both of the named commits — their best common ancestor.

Why we run itIt is the reference point for the whole merge. Git compares each branch against this commit to work out what changed on each side.

Expected resultA single 40-character commit ID.

1ad6ec688fac739e42eacdf91905c8ff73f27005

With the base in hand, Git computes two diffs — base to main, and base to feature — and applies both. That is the whole idea, and it explains the behaviour people find surprising:

  • A change only one side made applies cleanly, however large.
  • A change both sides made identically applies once.
  • A change both sides made differently in the same region is a conflict.

Note what is not involved: how many commits are on each side, or how long the branch has existed. Git compares end states against the base, not commit by commit. Ten commits that end up changing one line merge as easily as one commit changing one line.

If the branch you are merging in has the branch you are merging into as an ancestor, the histories have not diverged. There is nothing to reconcile: the target’s tip is the merge base.

Before: main is an ancestor of feature

A single lane of commits A, B, C and D. The label main points at commit B and the label feature points at commit D, with C and D descending from B. Because main is an ancestor of feature, there is nothing to reconcile.

ABCDmainfeaturemain has no commits that feature lacks. The merge base is B — main's own tip.

Git simply moves the main ref forward:

Terminal window
git switch main
git merge feature
Updating 1ad6ec6..e811fc1
Fast-forward
app.txt | 1 +
1 file changed, 1 insertion(+)
After: main points at the same commit as feature

A single lane of commits A, B, C and D with both the main and feature labels attached to commit D. No new commit was created; the main reference simply moved forward.

ABCDmain, featureNo new commit. Only the ref moved — which is why the history stays linear.

No merge commit is created. Confirm it directly — a fast-forwarded tip has exactly one parent:

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

Fast-forwarding is not a lesser kind of merge; it is the correct outcome when there is genuinely nothing to combine. The trade-off is that the branch leaves no trace in the history, which is what --no-ff addresses.

When both branches have commits the other lacks, they have diverged and Git must actually reconcile.

Diverged history requires a merge commit

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 X and Y, merging into M. The merge commit M has two parents, C and Y.

ABCMXYmainfeatureM records both C and Y as parents, so both histories remain fully reachable.
Terminal window
git switch main
git merge feature

When there are no conflicts, Git creates the merge commit and opens an editor for its message:

Merge branch 'feature'

The result has two parents:

Terminal window
git cat-file -p HEAD | grep parent
parent 5f49aabec7815dc0cb237a91e678d27aa6b55606
parent abe666692988d8a2bed78d6a092fb9f1da4bd72c

The first parent is where you were — the branch you merged into. The second is what you merged in. That ordering is not cosmetic: it is what HEAD^1 and HEAD^2 select, what git log --first-parent follows, and what git revert -m needs. Merge Commits covers the consequences.

Three flags decide the behaviour when a fast-forward is possible.

FlagWhen fast-forward is possibleWhen it is not
--ff (default)Fast-forwardCreate a merge commit
--no-ffCreate a merge commit anywayCreate a merge commit
--ff-onlyFast-forwardRefuse and stop

--no-ff forces an explicit integration point even when the history is linear. Teams use it so that every branch is visible as a unit in main’s history:

Terminal window
git merge --no-ff feature

--ff-only is a safety flag. It says “only integrate if this is trivial” — useful in scripts and when updating a local branch from a remote, where an unexpected merge commit means something has diverged that you did not expect:

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.

Fast-Forward vs Three-Way Merge covers the distinction in more depth, including how to check in advance which you will get.

If both sides changed the same region differently, Git stops mid-merge and asks you to resolve it.

Terminal window
git merge topic
Auto-merging app.txt
CONFLICT (content): Merge conflict in app.txt
Automatic merge failed; fix conflicts and then commit the result.

Note “Auto-merging” on the first line: Git merged what it could. The conflict is limited to the regions that genuinely disagree.

The file now contains conflict markers:

one
two
three
<<<<<<< HEAD
four
mainline
=======
other
>>>>>>> topic

And git status reports the conflicted state:

Terminal window
git status --short
UU app.txt

UU means “unmerged, both sides modified”. Underneath, the index holds three versions of the file:

Terminal window
git ls-files -s app.txt
100644 4cb29ea38f70d7c61b2a3a25b02e3bdf44905402 1 app.txt
100644 cb22e4730ab134e0c3809bda5bff5611fd64ed1d 2 app.txt
100644 f84b015d76af6565e0dacafa414130c0f7e1c214 3 app.txt

Stage 1 is the merge base, stage 2 is “ours”, stage 3 is “theirs”. That is the three-way comparison stored as data — and it is why the index exists in the form it does. Understanding the Git Index covers the stages.

Resolving means editing the file to the intended result, removing the markers, and staging it:

Terminal window
git add app.txt
git commit --no-edit

Staging collapses the three index entries back to a single stage-0 entry, which is mechanically what “resolved” means. Resolving Merge Conflicts is the full tutorial.

If a merge turns out to be more than you want to deal with right now:

Terminal window
git merge --abort

What it doesCancels the in-progress merge and restores the working tree, index and HEAD to their state before the merge began.

Why we run itIt is the safe exit. Nothing about the merge is kept, and neither branch is modified.

Expected resultNo output. git status afterwards reports a clean working tree on your original branch.

This works reliably as long as you had a clean working tree when you started the merge — which is why Git refuses to begin a merge with uncommitted changes it might have to overwrite.

Related states:

CommandUse for
git merge --abortCancel entirely, return to pre-merge state
git merge --quitLeave the merge state but keep the working tree as it is
git merge --continueComplete the merge after staging resolutions (equivalent to committing)

Before merging, see what you are about to bring in:

Terminal window
git log --oneline main..feature
git diff main...feature

Two dots in log (commits on feature only); three dots in diff (changes relative to the merge base).

After merging, read the shape:

Terminal window
git log --oneline --graph -6
* ac59a44 Merge branch 'topic'
|\
| * abe6666 T
* | 5f49aab M2
* | e811fc1 D
|/
* 1ad6ec6 A

To see only the merge points:

Terminal window
git log --oneline --merges

To read main as a flat sequence, following only the first parent of each merge:

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

That last one is genuinely useful on a history full of merge commits — it shows one entry per integration rather than every commit from every branch.

Once a branch is merged, its ref has no further purpose:

Terminal window
git branch --merged main
feature
* main

Anything listed is fully reachable from main:

Terminal window
git branch -d feature
git push origin --delete feature

-d refuses if the branch is not merged, which makes it a free verification that the integration actually happened.

Two different situations, two different tools. Choosing the wrong one is how people lose work or rewrite history others depend on.

If you have not pushed, moving the branch back is simplest:

Terminal window
git reset --hard HEAD~1

Rewriting shared history creates work for everyone else. Revert instead — it adds a new commit that undoes the merge’s effect, leaving history intact.

Reverting a merge needs one extra piece of information, because Git cannot know which side you want to keep:

Terminal window
git revert HEAD
error: commit f1b6bc006e03a46fffcd9fb9e4cb61d2682c369a is a merge but no -m option was given.
fatal: revert failed

-m selects the parent to treat as the mainline — the side whose changes you are keeping:

Terminal window
git revert -m 1 HEAD

What it doesCreates a new commit that reverses the changes the merge brought in, relative to the parent you nominate as the mainline.

Why we run itIt undoes a merge without rewriting history, so it is safe on a branch others have pulled.

Expected resultA new commit named Revert "Merge …". Files the merge introduced are removed again.

-m 1 means “keep the first parent’s line of development” — the branch you were on when you merged. That is almost always what you want. -m 2 would keep the merged-in branch’s side instead.

Most merges in practice integrate work from a remote rather than a local branch. The mechanics are identical; what changes is that you must fetch first.

Terminal window
git fetch origin
git merge origin/main

git pull does both in one step, which is convenient and hides the decision about how to integrate. If you prefer to see what arrived before combining it:

Terminal window
git fetch origin
git log --oneline HEAD..origin/main
git diff HEAD...origin/main
git merge origin/main

Remember that origin/main is your last-fetched snapshot, not the live state of the server. Ahead/behind counts from git status are only as current as your last fetch.

Git has more than one algorithm for performing a merge. In current Git the default for two-branch merges is ort, which replaced the older recursive implementation.

You will rarely need to change it. The cases where you might:

StrategyUse for
ort (default)Everything, ordinarily
oursRecording a merge while discarding the other side’s changes entirely
octopusMerging more than two branches at once, when none conflict

Strategies are distinct from strategy options, which tune the default algorithm rather than replacing it — -X ours resolves conflicting hunks in your favour while still merging everything else normally, which is very different from the ours strategy.

Advanced Merge Strategies covers all of this, including where a strategy can silently discard changes.

Both integrate; they differ in what they leave behind. Merging is the better choice when:

  • The branch is shared. Rebasing rewrites commits, which breaks everyone else’s copy.
  • You want the integration recorded. A merge commit says when a branch was integrated and by whom — which some audit contexts require.
  • The branch’s history is meaningful. Preserving well-structured commits and their original context has value.
  • Conflicts are substantial. A merge resolves each conflict once. A rebase may present a related conflict on every replayed commit.
  • You want to keep it simple. Merging never rewrites anything, so it has no force-push failure mode.

Rebasing is preferable when the branch is private and you want a linear history. Rebase vs Merge covers the full comparison.

Expecting a merge commit from every merge. Fast-forward is the default when nothing has diverged. Use --no-ff if you want the integration point.

Thinking --no-ff changes the content. It changes the shape of the history. The resulting files are identical either way.

Resolving conflicts by deleting markers without reading both sides. The markers are punctuation; the decision is yours. Deleting them and keeping whatever looks reasonable silently discards work.

Merging with a dirty working tree. Git will refuse when it would overwrite changes. Commit or stash first.

Assuming a clean merge is correct. Git combines textual changes. Semantic incompatibility merges cleanly and breaks at runtime. Run the tests after every non-trivial merge.

Using git pull without knowing what it does. It fetches and then integrates using whatever your configuration says. Set pull.ff only or pull.rebase deliberately.

A merge asks: what did each side change since we last agreed?

Git finds the last point of agreement — the merge base — and computes both answers. Where only one side changed something, it takes that change. Where both changed the same thing differently, it asks you.

If one side changed nothing since the base, there is nothing to combine, so Git just moves the label forward.

That framing makes fast-forward stop looking like a special case: it is the merge where one side’s answer is “nothing”.

  • git merge compares three commits: the merge base and both branch tips.
  • A fast-forward happens when the target branch is an ancestor of the branch being merged — no merge commit is created.
  • A three-way merge creates a commit with two parents; first parent is where you were.
  • --no-ff forces a merge commit; --ff-only refuses anything else.
  • Conflicts store three versions in the index at stages 1, 2 and 3; git add collapses them to stage 0.
  • git merge --abort cancels cleanly and touches no commits.
  • git branch --merged verifies integration by reachability, which squash and rebase integrations defeat.

Build both merge types in a disposable repository and see the difference in the object graph.

  1. Create a repository, commit a file with three lines.
  2. Create feature, add a line, commit. Do not touch main.
  3. Switch to main and merge feature. Note the output — it should say Fast-forward.
  4. Confirm no merge commit was created: git cat-file -p HEAD | grep -c parent should print 1.
  5. Now create topic from HEAD~1, change the same line differently, and commit.
  6. Switch to main, commit a different change to that line, then merge topic. Predict: conflict or not?
  7. Inspect the three stages: git ls-files -s <file>.
  8. Resolve, git add, git commit --no-edit. Confirm two parents this time.
  9. Run git log --oneline --graph and compare the shapes of the two merges.

Step 4 is the one worth pausing on: the first merge produced no new object at all. Only a ref moved.

Fast-forward versus three-way is decided purely by ancestry. The next lesson goes into that distinction properly, including how to know in advance which you will get.