Git Merge Explained: Fast-Forward, Three-Way and Conflicts
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.
The merge base
Section titled “The merge base”Everything starts here.
git merge-base main featureWhat 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.
1ad6ec688fac739e42eacdf91905c8ff73f27005With 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.
Fast-forward merges
Section titled “Fast-forward merges”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.
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.
Git simply moves the main ref forward:
git switch maingit merge featureUpdating 1ad6ec6..e811fc1Fast-forward app.txt | 1 + 1 file changed, 1 insertion(+)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.
No merge commit is created. Confirm it directly — a fast-forwarded tip has exactly one parent:
git cat-file -p HEAD | grep -c parent1Fast-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.
Three-way merges
Section titled “Three-way merges”When both branches have commits the other lacks, they have diverged and Git must actually reconcile.
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.
git switch maingit merge featureWhen there are no conflicts, Git creates the merge commit and opens an editor for its message:
Merge branch 'feature'The result has two parents:
git cat-file -p HEAD | grep parentparent 5f49aabec7815dc0cb237a91e678d27aa6b55606parent abe666692988d8a2bed78d6a092fb9f1da4bd72cThe 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.
Controlling which one happens
Section titled “Controlling which one happens”Three flags decide the behaviour when a fast-forward is possible.
| Flag | When fast-forward is possible | When it is not |
|---|---|---|
--ff (default) | Fast-forward | Create a merge commit |
--no-ff | Create a merge commit anyway | Create a merge commit |
--ff-only | Fast-forward | Refuse 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:
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:
git merge --ff-only origin/mainhint: 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.
When a merge conflicts
Section titled “When a merge conflicts”If both sides changed the same region differently, Git stops mid-merge and asks you to resolve it.
git merge topicAuto-merging app.txtCONFLICT (content): Merge conflict in app.txtAutomatic 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:
onetwothree<<<<<<< HEADfourmainline=======other>>>>>>> topicAnd git status reports the conflicted state:
git status --shortUU app.txtUU means “unmerged, both sides modified”. Underneath, the index holds three versions of the file:
git ls-files -s app.txt100644 4cb29ea38f70d7c61b2a3a25b02e3bdf44905402 1 app.txt100644 cb22e4730ab134e0c3809bda5bff5611fd64ed1d 2 app.txt100644 f84b015d76af6565e0dacafa414130c0f7e1c214 3 app.txtStage 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:
git add app.txtgit commit --no-editStaging 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.
Aborting a merge
Section titled “Aborting a merge”If a merge turns out to be more than you want to deal with right now:
git merge --abortWhat 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:
| Command | Use for |
|---|---|
git merge --abort | Cancel entirely, return to pre-merge state |
git merge --quit | Leave the merge state but keep the working tree as it is |
git merge --continue | Complete the merge after staging resolutions (equivalent to committing) |
Inspecting a merge
Section titled “Inspecting a merge”Before merging, see what you are about to bring in:
git log --oneline main..featuregit diff main...featureTwo dots in log (commits on feature only); three dots in diff (changes relative to the merge base).
After merging, read the shape:
git log --oneline --graph -6* ac59a44 Merge branch 'topic'|\| * abe6666 T* | 5f49aab M2* | e811fc1 D|/* 1ad6ec6 ATo see only the merge points:
git log --oneline --mergesTo read main as a flat sequence, following only the first parent of each merge:
git log --oneline --first-parent mainThat 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.
Cleaning up after a merge
Section titled “Cleaning up after a merge”Once a branch is merged, its ref has no further purpose:
git branch --merged main feature* mainAnything listed is fully reachable from main:
git branch -d featuregit push origin --delete feature-d refuses if the branch is not merged, which makes it a free verification that the integration
actually happened.
Undoing a merge
Section titled “Undoing a merge”Two different situations, two different tools. Choosing the wrong one is how people lose work or rewrite history others depend on.
The merge is still local
Section titled “The merge is still local”If you have not pushed, moving the branch back is simplest:
git reset --hard HEAD~1The merge is already pushed
Section titled “The merge is already pushed”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:
git revert HEADerror: 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:
git revert -m 1 HEADWhat 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.
Merging a remote branch
Section titled “Merging a remote branch”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.
git fetch origingit merge origin/maingit 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:
git fetch origingit log --oneline HEAD..origin/maingit diff HEAD...origin/maingit merge origin/mainRemember 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.
Merge strategies
Section titled “Merge strategies”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:
| Strategy | Use for |
|---|---|
ort (default) | Everything, ordinarily |
ours | Recording a merge while discarding the other side’s changes entirely |
octopus | Merging 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.
When merging is preferable to rebasing
Section titled “When merging is preferable to rebasing”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.
Common mistakes
Section titled “Common mistakes”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.
Mental Model
Section titled “Mental Model”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”.
What You Learned
Section titled “What You Learned”git mergecompares 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-ffforces a merge commit;--ff-onlyrefuses anything else.- Conflicts store three versions in the index at stages 1, 2 and 3;
git addcollapses them to stage 0. git merge --abortcancels cleanly and touches no commits.git branch --mergedverifies integration by reachability, which squash and rebase integrations defeat.
Try It Yourself
Section titled “Try It Yourself”Build both merge types in a disposable repository and see the difference in the object graph.
- Create a repository, commit a file with three lines.
- Create
feature, add a line, commit. Do not touchmain. - Switch to
mainand mergefeature. Note the output — it should sayFast-forward. - Confirm no merge commit was created:
git cat-file -p HEAD | grep -c parentshould print1. - Now create
topicfromHEAD~1, change the same line differently, and commit. - Switch to
main, commit a different change to that line, then mergetopic. Predict: conflict or not? - Inspect the three stages:
git ls-files -s <file>. - Resolve,
git add,git commit --no-edit. Confirm two parents this time. - Run
git log --oneline --graphand 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.
Next Lesson
Section titled “Next Lesson”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.