So far we have been lucky. Every merge in this module has gone well because Ana and Bruno were touching different parts of the files. The luck runs out here: in this lesson the two of them are going to modify the same lines of the same function, each for a perfectly reasonable motive, and Git is going to stop halfway through the merge.

Merge conflicts have a bad reputation, and they do not deserve it. A conflict is not an error, it is not corruption and it does not mean you have done anything wrong. It is Git telling you, with complete honesty: "there are two incompatible changes to the same lines here and I have no basis for choosing; you decide". The alternative — an algorithm deciding on its own whose code survives — would be far worse.

What does cause anxiety is the feeling of being trapped: the repository in a strange state, odd symbols inside the code and the nagging doubt that you have just broken something. This lesson removes that anxiety. By the end of it you will know exactly what state your repository is in during a conflict, how to read what Git has written into the file, how to resolve it and, if it all gets too much, how to back out without leaving a trace.

Contents

  1. Why a conflict happens
  2. Provoking a real conflict
  3. The conflict markers
  4. The diff3 and zdiff3 styles: seeing the ancestor as well
  5. Finding your bearings during a conflict: git status
  6. git diff during a conflict: the three versions
  7. Resolving and marking as resolved
  8. Shortcuts: --ours and --theirs per file
  9. Backing out: --abort and --quit
  10. git mergetool: graphical tools
  11. Special conflicts: deleted versus modified
  12. git rerere: never resolve the same thing twice

  1. Why a conflict happens

Remember the three-way merge decision table from lesson 03-03. For every chunk of a file, Git compares three versions: the common ancestor's (the base), your branch's (ours) and that of the branch you are merging (theirs).

Changed in ours Changed in theirs Result
No No The base is kept
Yes No ours wins
No Yes theirs wins
Yes Yes, identically That shared version wins
Yes Yes, differently CONFLICT

Only the last row produces a conflict: both branches changed the same thing in different ways.

And we need to be precise about what "the same thing" means, because it is the source of a very widespread fear:

  • Two people touching the same file does NOT produce a conflict. If Ana edits line 10 and Bruno line 200, Git combines the two without saying a word.
  • Touching the same lines does produce one, as do lines close enough together to fall into the same chunk (hunk) of the diff. Git works with a three-line margin of context, so changes separated by one or two lines can clash too.

Besides content conflicts, there are tree conflicts, which are about the existence or the location of a file rather than what is inside it:

Type Situation
Content Both branches modify the same lines
Delete/modify One branch deletes the file, the other modifies it
Add/add Both branches create a file with the same name and different content
Rename/delete One branch renames it, the other deletes it
Rename/rename Both rename it, with different names
Directory/file One branch creates reports/ and the other a file called reports

Content conflicts are 90 % of cases and they are what we will look at first. Delete/modify, being the second most frequent, gets its own section.

  1. Provoking a real conflict

Back to the project. main is up to date with everything integrated in the previous lessons, and app.js contains this function, which renders the task list on screen:

function renderList() {
  const list = document.getElementById('list');
  list.innerHTML = '';
  tasks.forEach(function (t) {
    list.appendChild(createTaskElement(t));
  });
  updateCounter();
}

Ana opens a branch to show the tasks sorted alphabetically:

git switch -c feature/alphabetical-order main

And modifies the function:

function renderList() {
  const list = document.getElementById('list');
  list.innerHTML = '';
  const sorted = tasks.slice().sort(function (a, b) {
    return a.text.localeCompare(b.text);
  });
  sorted.forEach(function (t) {
    list.appendChild(createTaskElement(t));
  });
  updateCounter();
}
git commit -am "Show the tasks in alphabetical order"
[feature/alphabetical-order a1e5c93] Show the tasks in alphabetical order
 1 file changed, 4 insertions(+), 1 deletion(-)

Bruno, meanwhile, opens another branch from the same point to fix something that has been reported to him: when there are no tasks, the screen goes blank and the application looks broken.

