---
title: 'Conflict Resolution'
source: 'https://academia.sh/en/courses/branching-and-collaboration/conflict-resolution'
course: 'Branching and Collaboration'
language: en
updated: '2026-08-17T18:10:44+00:00'
license: 'CC BY-SA 4.0'
---

# Conflict Resolution

When the tool cannot merge two sides, it writes conflict markers to the file; the resolution is given by hand, but none of the four integration formats records the chosen side, and the resolution's rationale stands nowhere unless it is written into the commit message.

In the previous lesson's measurement, the `metrics` and `report` branches touched the
same file on the third step. Both integration formats brought that file into main with
the correct result, but neither history recorded how the conflict was resolved. In the
measurement, this meant the sixth question went unanswered in both formats.

This lesson looks at that resolution itself: what the tool writes to the file when it
cannot merge the two sides, how the marks it writes are read, how a given resolution is
verified to be correct — and, once the work is done, exactly what is written to history
and what is not.

## Where the Tool Cannot Decide

A three-way merge reads three versions: the state at the common ancestor, the state on
main, the state on the incoming branch. If only one side changed a line, the decision
makes itself — the changed side is taken. A conflict is born where **both sides changed
the same line differently from the ancestor version**. The tool does not produce a
preference here; both changes are intentional, and which one is correct can only be said
by knowing the meaning of the work.

Conflicts do not arise only from line content. If one side deleted the file while the
other modified it, if one side renamed it while the other wrote to the same path, or if
both sides created a new file at the same path with different content, a decision cannot
be made either. What these cases share is this: comparison with the ancestor version
shows a change on both sides, and the two cannot be applied together.

A conflict is not an error. It is the tool saying "the decision is not mine here"; the
merge is stopped, the working area is left half-done, and the decision is handed to a
human.

## Conflict Markers

In handing over the decision, the tool does not leave the file empty: it writes both
sides' content into the file and places separators between them. These are called
**conflict markers**.

```text
# taught command and example dump — not executed

$ git merge report
Auto-merging config.py
CONFLICT (content): Merge conflict in config.py
Automatic merge failed; fix conflicts and then commit the result.

$ cat config.py
<<<<<<< HEAD
THRESHOLD = 40
||||||| common ancestor
THRESHOLD = 25
=======
THRESHOLD = 25
UNIT = "mm"
>>>>>>> report
```

The section between `<<<<<<<` and `|||||||` is **the side you are on**: the branch on
which you started the merge, that is, `HEAD`. Between `|||||||` and `=======` is the
state **at the common ancestor**. Between `=======` and `>>>>>>>` is **the incoming
side**. This three-part notation is not the default; the two-part notation writes no
ancestor version and shows only the two sides. The ancestor section supplies the
measure: in the dump above, the ancestor says `THRESHOLD = 25`, meaning the side that
**changed** the threshold value is `HEAD`, and the side that left the line as it was and
added a new line beneath it is `report`. Without this information, the two sides look
equal and the choice is made blind.

The markers are written into the file itself, the file remains in the working area, and
it does not compile in that state. In the tool's own record, meanwhile, the same
conflict sits as three separate **stages**: ancestor, the side you are on, and the
incoming side. Seeing the three versions separately before editing the file by hand is
done from these stages.

```text
# taught command and example dump — not executed

$ git status --short
UU config.py
M  metrics.py

$ git diff --base config.py
$ git diff --ours config.py
$ git diff --theirs config.py
```

`UU` shows that both sides changed the same path; delete and rename conflicts are marked
with other letter pairs. The three `diff` calls compare the file in the working area
against the ancestor, the side you are on, and the incoming side, in turn.

## Giving and Verifying the Resolution

Resolving means bringing the file to its intended final state and deleting the markers.
The result can be identical to one of the two sides, or it can be a hand-written third
content different from both — a state where, as in the setup, both the new threshold
and the new unit are present. Once the file is prepared, adding it to the staging area
means, in the tool's eyes, "this path is resolved"; when every path is resolved, the
merge commit is written.

The tool also offers a shortcut: filling the conflicting path entirely with one side's
version. This shortcut is appropriate for machine-generated files, because there one
side can be regenerated. In a hand-written file, the shortcut silently drops all of the
other side's changes to that file; the dropped change becomes invisible in the file and,
if tests do not cover that side, gives no sign anywhere.

Verification has three separate layers, and all three are required.

**The first is mechanical:** making sure no marker remains in the file. A remaining
`<<<<<<<` line produces a syntax error in most languages, but it can sit silently in
formatted text or data files. Searching the tree for the marker sequence catches this.

**The second is integrity:** seeing that the resolved file carries both sides' intent.
This is the narrowly-viewed danger of conflicts — the person doing the merge has usually
written only one of the two sides and does not know what the other was trying to do. The
choice is therefore less about merging two sides' code than reconciling two intentions.

**The third is behavioral:** running the tests. A resolved file can compile and still be
wrong: a state where each side is separately correct but the combination is wrong is
entirely ordinary. Conflict resolution is exactly where tests are most needed.

