Merge Conflicts
A merge conflict can look fixed in your editor while Git still refuses to continue. The reason is simple and easy to miss: Git decides whether a path is resolved from the index, not from the presence or absence of conflict markers in the working file. Markers are text; index stages are state.
Think of the working tree as the draft on your desk and the index as the tray that will be filed. Editing the draft does not replace what is in the tray. git add does. That small boundary explains both common failures: a clean-looking file that Git still calls unmerged, and a marker-bearing file that Git will happily stage.
How merge conflicts work
A diverged merge starts with three immutable snapshots:
- Base is the shared earlier commit.
- Ours is the snapshot at
HEAD, the checked-out side. - Theirs is the snapshot at the source branch named by
git merge.
The animation uses a deliberately narrow aligned-line model: one file, three substitution-only lines, and one possible conflict hunk. For each aligned line it applies four rules. If ours equals theirs, take either value. If ours equals base, only theirs changed, so take theirs. If theirs equals base, only ours changed, so take ours. If all three differ, none of those rules can justify a choice. That line conflicts.
The first line makes the equality rule do real work. The base says title=VizLab; both branches independently say title=VizLearn. Git takes VizLearn because ours equals theirs, not because either side matches the base and not because HEAD gets a ceremonial tie-breaker.
The conflict does not create a half-finished commit. HEAD and the current branch remain on the pre-merge commit. MERGE_HEAD records the source tip. For the unmerged path, the index removes stage 0 and stores three entries: stage 1 for base, stage 2 for ours, and stage 3 for theirs. The working file receives a merge result with a marker block such as:
title=VizLearn
<<<<<<< HEAD
accent=navy
=======
accent=teal
>>>>>>> feature
footer=v1
You now edit the working file. Git does not infer resolution from appearance, so deleting the markers is not enough. git add app.txt hashes the exact working bytes, removes stages 1/2/3, and writes one stage-0 entry. Only then can git merge --continue write the result tree, create a commit with two ordered parents, advance the current branch, and clear MERGE_HEAD.
There is a sharp edge here: git add stages bytes, not good judgment. If the marker block is still present, Git can stage it and the merge commit can preserve it as ordinary file content.
Merge conflicts step by step
The default trace uses one three-line file and these ten operations:
commit base: title=VizLab | accent=blue | footer=v1
git branch feature
commit main-theme: title=VizLearn | accent=navy | footer=v1
git switch feature
commit feature-theme: title=VizLearn | accent=teal | footer=v1
git switch main
git merge feature
edit app.txt: title=VizLearn | accent=indigo | footer=v1
git add app.txt
git merge --continue
- The
basecommit recordstitle=VizLabandaccent=blue. Bothmainandfeatureinitially point to it. main-themechanges the title toVizLearnand line 2 toaccent=navy. After switching tofeature,feature-thememakes the same title change but choosesaccent=teal. The two tips now share a base but neither contains the other.- Back on
main,git merge featurefreezes ours at themain-themetip and theirs at thefeature-themetip. The unique merge base isbase.MERGE_HEADpoints to the feature tip;HEADdoes not move. - Line 1 is the non-trivial equality case: base is
title=VizLab, while ours and theirs both equaltitle=VizLearn, so Git takes either side. Line 3 is unchanged everywhere. Line 2 isaccent=blueat base,accent=navyin ours, andaccent=tealin theirs. All three differ, so the engine records one conflict hunk. - The index becomes stages 1/2/3 for
app.txt, and the working file expands to the seven-line marker form shown above. Commit objects added: zero. Current-ref writes: zero. - The editor replaces the marker block with
accent=indigo. Only the working tree changes. The index still contains stages 1/2/3, so the path is still unmerged in Git's authoritative state. git add app.txthashes the three resolved lines and writes that blob to stage 0. The three unmerged stages disappear,MERGE_HEADremains set, and no branch moves.git merge --continueverifies that no unmerged paths remain. It writes a tree from stage 0, then creates one merge commit whose first parent is the pre-mergemaintip and whose second parent is the frozenfeaturetip.- Finally,
mainadvances to the new merge commit.featurestays where it was,MERGE_HEADclears, and the index plus working tree retain the resolvedaccent=indigosnapshot.
In the visualizer, the decisive handoff is the arrow between the two lower cards. Conflict materialization flows from the three-stage index to the working tree. git add app.txt reverses the direction and copies the edited bytes back into stage 0.
Why the index has three stages
Stage numbers are part of the index key. During a normal checkout, app.txt has one stage-0 entry. During a content conflict, one path needs three simultaneous blob identities:
| Index stage | Role | Meaning in this merge |
|---|---|---|
| 1 | base | The common ancestor's file |
| 2 | ours | The file from pre-merge HEAD |
| 3 | theirs | The file from MERGE_HEAD |
| 0 | resolved | The single version selected by git add |
This is better than storing only a red "conflicted" flag. The three blobs preserve the evidence needed by merge tools, while the working file remains an editable proposal. Once stage 0 replaces the unmerged entries, Git knows which exact bytes would enter the next tree. It does not claim those bytes are sensible.
For the mini-engine, finding the merge base costs O(V + E) over the reachable commit graph. The aligned content pass costs O(L + B) for L logical lines and B bytes. Hashing the resolution and serializing its tree and commit are linear in the staged bytes. Those bounds describe this scoped engine, not the performance of Git's production ort strategy.
A practical merge-conflict workflow
- Run
git statusfirst. Its "unmerged paths" section tells you which paths Git still considers unresolved; the marker text is not the authority. - If you want to see the three stored inputs directly, run
git ls-files -u. Stage 1 is base, stage 2 is ours, and stage 3 is theirs. - Edit the file, decide what the program should actually do, and run the relevant tests. A marker-free file is a candidate resolution, not yet a staged one.
- Run
git add app.txt, then inspect the staged result withgit diff --cached. At this point the path has one stage-0 entry instead of stages 1/2/3. - Use
git merge --continuewhen the staged snapshot is correct, orgit merge --abortwhen you want to return to the pre-merge state covered by this lesson.
That order is worth memorizing: inspect state, edit and verify, stage, inspect the staged result, then continue or abort. It turns a mysterious red warning into a sequence of explicit state changes.
Edge cases that reveal the index boundary
- Different lines: if
mainchanges line 2 andfeaturechanges line 3, the same rules choose both edits cleanly. No stages 1/2/3 and no marker block appear. - Continue too soon: editing the file without
git addleaves the index unmerged.git merge --continuecreates no commit and moves no ref. - Stage markers: running
git add app.txtwhile markers remain creates a valid stage-0 blob containing those markers. Continuing can commit them. - Abort: in this lesson's clean pre-merge-worktree scope,
git merge --abortrestores the index and working file from the merge-startHEAD, clears temporary merge state, and leaves both branch refs fixed. - No active merge:
git merge --continueandgit merge --abortfail whenMERGE_HEADis absent. The lesson-scoped editor and add actions are unavailable outside a paused merge. - Visualizer capacity: eight commits, three branches, and twenty-four commands are layout limits. Real Git has no such small caps.
Common mistakes that confuse bytes with resolution
- Treating conflict markers as the conflict itself. The authoritative conflict is the path's stages 1/2/3 in the index. Markers are working-tree text produced for a content merge.
- Assuming an editor resolves Git state. Saving a clean-looking file changes the working tree only. Run
git addto replace the unmerged index entries. - Believing
git addvalidates the resolution. It stages the current bytes, including accidental marker text, debug output, or a bad combination of both sides. - Expecting a conflict commit to appear immediately. The merge pauses before the result tree, merge commit, and current-ref write.
- Moving both branches in your mental model. A completed merge advances the checked-out branch. The source branch remains at its original tip.
- Forgetting parent order. Parent 1 is the pre-merge current tip; parent 2 is
MERGE_HEAD. Those ordered headers are part of the commit bytes and therefore part of its object ID.
A note on simplification: one file, one hunk
This animation models one clean local repository with attached HEAD, one app.txt file, exactly three initial logical lines, aligned substitutions, one unique merge base, and at most one conflict hunk. It omits additions, deletions, renames, directory/file conflicts, binary files, multiple merge bases, custom merge drivers, attributes, rerere, hooks, signatures, submodules, sparse or shallow history, pre-existing working-tree edits, and remotes. Real ort also writes an AUTO_MERGE tree during a conflicted merge; the lesson names that omission instead of drawing a fake approximation.
For production behavior, see git merge, git ls-files, and the Git Book's conflict walkthrough.
Keep exploring
A few lessons that share the same technique or lead into the next idea.
Found a mistake or something unclear? Leave feedback.