git switch -c fix/empty-list-message main
function renderList() {
  const list = document.getElementById('list');
  list.innerHTML = '';
  if (tasks.length === 0) {
    list.innerHTML = '<li class="empty">No pending tasks</li>';
    updateCounter();
    return;
  }
  tasks.forEach(function (t) {
    list.appendChild(createTaskElement(t));
  });
  updateCounter();
}
git commit -am "Show a message when the list is empty"
[fix/empty-list-message 6d3f8b2] Show a message when the list is empty
 1 file changed, 5 insertions(+)
gitGraph
   commit id: "3b9e7d1"
   branch alphabetical-order
   checkout alphabetical-order
   commit id: "a1e5c93"
   checkout main
   branch empty-list-message
   checkout empty-list-message
   commit id: "6d3f8b2"

Both changes are good and the project wants both of them. But they both insert code at exactly the same spot: between list.innerHTML = ''; and the forEach. Git has no way of knowing whether Bruno's if goes before or after Ana's sort, nor whether the forEach should walk over tasks or sorted.

Ana integrates hers first, and it goes in cleanly:

git switch main
git merge --no-ff --no-edit feature/alphabetical-order
Merge made by the 'ort' strategy.
 app.js | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

And now Bruno's:

git merge --no-ff fix/empty-list-message
Auto-merging app.js
CONFLICT (content): Merge conflict in app.js
Automatic merge failed; fix conflicts and then commit the result.

There it is. Let us dissect it.

  1. The conflict markers

The first thing to understand: Git has modified your file on disk. app.js no longer contains valid JavaScript, but code with annotations inside it:

function renderList() {
  const list = document.getElementById('list');
  list.innerHTML = '';
<<<<<<< HEAD
  const sorted = tasks.slice().sort(function (a, b) {
    return a.text.localeCompare(b.text);
  });
  sorted.forEach(function (t) {
=======
  if (tasks.length === 0) {
    list.innerHTML = '<li class="empty">No pending tasks</li>';
    updateCounter();
    return;
  }
  tasks.forEach(function (t) {
>>>>>>> fix/empty-list-message
    list.appendChild(createTaskElement(t));
  });
  updateCounter();
}

There are three markers by default:

Marker Meaning
<<<<<<< HEAD Your branch's version starts here (ours). The label says where it comes from
======= Separator: ours ends, theirs begins
>>>>>>> fix/empty-list-message The other branch's version ends here (theirs), labelled with its name

Important observations that tend to get missed:

  1. Only the conflicting area carries markers. The first three lines of the function and the last three are clean: Git merged them with no trouble. A 400-line file with a 6-line conflict has 394 lines already resolved.

  2. A file can have several conflict blocks, each with its own set of markers. All of them have to be resolved.

  3. The markers are ordinary text. There is nothing magic about them: you can edit them, delete them or leave them (if you leave them, git commit will warn you, but you can force it through and put rubbish into the project, so always check).

  4. The label on the right is the name of the merged branch, which helps enormously when you are merging several things in a row and have lost track.

And here is the problem with the default format: you cannot see what was there before. Is the forEach over tasks in Bruno's version a change of his, or simply the original code he never touched? With this format you cannot tell, and that information is exactly what you need to resolve well. Which is what the next section is for.

  1. The diff3 and zdiff3 styles: seeing the ancestor as well

Git can show three sections instead of two, adding the common ancestor's. It is controlled by the merge.conflictStyle option:

git config --global merge.conflictStyle diff3

Let us abort and redo the merge to see it:

git merge --abort
git merge --no-ff fix/empty-list-message

Now app.js contains:

function renderList() {
  const list = document.getElementById('list');
  list.innerHTML = '';
<<<<<<< HEAD
  const sorted = tasks.slice().sort(function (a, b) {
    return a.text.localeCompare(b.text);
  });
  sorted.forEach(function (t) {
||||||| 3b9e7d1
  tasks.forEach(function (t) {
=======
  if (tasks.length === 0) {
    list.innerHTML = '<li class="empty">No pending tasks</li>';
    updateCounter();
    return;
  }
  tasks.forEach(function (t) {
>>>>>>> fix/empty-list-message
    list.appendChild(createTaskElement(t));
  });
  updateCounter();
}

The new block, delimited by ||||||| and labelled with the common ancestor's hash, holds the original code. And now it reads completely differently:

  • The ancestor had tasks.forEach(...).
  • Ana replaced it with the sort block plus sorted.forEach(...).
  • Bruno added the if in front and did not touch the forEach.

With that information the resolution is obvious: you have to keep both things, putting Bruno's if first and honouring Ana's change from tasks to sorted.

Without the ancestor you would have had to deduce it or dig through the history. With it, you see it at a glance.

zdiff3, better still

Since Git 2.35 there is one more style: zdiff3 (from zealous diff3). It does the same as diff3 but also lifts the lines common to both sides out of the conflict:

git config --global merge.conflictStyle zdiff3

In conflicts where the two sides share lines at the start or the end of the block, the conflicting area shrinks noticeably and only what genuinely disagrees is left inside.

Style Sections Available since Recommendation
merge 2 (ours, theirs) Always The default; the worst of the three
diff3 3 (ours, base, theirs) Always Far better; adopt it
zdiff3 3, with the shared lines outside Git 2.35 (2022) The best if your version supports it

This is probably the highest-return piece of advice in the whole lesson. Set it right now:

git config --global merge.conflictStyle zdiff3

It fits with what you already configured in lesson 01-06 and it has no downside whatsoever.

  1. Finding your bearings during a conflict: git status

During a conflict the repository is in a special state. git status is your compass, and it changes completely:

git status
On branch main
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  (use "git add <file>..." to mark resolution)
	both modified:   app.js

no changes added to commit (use "git add" to commit)

The key elements:

  • You have unmerged paths: there is a half-finished merge.
  • Unmerged paths: a new section, distinct from "staged" and "unstaged". Conflicted files go here.
  • both modified: the type of conflict. Others you may see: deleted by us, deleted by them, both added, added by us.
  • Git reminds you of the two ways out: resolve and commit, or abort.

In short format:

git status --short
UU app.js

UU means unmerged in both columns. The full table of conflict codes:

Code Meaning
UU Modified by both (both modified)
AA Added by both (both added)
DD Deleted by both
AU Added by us, unmodified by them
UA Added by them
DU Deleted by us, modified by them
UD Modified by us, deleted by them

If all you want is the list of outstanding files, with no noise:

git diff --name-only --diff-filter=U
app.js

That is the command used in scripts and editor shortcuts to jump from one conflict to the next.

What is inside .git at this moment

It is worth looking once, because it demystifies the state:

ls .git/MERGE_HEAD .git/MERGE_MSG
.git/MERGE_HEAD  .git/MERGE_MSG
cat .git/MERGE_HEAD
6d3f8b2e4a9c1f5b7d3a8e2c6b4f9d1a5c7e3b8f

MERGE_HEAD holds the commit you are merging. Its existence is what defines "being in a merge": it is why git commit knows to create a commit with two parents, and why git merge --abort knows what to undo. MERGE_MSG holds the proposed message.

And the index (the staging area) is special right now too. Normally it holds one version of each file; during a conflict it holds three, numbered by stages:

git ls-files -u
100644 8e1f5b3d7a2c9e4b6f1d8a3c5e7b2d9f4a6c8e1b 1	app.js
100644 2a7c9e4f1b6d8a3c5e2f7b9d4a1c6e8b3f5d7a2c 2	app.js
100644 9f3b7d1a5c8e2f6b4d9a1c7e3b5f8d2a6c4e9b1f 3	app.js
Stage Version
1 The common ancestor's (the base)
2 Your branch's (ours)
3 The merged branch's (theirs)

Those three stages are what make everything that follows possible: the special diffs, --ours/--theirs and the graphical tools. When you resolve and run git add, the three stages are replaced by a single ordinary version, and that is how Git knows the file is now resolved.

  1. git diff during a conflict: the three versions

A plain git diff during a conflict shows a format you have not seen before: the combined diff.

git diff
diff --cc app.js
index 2a7c9e4,9f3b7d1..0000000
--- a/app.js
+++ b/app.js
@@@ -10,7 -10,11 +10,16 @@@ function renderList()
    const list = document.getElementById('list');
    list.innerHTML = '';
++<<<<<<< HEAD
 +  const sorted = tasks.slice().sort(function (a, b) {
 +    return a.text.localeCompare(b.text);
 +  });
 +  sorted.forEach(function (t) {
++=======
+   if (tasks.length === 0) {
+     list.innerHTML = '<li class="empty">No pending tasks</li>';
+     updateCounter();
+     return;
+   }
+   tasks.forEach(function (t) {
++>>>>>>> fix/empty-list-message
      list.appendChild(createTaskElement(t));
    });
    updateCounter();

Notice that there are two columns of +/- markers instead of one, and that the header @@@ has three at-signs. Each column corresponds to one parent. It is a dense format; in practice the pairwise diffs get used far more.

To compare the conflicted file against each of the three versions:

# Against the common ancestor's version (stage 1)
git diff --base app.js

# Against your branch's version (stage 2)
git diff --ours app.js

# Against the merged branch's version (stage 3)
git diff --theirs app.js

You can also pull out any of the three complete versions to look at in isolation, with the :<stage>:<path> syntax:

git show :1:app.js    # the ancestor's
git show :2:app.js    # yours
git show :3:app.js    # the other branch's

This is enormously useful when the conflict is large and you want to read each version in full without markers, instead of trying to decipher the mixed-up file. For example, to save Bruno's version to a separate file and consult it while you edit:

git show :3:app.js > /tmp/bruno-version.js

  1. Resolving and marking as resolved

Resolving a conflict means, quite simply, leaving the file the way it should end up. Nothing more, nothing less. There is no magic command: you edit the file, remove the markers and write the correct code.

In our case, the ||||||| block tells us the two contributions have to be combined. Ana edits app.js and leaves it like this:

function renderList() {
  const list = document.getElementById('list');
  list.innerHTML = '';
  if (tasks.length === 0) {
    list.innerHTML = '<li class="empty">No pending tasks</li>';
    updateCounter();
    return;
  }
  const sorted = tasks.slice().sort(function (a, b) {
    return a.text.localeCompare(b.text);
  });
  sorted.forEach(function (t) {
    list.appendChild(createTaskElement(t));
  });
  updateCounter();
}

No markers. Bruno's if goes first (if there are no tasks, there is no point sorting them) and Ana's sort after it. This is code that existed on neither branch: it is the synthesis only a person could produce. That is exactly what Git was asking you for.

Before calling anything done, you have to test it. A conflict resolved without running the code is a gamble:

# Check that no forgotten marker is left behind
grep -n '^<<<<<<<\|^=======\|^>>>>>>>\|^|||||||' app.js
(no output)

And after that, open the application or run the tests.

Now you mark it as resolved:

git add app.js

git add on a conflicted file has a special meaning: it replaces the three index stages with the version on disk and declares the conflict resolved. It is not "stage a change"; it is "I have decided, this is the good version".

git status
On branch main
All conflicts fixed but you are still merging.
  (use "git commit" to conclude merge)

Changes to be committed:
	modified:   app.js

All conflicts fixed but you are still merging: the merge is still under way (MERGE_HEAD still exists), but nothing is blocked any more. All that is left is to commit:

git commit

Git opens the editor with the message it saved in MERGE_MSG, now with an extra note:

Merge branch 'fix/empty-list-message'

# Conflicts:
#	app.js
#
# It looks like you may be committing a merge.
# If this is not correct, please run
#	git update-ref -d MERGE_HEAD
# and try again.

Describing how you resolved it is an excellent habit, because whoever reads this a year from now will be grateful:

Merge the empty-list message

Conflict in renderList(): Bruno's branch added the empty-list check
and Ana's added the alphabetical ordering, both at the same spot.
We keep both: first the early return when there are no tasks, then
the sorting.
[main c2a8f1e] Merge the empty-list message
git log --oneline --graph -5
*   c2a8f1e (HEAD -> main) Merge the empty-list message
|\
| * 6d3f8b2 (fix/empty-list-message) Show a message when the list is empty
* |   e1f3a7b Merge branch 'feature/alphabetical-order'
|\ \
| * | a1e5c93 (feature/alphabetical-order) Show the tasks in alphabetical order
|/ /
* / 3b9e7d1 Add CSV export of the task list
|/

And this is where --cc, which we saw in lesson 03-03, comes into its own:

git show --cc c2a8f1e

It shows only the lines that differ from both parents, which is to say exactly the code Ana wrote by hand while resolving. It is the way to audit a conflict resolution without reading the whole file.

  1. Shortcuts: --ours and --theirs per file

Sometimes there is nothing to synthesise: one of the two versions is simply the right one. Typical with automatically generated files, dependency lock files, or when you know one side's work made the other's obsolete.

# Keep your branch's version, in full
git checkout --ours app.js

# Keep the merged branch's version, in full
git checkout --theirs app.js

Or with the modern commands we saw in lesson 03-02:

git restore --ours app.js
git restore --theirs app.js

These commands overwrite the file on disk with stage 2 or stage 3 from the index, markers and all left out. Afterwards you still have to mark it as resolved:

git restore --theirs packages.lock
git add packages.lock

Two important warnings:

  1. They take the WHOLE file, not just the conflicting area. If app.js also had changes from the other branch elsewhere that merged perfectly well, --ours throws those away too. Use it only when you genuinely want one complete version.

  2. Do not confuse them with -X ours/-X theirs from the previous lesson. These act file by file, on a conflict that has already happened; those act globally, before the conflict appears at all.

A very practical pattern when there are several files and only some of them are "generated":

# Resolve the code files by hand
vim app.js
git add app.js

# The generated ones, with the incoming version
git restore --theirs dist/bundle.js packages.lock
git add dist/bundle.js packages.lock

git commit

And if you would rather start again on a file you have mangled while editing:

git checkout --merge app.js

That restores the file with the original conflict markers, exactly as it was right after the failed merge.

  1. Backing out: --abort and --quit

If you have lost the thread, if the conflict is far bigger than you expected or if you would simply rather do it another time, you can leave without a trace:

git merge --abort
(no output)
git status
On branch main
nothing to commit, working tree clean

As if the merge had never happened. git merge --abort undoes everything: it restores the working tree and the index to the state before git merge, and it deletes MERGE_HEAD and MERGE_MSG.

It is the most reassuring operation in Git and it is worth internalising: during a conflict you are never trapped. There is always an escape key.

One precaution: if you had uncommitted changes before starting the merge, --abort may not be able to recover the exact state. That is why the advice from lesson 03-03 — merge with a clean working tree — is not a personal quirk.

There is also a strange cousin:

git merge --quit

--quit leaves the merge state (it deletes MERGE_HEAD) but leaves the working tree and the index exactly as they are, markers and all. It is for very specific cases: you want to keep the half-finished result but you do not want Git to create a merge commit with two parents. If you do not know that you need it, you do not need it: use --abort.

Command Merge state Working tree When
git merge --abort Cancelled Restored to the previous state Almost always
git merge --quit Cancelled Left as it is Advanced cases
git commit Completed What you resolved is kept Once you have resolved

  1. git mergetool: graphical tools

For large conflicts, editing markers by hand is awkward. git mergetool opens a three- or four-panel tool: base, ours, theirs and result.

git mergetool
Merging:
app.js

Normal merge conflict for 'app.js':
  {local}: modified file
  {remote}: modified file
Hit return to start merge resolution tool (vimdiff):

If you have not configured one, Git picks the first it finds installed. To pin one down:

# Meld (cross-platform, highly recommended to start with)
git config --global merge.tool meld

# VS Code
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait --merge $REMOTE $LOCAL $BASE $MERGED'

# KDiff3
git config --global merge.tool kdiff3

# vimdiff
git config --global merge.tool vimdiff

And an option that almost everybody ends up turning on:

git config --global mergetool.keepBackup false

Without it, git mergetool leaves *.orig files holding the conflicted version scattered everywhere, and you then have to delete them by hand (or ignore them in .gitignore).

The four variables the tool receives, worth understanding if you want to configure your own:

Variable Content
$BASE The common ancestor's version (stage 1)
$LOCAL Your version (stage 2, ours)
$REMOTE The incoming version (stage 3, theirs)
$MERGED The destination file, where you write the result

When you save and quit the tool, git mergetool marks the file as resolved automatically (it does the git add for you) and moves on to the next conflict if there is one.

A practical note: today most modern editors ship their own conflict resolver, which detects the markers in the file and offers buttons along the lines of "accept current / accept incoming / accept both". They work on the very same marker-laden file we have seen, so everything learnt here applies unchanged. git mergetool is still useful when you want the four panels with the ancestor in plain view.

  1. Special conflicts: deleted versus modified

The second most frequent kind of conflict does not happen inside the file, but over its existence.

The situation: the project had a file internal-notes.md with jottings from the early days of development. Ana decided it was obsolete and deleted it on her branch:

git switch -c cleanup/delete-notes main
git rm internal-notes.md
git commit -m "Delete the internal notes, now obsolete"

Bruno, who did not know that, updated it on his:

git switch -c docs/update-notes main
# … edits internal-notes.md …
git commit -am "Update the internal notes with the new workflow"

Ana merges hers into main (no trouble) and then Bruno's:

git merge docs/update-notes
CONFLICT (modify/delete): internal-notes.md deleted in HEAD and modified in
docs/update-notes.  Version docs/update-notes of internal-notes.md left in
tree.
Automatic merge failed; fix conflicts and then commit the result.

Notice two things:

  1. The conflict type is modify/delete, not content.
  2. There are no markers inside the file. There would be no point: the conflict is not about its content, but about whether it should exist. Git has left Bruno's version on disk, as the message says.
git status
On branch main
You have unmerged paths.

Unmerged paths:
  (use "git add/rm <file>..." as appropriate to mark resolution)
	deleted by us:   internal-notes.md

deleted by us: we (the branch we are standing on) deleted it, they modified it. Git cannot decide whether the deletion was right or whether Bruno's updates justify keeping it; that depends on why it was deleted, and only a person knows that.

Resolving means declaring what should happen, and there are only two options:

# Option A: keep the deletion (the file should no longer exist)
git rm internal-notes.md
internal-notes.md: needs merge
rm 'internal-notes.md'
# Option B: keep the file with Bruno's changes
git add internal-notes.md

In both cases, afterwards:

git commit

Before deciding, it is worth looking at what those changes contained, in case Bruno wrote something that deserves keeping somewhere else:

git show docs/update-notes:internal-notes.md

And if the answer is "yes, it is worth keeping, but not here", you can rescue the content into another file before resolving.

The other tree conflicts

They are resolved with the same logic: git add if the file should exist with the content it has on disk, git rm if it should not exist.

CONFLICT (add/add): Merge conflict in config.json

Both branches created the same file with different content. Here there are markers inside (Git treats emptiness as the base), so it is resolved like an ordinary content conflict.

CONFLICT (rename/rename): Rename styles.css->css/styles.css in HEAD.
Rename styles.css->assets/styles.css in branch-b

Both branches moved it somewhere different. You decide on the right location, put the file there, delete the other copies and mark everything with git add/git rm.

  1. git rerere: never resolve the same thing twice

One last note, so you know it exists when you need it.

rerere comes from reuse recorded resolution. If you turn it on, Git memorises how you resolved each conflict and, when the same conflict comes round again, it resolves it on its own using your earlier decision.

git config --global rerere.enabled true

When does a conflict repeat itself? More often than you would think: when you merge main into your working branch every few days, when you abort a merge and retry it, when you redo a branch with rebase several times (module 5), or when you work with long-lived branches.

It is an advanced tool and it has its subtleties — it is worth reviewing whatever it resolves on its own, especially if the earlier resolution was rushed — so we only mention it here. Knowing that it exists and that it is turned on with that one line is enough for now.

Common Mistakes and Tips

Mistake 1: leaving markers in the code. Committing a file with <<<<<<< inside breaks the project spectacularly. Git warns you if it spots markers when you commit, but it is not infallible. Always check before git add:

git diff --check
grep -rn '^<<<<<<<' .

Mistake 2: resolving by "picking a side" without thinking. The urge to take --ours and be done with it destroys the other side's work, including changes that had merged perfectly well elsewhere in the file. Read the conflict: in most cases the right answer is to keep both things, as in this lesson's example.

Mistake 3: not testing the result. A resolved conflict can compile and still be wrong: variables left unused, duplicated functions, logic that runs twice. Run the application or the tests before committing.

Mistake 4: panicking and deleting the repository. This happens more often than it should. git merge --abort leaves everything exactly as it was. There is no conflict situation you cannot walk away from.

Mistake 5: confusing --ours/--theirs during a conflict with -X ours/-X theirs. The former act on one specific file, with the merge already stopped. The latter are a global policy applied beforehand. And in a rebase, on top of that, the roles of ours and theirs are swapped compared with what you would expect (lesson 05-01).

Tip 1: turn zdiff3 on today. Seeing the common ancestor transforms the quality of your resolutions:

git config --global merge.conflictStyle zdiff3

Tip 2: small, frequent conflicts instead of large, rare ones. The best technique for resolving conflicts is having fewer. Short-lived branches, frequent integration and bringing main into your branch every few days turn one 300-line conflict into five three-line ones.

Tip 3: if the conflict is enormous, abort and study it. Before wrestling with it, work out why it is there:

git merge --abort
git log --oneline main..the-other-branch
git diff main...the-other-branch --stat

Sometimes the conclusion is that you need to talk to the other person before touching anything. That is a valid answer too.

Tip 4: document the resolution in the merge commit message. A merge commit with conflicts contains human decisions that exist nowhere else. Explaining in three lines what clashed and what was decided saves whoever comes next a lot of archaeology.

Tip 5: when the decision is about someone else's code, ask. If the conflict is in code you do not know, resolving it by eye is a gamble. A thirty-second message to whoever wrote it is cheaper than a production failure.

Exercises

Exercise 1: provoking and resolving a content conflict

Set up a test repository, provoke a conflict in which both branches modify the same line, and resolve it by combining both contributions. Do it first with the default conflict style and then with diff3, and explain what extra information the second one gives you.

Exercise 2: the delete versus modify conflict

Provoke a modify/delete conflict and resolve it both possible ways (keeping the file and keeping the deletion). Before deciding, show the content the modifying branch was contributing.

Exercise 3: rescuing the three versions

In a content conflict, without opening any editor, save the three versions of the file (base, ours and theirs) separately in /tmp and compare the base against each of the other two. Then abort the merge.

Solutions

Solution 1:

mkdir /tmp/practice-conflict && cd /tmp/practice-conflict
git init -b main
cat > greeting.js <<'END'
function greet(name) {
  return "Hello " + name;
}
END
git add . && git commit -m "Add the greeting function"

# Branch A: adds an exclamation mark
git switch -c branch-a
cat > greeting.js <<'END'
function greet(name) {
  return "Hello " + name + "!";
}
END
git commit -am "Add an exclamation mark"

# Branch B: adds the surname
git switch main
git switch -c branch-b
cat > greeting.js <<'END'
function greet(name, surname) {
  return "Hello " + name + " " + surname;
}
END
git commit -am "Include the surname in the greeting"

# We merge
git switch main
git merge branch-a
git merge branch-b
Auto-merging greeting.js
CONFLICT (content): Merge conflict in greeting.js

With the default style:

function greet(name) {
<<<<<<< HEAD
  return "Hello " + name + "!";
=======
}
function greet(name, surname) {
  return "Hello " + name + " " + surname;
>>>>>>> branch-b
}

With diff3:

git merge --abort
git config merge.conflictStyle diff3
git merge branch-b
<<<<<<< HEAD
function greet(name) {
  return "Hello " + name + "!";
||||||| 8a1d5c3
function greet(name) {
  return "Hello " + name;
=======
function greet(name, surname) {
  return "Hello " + name + " " + surname;
>>>>>>> branch-b

What diff3 adds: it makes plain that the base was "Hello " + name, that branch A only added the exclamation mark and that branch B only added the parameter and the surname. Without the base block you had to deduce that by mentally comparing the two sides, and it was easy to get wrong about what had actually changed.

A resolution that combines both contributions:

cat > greeting.js <<'END'
function greet(name, surname) {
  return "Hello " + name + " " + surname + "!";
}
END
grep -c '<<<<<<<' greeting.js
0
git add greeting.js
git commit -m "Merge the surname and the exclamation mark

Conflict in greet(): branch-a added the exclamation mark and
branch-b the surname parameter. We keep both changes."

Solution 2:

mkdir /tmp/practice-delete && cd /tmp/practice-delete
git init -b main
echo "Project notes" > notes.md
echo "content" > other.txt
git add . && git commit -m "Base"

# The branch that deletes
git switch -c delete-notes
git rm notes.md
git commit -m "Delete the obsolete notes"

# The branch that modifies
git switch main
git switch -c update-notes
echo "Project notes - revision 2" > notes.md
git commit -am "Update the notes"

# Merge
git switch main
git merge delete-notes          # fast-forward, no trouble
git merge update-notes
CONFLICT (modify/delete): notes.md deleted in HEAD and modified in
update-notes.  Version update-notes of notes.md left in tree.
git status --short
DU notes.md

Before deciding, we look at what the branch was contributing:

git show update-notes:notes.md
Project notes - revision 2

Option A: keep the deletion.

git rm notes.md
git commit -m "Merge update-notes keeping the deletion of notes.md"
ls
other.txt

Option B: keep the file (starting from git merge --abort and repeating).

git add notes.md
git commit -m "Merge update-notes keeping the updated notes.md"
cat notes.md
Project notes - revision 2

The rule that settles every tree conflict: git add if the file should exist, git rm if it should not.

Solution 3:

Starting from a content conflict in progress over greeting.js:

git show :1:greeting.js > /tmp/base.js      # common ancestor
git show :2:greeting.js > /tmp/ours.js      # our branch
git show :3:greeting.js > /tmp/theirs.js    # merged branch
diff /tmp/base.js /tmp/ours.js
2c2
<   return "Hello " + name;
---
>   return "Hello " + name + "!";
diff /tmp/base.js /tmp/theirs.js
1,2c1,2
< function greet(name) {
<   return "Hello " + name;
---
> function greet(name, surname) {
>   return "Hello " + name + " " + surname;

The two diffs, separately and without markers, show with complete clarity what each branch did relative to the starting point. It is the technique that rescues large conflicts: instead of reading a mixed-up file, you read the two changes one at a time.

git merge --abort
git status
On branch main
nothing to commit, working tree clean

Everything as it was.

Conclusion

Conflicts have stopped being a mystery:

  • A conflict is not an error. It happens when both branches changed the same lines in different ways since the common ancestor. Two people touching the same file is not enough: they have to clash on the same lines.
  • Git writes markers into the file: <<<<<<< opens your version, ======= separates and >>>>>>> closes the incoming one. With merge.conflictStyle set to diff3 or zdiff3 you also get the ||||||| block holding the ancestor's content, which is the information that really lets you resolve well.
  • git status is the compass: the Unmerged paths section, the conflict type (both modified, deleted by us…) and the short codes (UU, DU, AA). Internally, the index holds three stages of the file, reachable with git show :1:, :2: and :3:, and MERGE_HEAD is what defines a merge as being in progress.
  • Resolving means leaving the file as it should end up and marking it with git add (or git rm if it should not exist). There is no magic command: there is a human decision, and often the right answer is to combine both contributions.
  • The shortcuts git checkout --ours/--theirs <file> (or git restore --ours/--theirs) replace the whole file with one of the versions. Useful for generated files; dangerous if the file also had changes that merged cleanly.
  • You are never trapped: git merge --abort restores the previous state completely. --quit leaves the merge while keeping the mess, and it is for advanced cases.
  • git mergetool opens a multi-panel tool with base, ours, theirs and result, and marks the file as resolved when you save.
  • Tree conflicts (delete/modify, add/add, renames) carry no markers: you resolve them by declaring what should exist.
  • git rerere memorises resolutions and replays them when the same conflict comes back.

What comes next

The project is in good shape: main holds the counter, the filter, the CSV export, the alphabetical ordering and the empty-list message. But Ana's repository is starting to look like a junk drawer. She has branches for features that are already integrated, branches from abandoned experiments, branches whose name nobody remembers and one simply called test.

In the last lesson of the module, Branch Management, we will tidy up: listing branches with useful information (-v, --merged, --no-merged, custom formats sorted by date), renaming them, deleting them safely (-d versus -D) and understanding exactly what it means when Git refuses to delete a branch. We will also look at naming conventions — which characters Git accepts and which prefixes people use — and we will close the module with the problem we have been dodging all lesson: all of this is happening on a single laptop.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved