Skip to content

AI Merge Conflict Resolution

Lesson 3 of 8Intermediate13 min readGitHub Copilot & AI Engineering · AI + GitVerified: git 2.43.0 on Ubuntu 24.04 with merge.conflictStyle=zdiff3, September 2026

Merge conflicts are the case where AI assistance is most tempting and most consequential.

Tempting because conflict markers are mechanical, tedious, and a model reads them fluently. Consequential because the output is code that goes into your default branch, and because the failure mode is silent.

  1. Turn on a conflict style that shows the base. zdiff3 is worth configuring once.
  2. Give the model all three versions — yours, theirs, and the common ancestor — plus the surrounding code.
  3. Ask what each side was trying to do, before asking for a resolution.
  4. Read the proposed resolution against both intentions.
  5. Run the tests. Both branches’ tests, not just the ones that were already passing.

Step 1 does more for resolution quality than anything else on the page, and most people have never changed it.

Git’s default conflict style shows two versions. That is not enough information to resolve a conflict correctly, and it is not enough for a model either.

Terminal window
git config --global merge.conflictStyle zdiff3

What it doesSets the conflict style to zdiff3, which includes the common ancestor in conflict markers.

Why we run itWithout the base version, you cannot tell which side changed what — only that they differ.

Expected resultNo output. Subsequent conflicts include a third section.

A real conflict, from a repository where one branch added rounding and the other multiplied by quantity:

def total(items):
<<<<<<< HEAD
return round(sum(i.price for i in items), 2)
||||||| 1e50772
return sum(i.price for i in items)
=======
return sum(i.price * i.qty for i in items)
>>>>>>> feature

Three sections:

MarkerContains
<<<<<<< HEADYour side — the branch you are merging into
||||||| 1e50772The common ancestor — what both started from
======= Their side — the branch being merged
>>>>>>> featureEnd

With the base visible, the situation is unambiguous: HEAD added rounding. feature added quantity multiplication. Neither touched what the other touched.

Without the base, both sides look like complete rewrites of the line, and “pick one” looks like a reasonable strategy. It is not — and this example shows exactly why.

The correct resolution here is neither side

Section titled “The correct resolution here is neither side”

Read the two changes again. The right answer is:

def total(items):
return round(sum(i.price * i.qty for i in items), 2)

Both changes, composed. Taking HEAD loses quantity handling — every multi-item order is now undercharged. Taking feature loses rounding — totals grow floating-point noise.

Both wrong answers produce a file with no conflict markers. Both compile. Both look plausible in a diff. Only tests that cover quantities and rounding catch the mistake, and only if both exist.

This is the whole lesson in one example, and it is why the ordering in “the short answer” puts understanding intent before requesting a resolution.

Git reports textual conflicts: two branches changed the same lines. Those are the ones with markers, and they are the easy category — Git has told you where to look.

Semantic conflicts are changes that merge cleanly and are wrong together. Git reports nothing at all.

Examples:

  • One branch renames a function; another adds a call to the old name in a different file
  • One branch adds a required constructor parameter; another adds an instantiation elsewhere
  • One branch changes a unit from seconds to milliseconds; another adds a caller passing seconds
  • One branch tightens validation; another adds a code path that relied on the looser behaviour

None of these produce a conflict marker. All of them break the merged result.

AI can help find them, and only if you ask. After resolving the textual conflicts:

Both branches merged cleanly apart from the conflict we just resolved. Here is the diff of each branch against the merge base. Are there changes that do not conflict textually but might interact — renames, signature changes, changed units or assumptions?

That is a question a model is genuinely good at, because it involves reading two diffs and looking for relationships. It is also a question nobody asks, which is why semantic conflicts reach main.

The authoritative check remains the same one: build and run the tests. A compiler catches the renamed function; tests catch the changed unit.

Conflict resolution quality is bounded by context, more than most tasks — because the right answer depends on code that is not in the conflict hunk.

Always:

  • The full conflicted region, with zdiff3 markers intact
  • Enough surrounding code to see how the function is used
  • What each branch was for — the branch name, the pull request title, or one sentence

Often needed:

  • The tests covering the affected code
  • Callers of the changed function
  • Related conflicts in other files, which are frequently the same decision repeated

Useful commands:

Terminal window
{/* Which files are conflicted */}
git diff --name-only --diff-filter=U
Terminal window
{/* What each side changed relative to the merge base */}
git diff HEAD...MERGE_HEAD
git diff MERGE_HEAD...HEAD
Terminal window
{/* The three versions of a conflicted file, from the index */}
git show :1:cart.py # base
git show :2:cart.py # ours
git show :3:cart.py # theirs

Those last three are the precise version of “give it all three sides”, and they are exactly what zdiff3 inlines for you.

The sequence matters, and reversing it is the most common way this goes wrong.

First: what was each side trying to do?

Here is a conflict with the base version included. In one sentence each, what did HEAD change and what did feature change, relative to the base?

This is a reading comprehension task, it is checkable in seconds, and it surfaces the situation above — where the two changes are orthogonal and both are needed.

Second: what are the resolution options?

Given those two intentions, what are the possible resolutions, and what does each one lose?

Asking for options rather than the answer is the highest-leverage prompt change on this page. It turns an assertion into a decision you make, and it surfaces the “take one side” losses explicitly.

Third: produce the resolution.

Only after you have decided which option is right.

Fourth: what would test this?

What test would fail if we had taken HEAD’s version instead?

If the answer is “none of the existing tests”, you have found a coverage gap that is more important than the conflict.

Rebase conflicts are inverted, and this matters

Section titled “Rebase conflicts are inverted, and this matters”

A detail that trips up humans and misleads models: during a rebase, HEAD is not your work.

In a merge, HEAD is the branch you are on — your side. In a rebase, Git checks out the upstream branch and replays your commits onto it, so HEAD is the upstream and the >>>>>>> side is your commit being applied.

A real conflict from rebasing feature onto main:

<<<<<<< HEAD
MAIN
||||||| parent of 8fb9996 (feature change)
base
=======
FEATURE
>>>>>>> 8fb9996 (feature change)

HEAD is main. The commit hash on the closing marker is your commit. This is the reverse of the merge case, and the labels do not warn you.

Two consequences.

git checkout --ours and --theirs mean the opposite of what you expect. During a rebase, --ours is the upstream branch and --theirs is your commit. Using them from muscle memory built on merges discards exactly the wrong side.

Tell the model which operation you are in. A conflict hunk pasted without that context is ambiguous, and a model will assume the merge convention — that HEAD is your work. Every subsequent statement about “your change” and “their change” is then reversed.

The reliable phrasing when asking for help:

This conflict is from a rebase of my feature branch onto main. HEAD is main; the lower section is my commit being replayed.

The rebase lesson covers why the operation works this way. For this page the point is narrower: the same conflict text means different things depending on an operation that is not visible in the text.

The first example was two changes to one line. The commoner and nastier case is a conflict in one file whose correct resolution depends on a file that has no conflict at all.

Branch A renames calculateTotal() to calculateOrderTotal() and updates every call site it knows about.

Branch B, written in parallel, adds a new module that calls calculateTotal().

The merge produces one conflict, in whatever file both branches touched. Branch B’s new module merges cleanly and calls a function that no longer exists.

What resolving the visible conflict “correctly” gets you: a merged branch that does not build.

The question that finds it, asked after the textual resolution:

Branch A renamed calculateTotal to calculateOrderTotal. Here is branch B’s diff against the merge base. Does anything in it reference the old name?

That is a search a model does well and a human does badly across a large diff. It is also, once stated, obviously the question — which is the point: the technique is knowing to ask, not the asking.

The compiler would have caught this one. Plenty of variants — a changed unit, a changed default, a narrowed validation — compile fine and need the tests.

The steps that make a resolution trustworthy, in the order of what they catch.

  1. Check for leftover markers. Trivially caught and embarrassingly common:

    Terminal window
    git diff --check
  2. Build. Catches renamed symbols and signature mismatches — the semantic conflicts a compiler can see.

  3. Run your tests. The ones on your branch.

  4. Run their tests. The ones the incoming branch added. If the merged code breaks them, you have dropped their change — and this is the step people skip, because the tests were not there before.

  5. Read the merged diff against the base, not against either branch:

    Terminal window
    git diff $(git merge-base HEAD MERGE_HEAD) -- cart.py

    This shows the net effect of the merge, which is what actually ships.

  6. Ask what is now unreachable. A resolution that quietly removes a code path is a behaviour change nobody described.

Step 4 is the one that catches the failure this page opened with. Both wrong resolutions in the worked example pass your existing tests; only the incoming branch’s tests notice.

Long-lived branches produce the same conflict repeatedly — every rebase onto a moving upstream re-presents the resolutions you already made.

Git has a built-in answer that is better than asking a model the same question ten times:

Terminal window
git config --global rerere.enabled true

What it doesEnables reuse recorded resolution, so Git remembers how you resolved a conflict and replays it automatically next time the same conflict appears.

Why we run itA resolution you have already verified should not need re-deciding, and re-deciding it is where inconsistency creeps in.

Expected resultNo output. Subsequent identical conflicts are resolved automatically, and Git reports 'Resolved ... using previous resolution'.

The interaction with AI assistance is worth stating: rerere replays a resolution you verified; asking a model again produces a fresh answer that may differ. For a conflict you have already thought about and tested, the recorded resolution is strictly better.

The caveat is that rerere replays textual resolutions. If the surrounding code has moved on such that the old resolution is no longer semantically right, it will apply it anyway — so the tests still have to run.

A high conflict count remains a signal about branch lifetime rather than about tooling. rerere reduces the cost of a bad situation; short-lived branches avoid it.

AI assistance is a poor fit for some conflicts, and recognising them early saves time.

Conflicts in code you do not understand. You cannot evaluate the resolution, so you would be accepting it on faith. Read the code first, or ask the author.