The safe way to back out of a resolution is to **abort** the merge; the tool carries a
separate option for this and returns the working area to its state before the merge.
Aborting and starting over is always cheaper than trying to clean up a half-finished
merge by hand.

The tool also has an ability to remember resolutions: when the same conflict appears
again later, it applies the previous resolution on its own. This ability saves effort in
the case where a long-lived branch produces the same conflict repeatedly. What matters
for the measurement is this: the resolution it records sits in a **local directory of
the repository**, is not written to history, and is not shared with anyone. This is the
first place where resolution information lives outside history.

The measurement's assumptions:

- **MR8** — The conflict arises from the `metrics` and `report` branches touching the
  same file on the third step. The setup knows this; history is not required to know it.
- **MR9** — The measurement sets up all four integration formats. The formats'
  definitions are the definitions from the shared setup and are not changed in this
  lesson.
- **MR10** — The sixth question is measured by searching history for a field carrying
  the chosen side. What is measured is the field's **presence**, not its name.
- **MR11** — A resolution record consists of three fields: the chosen side, the
  rationale, and the role that made the decision. None of the three is a field the tool
  writes on its own.
- **MR12** — Under squash, a commit's file field is not a single name but a list of
  names: it can be read that the shared file was touched, but which commit touched it
  cannot be read individually.
- **MR13** — Writing the rationale into the commit message is a **single** change in the
  measurement: three fields are added to the merge commit, and nothing else changes.
- **MR14** — The set's resolution is **1/12** at twelve commits and **1/6** at six
  questions.

## Measurement

```python
"""What resolving a conflict writes to history.

Part 1 - where the two commits causing the conflict stand in four formats.
Part 2 - the sixth question is checked for in all four formats.
Part 3 - the one change: the resolution's rationale is written into a commit message.
"""
SEED = 20260813
BRANCHES = ("metrics", "report", "identity")
FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"}
BUGGY = ("report", 2)
SHARED_FILE = "config.py"
CONFLICT_FIELDS = ("chosen", "rationale", "decided_by")


def rng(seed):
    state = seed % 2147483646 + 1

    def draw(n):
        nonlocal state
        state = (state * 48271) % 2147483647
        return state % n
    return draw


def development():
    draw, record, time = rng(SEED), [], 0
    for step in range(1, 5):
        for branch in BRANCHES:
            time += 1 + draw(3)
            file = (SHARED_FILE if step == 3 and branch in ("metrics", "report")
                    else FILES[branch])
            record.append({"branch": branch, "step": step, "file": file,
                          "buggy": (branch, step) == BUGGY, "time": time})
    return record


def integrate(record, format):
    t = []
    if format == "merge commit":
        for branch in BRANCHES:
            for k in [x for x in record if x["branch"] == branch]:
                t.append({**k, "branch_record": branch, "time_preserved": True})
            t.append({"branch": branch, "step": 0, "file": None, "buggy": False,
                      "time": max(x["time"] for x in record if x["branch"] == branch),
                      "branch_record": branch, "merge_commit": True, "time_preserved": True})
    elif format == "rebase":
        for branch in BRANCHES:
            for k in [x for x in record if x["branch"] == branch]:
                t.append({**k, "branch_record": None, "time_preserved": False})
    elif format == "squash":
        for branch in BRANCHES:
            group = [x for x in record if x["branch"] == branch]
            t.append({"branch": branch, "step": 0,
                      "file": sorted({x["file"] for x in group}),
                      "buggy": any(x["buggy"] for x in group),
                      "time": max(x["time"] for x in group), "branch_record": branch,
                      "time_preserved": False, "squashed": len(group)})
    elif format == "fast-forward":
        for k in sorted(record, key=lambda x: x["time"]):
            t.append({**k, "branch_record": None, "time_preserved": True})
    return t


def q4_file(t):
    return all(isinstance(x["file"], str) or x.get("merge_commit") for x in t)


def q6_conflict(t):
    return any("chosen" in x for x in t)


def touched(t, name):
    """Count of commits in history that can be read as touching file `name`."""
    return sum(1 for x in t if x["file"] == name
               or (isinstance(x["file"], list) and name in x["file"]))


def separately_readable(t, name):
    """Commits readable as a single commit that touches only that file."""
    return sum(1 for x in t if x["file"] == name)


def write_to_message(t):
    """The one change: the resolution's rationale is written into a commit message."""
    y = [dict(x) for x in t]
    for x in y:
        if x.get("merge_commit") and x["branch"] == "report":
            x.update({"chosen": "metrics side", "rationale": "threshold field",
                      "decided_by": "merger"})
    return y


FORMATS = ("merge commit", "rebase", "squash", "fast-forward")
record = development()
T = {f: integrate(record, f) for f in FORMATS}
conflicting = [(k["branch"], k["step"]) for k in record if k["file"] == SHARED_FILE]

print(f"real development: {len(record)} commits | commits touching {SHARED_FILE}: "
      f"{len(conflicting)}: {conflicting}")
print()
print(f"{'format':<24s} {'commits':>7s} {'touched':>7s} {'separate':>8s}"
      f" {'file trail':>10s} {'conflict decision':>18s}")
for f in FORMATS:
    print(f"{f:<24s} {len(T[f]):7d} {touched(T[f], SHARED_FILE):7d}"
          f" {separately_readable(T[f], SHARED_FILE):8d}"
          f" {('yes' if q4_file(T[f]) else 'no'):>10s}"
          f" {('yes' if q6_conflict(T[f]) else 'no'):>18s}")
print(f"formats answering the sixth question: "
      f"{sum(q6_conflict(T[f]) for f in FORMATS)}/{len(FORMATS)}")
print()
M = write_to_message(T["merge commit"])
print(f"{'history':<36s} {'carrying a resolution field':>27s} {'conflict decision':>18s}")
for name, t in (("what the tool wrote", T["merge commit"]),
              ("rationale written to commit message", M)):
    carrying = sum(1 for x in t if any(a in x for a in CONFLICT_FIELDS))
    print(f"{name:<36s} {f'{carrying}/{len(t)}':>27s}"
          f" {('yes' if q6_conflict(t) else 'no'):>18s}")
```

