Skip to content
academia.sh

Lesson 06 / 15

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.

Contents

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.

# 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.

# 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

"""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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close