Conflicts in security-sensitive paths. Authentication, authorisation, cryptography, input validation. The cost of a subtly wrong merge is too high, and these are exactly the areas where a plausible blend is dangerous.

Conflicts in generated files. Lock files, compiled assets, migrations. The answer is usually to regenerate rather than to merge — and a hand-merged lock file is a supply-chain problem, since it produces a dependency set nobody resolved.

Conflicts that indicate a bad merge. Twenty conflicts across the codebase usually means the branch diverged too far. The fix is a conversation about branching, not a better resolution — see short-lived branches.

Anything where git merge --abort is cheaper. Aborting and rebasing, or asking the branch author to resolve, is often the right call. It is always available before you commit:

Terminal window
git merge --abort

Some conflicts deserve to be treated as a security question rather than a merge question, and it is worth being able to recognise them quickly.

Anything in an authorisation path. A conflict in a permission check, a role comparison, or a middleware ordering. A resolution that drops one side’s tightening is an authorisation bypass that compiles and passes the tests written before the tightening existed.

Dependency and lock file conflicts. Hand-merging a lock file produces a dependency set that no resolver ever computed and nobody has tested — see dependency security. Regenerate from the merged manifest instead.

Workflow file conflicts. A conflict in .github/workflows/ is a conflict about what runs with your repository’s credentials. It deserves the review a privilege change gets, and a CODEOWNERS entry on /.github/ makes that automatic.

Input validation and sanitisation. The same pattern as the worked example — two branches tightening different things, and a resolution keeping one.

Configuration that differs by environment. A conflict where one side is production configuration is a conflict where “pick the other one” has an operational consequence.

For all five, the rule is the same and is stricter than the rest of this page: use AI to explain the two sides, and decide the resolution yourself. The explanation is the useful part; the decision is the part with consequences.

Resolving without the base. The default conflict style hides the information that makes the decision obvious. Configure zdiff3 once.

Taking one side because it looks complete. Both sides look complete. That is what a conflict is.

Accepting a resolution without running tests. No conflict markers is not correctness.

Not running the incoming branch’s tests. They are the ones that catch a dropped change.

Asking for a resolution before understanding the intentions. You get an answer you cannot evaluate.

Resolving conflicts in generated files by hand. Regenerate instead.

Using AI on security-critical merges. The blast radius does not justify the time saved.

Treating a large conflict count as a resolution problem. It is usually a branching problem.

Most of this page is technique rather than tool, but three surfaces change the ergonomics enough to mention.

Editor merge UIs. VS Code and JetBrains both present three-way merge views that show the base alongside both sides — the same information zdiff3 puts in the file, laid out better. Where Copilot is integrated, the resolution suggestion arrives with that context already attached, which is one fewer thing to assemble by hand.

Copilot CLI. It can run the Git commands itself — read the conflicted files, fetch the three versions from the index, check what the incoming branch changed — rather than being handed output. For a multi-file conflict this is a meaningful saving, because gathering the context is most of the work.

Agent mode. Capable of resolving conflicts across several files and running the tests afterwards. That combination is genuinely useful and is also the configuration where the failure mode on this page is easiest to hit: the loop ends when the tests it knows about pass, which is not the same as the merge being right. Supervise it, and run the incoming branch’s tests yourself.

None of these change the verification requirement. They change how quickly you reach the point where verification is the only thing left to do.

A merge conflict is Git saying “two people changed this and I will not guess”. Resolving it means reconstructing both intentions and producing code that satisfies both. AI is good at the reconstruction and cannot verify the result — which is what tests are for.

  • merge.conflictStyle=zdiff3 shows the common ancestor, which is what makes the decision decidable
  • The correct resolution is frequently neither side but both changes composed
  • Both wrong resolutions produce a file with no conflict markers
  • Semantic conflicts merge cleanly and break the result; Git reports nothing
  • Ask what each side intended, then for options, then for a resolution — in that order
  • git show :1:, :2: and :3: give the base, ours and theirs from the index
  • git diff --check catches leftover markers
  • Running the incoming branch’s tests is what catches a dropped change
  • Generated files should be regenerated, not merged
  • git merge --abort is available until you commit

Use a disposable repository and reproduce the worked example.

  1. Set merge.conflictStyle to zdiff3. Create a file with a total() function.

  2. On main, wrap the sum in round(..., 2). On feature, multiply price by quantity. Merge.

  3. Look at the conflict. Predict: with the base visible, is it obvious that both changes are needed?

  4. Reconfigure to the default merge conflict style and produce the same conflict. Predict: is it still obvious?

  5. Ask an assistant to resolve it, giving only the two-sided version. Predict: does it pick a side or compose them?

  6. Ask again with the base included, and with the “what did each side intend” question first. Compare.

  7. Write a test that fails if quantity is ignored, and one that fails if rounding is dropped. Re-check each candidate resolution against both.

  8. Delete the repository.

AI-assisted engineering learning pathEleven lessons on getting value from Copilot and agents without giving up review discipline.