Software Engineering · Version Control Practice
Conflict Markers, Merge Drivers, and Recorded Resolutions
How conflict markers encode the three inputs, how an attribute-selected merge driver replaces the line-based algorithm, and how rerere replays a resolution it has seen before.
A conflict is not an error condition in the merge algorithm; it is the output format for a case the algorithm declines to decide. The working tree receives a file containing markers that delimit the three inputs, and the index retains the three blobs so that a resolver — human or program — can read them independently of the file. Both of those artefacts are more structured than the markers alone suggest, and the second one is what makes automated resolution possible.
What the two-style marker actually omits
The default merge conflict style writes two sections: ours between <<<<<<< and =======, theirs between ======= and >>>>>>>. The base is absent from the file entirely. It is still in the index at stage 1, and git checkout --conflict=diff3 <path> rewrites the file with a third section delimited by |||||||.
The difference matters for any conflict where the two sides changed different parts of the same hunk. With two sections, the resolver sees two versions that both differ from each other and cannot tell which lines either side added and which it merely inherited. With the base section present, each side’s contribution is visible as the difference from the base, which is exactly the information the merge algorithm had.
The zdiff3 style is a refinement of diff3 that removes lines common to both sides from the conflict region, so the conflict shows only the genuinely divergent part. It is not the default, and the choice is made per repository through configuration rather than per merge, so a repository that wants it has to set it once and accept that existing habits of reading markers will not transfer directly.
Marker length is seven characters by default and is lengthened automatically when the conflicting content itself contains a line beginning with seven identical characters, so the markers remain unambiguous. The labels after the marker characters are ref names or commit descriptions, and they are informational only; parsing a conflict file by looking for a specific label is fragile because the label changes with the merge invocation.
The index is the real interface
Three stages, one per input. git ls-files -u prints them with mode, object id, stage, and path. A path can have any non-empty subset of stages 1, 2, and 3, and the subset encodes the conflict kind:
- Stages 2 and 3 only: both sides added the file, no base. A modify/delete conflict looks different — one side has a stage and the other does not.
- Stage 1 and 2 only: theirs deleted, ours modified.
- Stage 1 and 3 only: ours deleted, theirs modified.
- Stages 2 and 3 only with a base present in neither: a rename/rename conflict, where each side renamed the original to a different path and both new paths are recorded as additions.
That last case is why a merge can produce two conflicted paths from one original. Git records the rename targets as added files with no base, and the resolver has to decide which path survives.
Resolution is the act of writing a stage-0 entry. git add does that, collapsing stages 1, 2, and 3 into stage 0 with the content of the working tree file. Nothing checks that the written content contains no markers; git add will happily stage a file with <<<<<<< still in it. Some repositories install a pre-commit check for exactly this, because the failure mode is a build break rather than a merge error.
The three blobs remain in the object store regardless of the resolution, so git checkout :1:<path> can extract the base after the fact, and git show :2:<path> the ours version. That is the mechanism an automated resolver uses: read the three stages, compute a result, write it, and add.
Merge drivers selected by attribute
The line-based algorithm can be replaced per path through the merge attribute in .gitattributes. The attribute value names a driver, and a driver is either one of the built-ins or a command configured under merge.<name>.driver.
Two built-ins are worth knowing. merge=binary marks a path as unmergeable at the line level: any change on both sides is a conflict, and the marker file is not written. merge=union concatenates the two sides’ additions and keeps both, resolving in favour of a superset. Union is appropriate for append-only files — a changelog, a list of generated entries, a lockfile whose ordering does not matter — and inappropriate anywhere else, because it produces a file that is syntactically valid and semantically wrong without any conflict being reported.
A custom driver is a command that receives three temporary file paths — the base, ours, and theirs — and is expected to write the result to ours and exit with zero for success or non-zero for conflict. The paths are passed as %O, %A, and %B in the configured command string, and the exit status determines whether the merge stops.
# .gitattributes
CHANGELOG.md merge=union
*.lock merge=binary
generated/*.c merge=regen
The contract is small enough that a driver can be a script invoking a domain-specific tool: a structured-data merger for a serialisation format, a semantic merger for a language with a parser available, or a tool that regenerates a derived file from its source rather than merging it.
The derived-file case is the cleanest use. If a path is generated from another path, the correct resolution is never a merge of the two generated versions; it is a regeneration from the merged source. A driver that runs the generator against the merged input and exits zero gets this right by construction, and a merge=ours-style driver that keeps one side is a crude approximation of the same idea with the failure mode of keeping a stale output.
merge=ours deserves a warning: it is a configured driver that always resolves in favour of the current branch’s version, and it is not a built-in. Applying it to a file that two branches genuinely both edit silently discards the other side’s change. Its legitimate uses are narrow — a version stamp, a build number — and its illegitimate uses are invisible in the result.
Recording resolutions for replay
git rerere — reuse recorded resolution — addresses the case where the same conflict recurs. It is most obviously useful during a rebase of a long series, where each commit may hit the same conflict against the same base, and during repeated merges of a branch that is kept current.
The mechanism has two phases. During a merge, after the automatic pass leaves conflicts, rerere computes a hash of the pre-image: the conflicted file content including markers, which encodes all three inputs. It stores that pre-image under a directory keyed by the hash. After the resolver writes a resolution and stages it, rerere computes the post-image, the resolved content, and stores it alongside.
On a later merge, when a conflict is produced, rerere hashes the new pre-image and looks it up. A hit means the same three-way conflict was resolved before, and the recorded post-image is applied to the working tree. The file is still staged as unresolved until it is added, so the resolution can be reviewed rather than trusted blindly.
The hash is over exact bytes, which is both the strength and the limitation. Any difference in the surrounding context, in whitespace, or in the base version produces a different pre-image and a miss. A rebase where the base shifts by one commit often produces different context lines around the conflict and therefore no reuse. The recorded resolutions are also repository-local unless the directory is shared, and they are not transferred by clone.
rerere.enabled turns on recording and reuse; the subcommands status, diff, remaining, and forget inspect and manage the store. A gc subcommand prunes entries not used recently. Recording happens only when enabled before the conflict is produced, which is why enabling it after the fact does not help the merge already in progress.
The practical failure modes
Automated resolution of any kind — driver, union, or rerere — produces a clean merge with no conflict, and a clean merge is indistinguishable from a correct one at the object level. The merge commit records the tree; it does not record that a program decided. This is the reason these mechanisms are worth using only where the resolution rule is mechanically justifiable: append-only files, generated files, and conflicts whose correct answer is genuinely a function of the three inputs.
The second failure mode is staleness. A recorded rerere resolution or a union-merged append can be correct against one base and wrong against another, and neither mechanism re-validates. A build or test run after the merge is the only check that the resolution preserved intent, and it must run on the merged tree rather than on either side.
The third is the interaction with history rewriting. A rebase replays commits, and each replay that conflicts produces a new pre-image and a new resolution. If rerere is enabled, the store accumulates one entry per distinct pre-image, and the same logical conflict can have several recorded resolutions that differ in small ways. The store is a cache, not a record, and treating it as documentation of how conflicts were resolved overstates what it contains.
What the mechanisms share is a dependence on the three-stage index, which is the only place the inputs survive after the working tree has been written. That structure is produced by the merge algorithm described in three-way merge and merge base selection, and the frequency with which conflicts recur is governed by how often the base moves, discussed in branch lifetime and integration frequency.
References
- Git documentation — gitattributes — Git project
- Git documentation — git-rerere — Git project
- Git documentation — git-merge — Git project
- diff3 man page — GNU diffutils