```
real development: 12 commits | commits touching config.py: 2: [('metrics', 3), ('report', 3)]

format                   commits touched separate file trail  conflict decision
merge commit                  15       2        2        yes                 no
rebase                        12       2        2        yes                 no
squash                         3       2        0         no                 no
fast-forward                  12       2        2        yes                 no
formats answering the sixth question: 0/4

history                              carrying a resolution field  conflict decision
what the tool wrote                                         0/15                 no
rationale written to commit message                         1/15                yes
```

## None of the Four Can Answer It

The top table's last column pays off this course's third claim: **conflict decision is
no in all four formats.** The number of formats answering it is **0/4**. This does not
mean one format is worse than the others; it means none of the four measured formats has
such a field at all.

Looking at what history records makes the distinction clear. The two commits touching
the shared file are read as **2** in three formats: it can be found which file the
conflict arose in and which two pieces of work touched it. Under squash the file name
sits not in a separate commit but inside a commit group's file list; it can be seen that
it was touched, but the count of separately readable commits is **0**, and the file
trail question is no in this format.

So history records **that** a conflict happened — where the conflict arose can be found
in three formats. What it does not record is the resolution itself: which side was
chosen, which lines were written by hand, who made the decision, and why. None of these
four pieces of information sits in a field the tool writes.

Seeing that this is not a tool shortcoming only takes considering what a commit is. A
commit carries one state of the tree and its parents. The tree carried by a merge commit
already contains the resolved file — the result is recorded. What is not recorded is the
**process**: which of the two candidates was chosen and why cannot be read from the
result itself. The tool stores only the result, because what it stores is the tree.

## The Rationale's Only Place

The bottom table measures the next step. The one thing changed in the measurement is
this: the resolution's rationale is written into the merge commit's message. Three
fields — the chosen side, the rationale, the role that decided — are added to that one
commit.

The result is that **1** of the fifteen-commit history carries a resolution record, and
the sixth question turns yes. The ratio is **1/15**: above the set's resolution, but a
small share of history as a whole. This smallness is not incidental; a resolution record
is meaningful only in the commit where the conflict was closed.

From here comes the course's most practical result: **if a conflict resolution's
rationale is not written into the commit message, it exists nowhere.** The tool does not
write it, the file does not carry it, tests do not describe it. Even the local ability
that remembers resolutions keeps it only in that repository and shares it with no one.
Months later, the only place a person asking "why is this line like this" can look is
the message, and if the message is empty, the answer is with no one.

This has a concrete counterpart: a merge commit's default message only states which
branch was merged, and stays that way if left as is. In a conflicted merge, that message
needs to carry three things — which file the conflict was in, which side was chosen and
for what rationale, and, if there is hand-written content, what it is. All three are
three lines, and all three are unrecoverable once left unwritten.

## Summary

- A conflict is born where both sides changed the same line differently from the
  ancestor version; the tool produces no preference, hands over the decision, and leaves
  the merge half-done.
- Conflict markers write the side you are on, the ancestor version, and the incoming
  side into the file; the ancestor section shows which side actually made the change and
  takes the choice out of the blind.
- A resolution is verified at three layers: that no marker remains, that both sides'
  intent is preserved, and that tests pass. The safe way back is to abort the merge.
- The **2** commits touching the shared file are read separately in three formats, and
  **0** under squash; but none of the four formats can answer the conflict decision
  question — **0/4**.
- When the rationale is written into the commit message, **1** of the fifteen commits
  carries the resolution record and the sixth question is answered; unwritten, the
  resolution's rationale stands nowhere.

## Next Step

In this lesson, the conflict was resolved in a single place, at the moment the merge
commit was written. The resolution was given once and recorded once. A format that wants
to keep history fork-free, in a single line, instead rewrites the branch's commits onto
main's tip — and then the same conflict can be asked separately for each commit. The
next lesson looks at this format: twelve commits stay twelve, but which three questions
get answered, which information gets erased, and why applying it on a shared branch
cannot be undone.
