Mert Özen Further With Every Line
DevOps Git & GitHub — #10

Resolving Git Conflicts: Markers, merge --abort and zdiff3

Mert Özen Nov 6, 2026 9 min 2 views
Resolving Git Conflicts: Markers, merge --abort and zdiff3

We see with real output why conflicts happen, how to read the markers, how to inspect the state with git status, git log --merge and git ls-files -u, resolving step by step, git merge --abort, merge.conflictStyle zdiff3, --ours/--theirs, mergetool and rerere, with a short note on git add --resolved in Git 2.56.

Version note: The commands in this post were tested with Git 2.54; git add --resolved arrives with Git 2.56 and is described here without output.

Guess First

The spring sale is coming. On the spring-sale branch, Alan lowers the price of Dune from 14 to 12 and adds a line about the campaign to the README. Around the same time, Ada raises the price of Dune to 16 on main. In this example we made Alan's commit in the same repo, under his name:

$ git switch -c spring-sale
Switched to a new branch 'spring-sale'
$ git commit -am "Discount Dune for the spring sale"
[spring-sale 76a5e12] Discount Dune for the spring sale
 2 files changed, 3 insertions(+), 1 deletion(-)
$ git switch main
Switched to branch 'main'
$ git commit -am "Raise Dune price"
[main 552f98c] Raise Dune price
 1 file changed, 1 insertion(+), 1 deletion(-)

Now Ada is going to type git merge spring-sale. Question: what happens to the README change? Which value does the price of Dune end up with? Does Git create a merge commit without stopping?

Why Do Conflicts Happen?

$ git merge spring-sale
Auto-merging src/books.js
CONFLICT (content): Merge conflict in src/books.js
Automatic merge failed; fix conflicts and then commit the result.

Remember the three-way merge from the previous post: Git looks at what each side changed since the common ancestor. The README changed only on Alan's side, no problem there. But the Dune line changed on both sides, and in different ways. The ancestor said 14; one side says 16, the other says 12. Git cannot know which one is right, because this is not a text problem, it is a business decision. So it stops and leaves the decision to you.

A conflict is not an error, it is a question. Git is asking "you both wrote different things in the same place, which one stays?". The merge is half done, but nothing is lost; the commits from both sides are where they were.

Reading the Markers

$ cat src/books.js
const books = [
<<<<<<< HEAD
  { id: 1, title: "Dune", price: 16, isbn: "9780441013593" },
=======
  { id: 1, title: "Dune", price: 12, isbn: "9780441013593" },
>>>>>>> spring-sale
  { id: 2, title: "Neuromancer", price: 11 },
  { id: 3, title: "Snow Crash", price: 10 },
];

function findBook(id) {
  return books.find((b) => b.id === id);
}

module.exports = { books, findBook };

Git wrote conflict markers into the file. Between <<<<<<< HEAD and ======= is your side, the version on the branch where you started the merge. Between ======= and >>>>>>> spring-sale is the version on the branch you are merging in. Everything outside the markers has already been merged without trouble. Your only job is to replace this block, markers included, with what it should finally say.

Think of it as a draft contract. Two lawyers wrote the same clause differently; the editor put the two versions one under the other and left a note in the margin saying "which one?". You write the final clause: you can pick one, combine both, or write a completely new sentence.

Seeing the State During a Conflict

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

Changes to be committed:
	modified:   README.md

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

git status tells you step by step what to do. The README merged cleanly and was added to the index. src/books.js is waiting as "both modified". If several files have conflicts, they are all listed here and drop off the list as you resolve them.

During a conflict git diff behaves differently too and shows a format called combined diff. The two-column header (@@@) and the two marks at the start of each line tell you which side a line came from. We will see an example in the resolution step. If you want to see which commits led to this conflict, git log --merge helps:

$ git log --merge --oneline
552f98c Raise Dune price
76a5e12 Discount Dune for the spring sale
$ git ls-files -u
100644 7c367681de29936ec0c92ec9f899077690c3a3d3 1	src/books.js
100644 8319e439b375fc21a5cc14dfbedc01367530186a 2	src/books.js
100644 b2cbcae16fd76c65f7a2790afc87db7ca097a0b4 3	src/books.js

git log --merge lists the commits on both sides that touched the conflicted files; on long-lived branches it is the quick answer to "who changed this line, and why?". git ls-files -u shows what is behind the curtain. During a conflict the index holds three versions of the same file: 1 the common ancestor, 2 your side, 3 the other side. The index being filled three times over also explains why the merge counts as "half done".

Resolving Step by Step

Ada and Alan talk and reach a joint decision: the regular price will be 16, and during the campaign the book will sell for 12. Ada deletes the markers and writes the line in a new form that contains both. After saving, git diff shows that the resolution differs from both sides:

$ git diff
diff --cc src/books.js
index 8319e43,b2cbcae..0000000
--- a/src/books.js
+++ b/src/books.js
@@@ -1,5 -1,5 +1,5 @@@
  const books = [
-   { id: 1, title: "Dune", price: 16, isbn: "9780441013593" },
 -  { id: 1, title: "Dune", price: 12, isbn: "9780441013593" },
++  { id: 1, title: "Dune", price: 16, salePrice: 12, isbn: "9780441013593" },
    { id: 2, title: "Neuromancer", price: 11 },
    { id: 3, title: "Snow Crash", price: 10 },
  ];

You tell Git a file is resolved with git add. Here the meaning of add stretches a little: it says "this is the final version of the file, the conflict is over". Then you complete the merge with git commit. If you do not give a message, Git opens the editor with the message Merge branch 'spring-sale':

$ git add src/books.js
$ git status
On branch main
All conflicts fixed but you are still merging.
  (use "git commit" to conclude merge)

Changes to be committed:
	modified:   README.md
	modified:   src/books.js

$ git commit
[main 85ae714] Merge branch 'spring-sale'
$ git log --oneline --graph -4
*   85ae714 Merge branch 'spring-sale'
|\
| * 76a5e12 Discount Dune for the spring sale
* | 552f98c Raise Dune price
|/
*   7111256 Merge branch 'isbn'
|\  

To sum up the steps: find the conflicted files with git status, open each one and clean up the markers, check that the code really works, mark it with git add, finish with git commit. Do not skip the third step. A file without markers is not always a correct file; when you combine the logic of two sides it is very easy to miss a bracket or a comma. That is why running the tests before the merge commit matters.

Backing Out: git merge --abort

Sometimes you do not want to resolve the conflict right now: you merged the wrong branch, or you need to talk to your teammate first. To undo the merge as if it never started:

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

--abort takes the repo back to the state right before the merge. That is why starting a merge with a clean working tree matters. If you abort a merge you started with uncommitted changes, Git may not always be able to bring those changes back intact.

zdiff3: Show the Common Ancestor Too

One piece of information is missing from the default markers: what did the ancestor say? You see 16 and 12, but you do not know the original value was 14. For a simple price that is fine; but in a conflict where both sides changed the same function in different directions, the ancestor is the key to "who changed what". The merge.conflictStyle setting solves this:

$ git config set --global merge.conflictStyle zdiff3
$ git merge spring-sale
Auto-merging src/books.js
CONFLICT (content): Merge conflict in src/books.js
Automatic merge failed; fix conflicts and then commit the result.
$ head -9 src/books.js
const books = [
<<<<<<< HEAD
  { id: 1, title: "Dune", price: 16, isbn: "9780441013593" },
||||||| 7111256
  { id: 1, title: "Dune", price: 14, isbn: "9780441013593" },
=======
  { id: 1, title: "Dune", price: 12, isbn: "9780441013593" },
>>>>>>> spring-sale
  { id: 2, title: "Neuromancer", price: 11 },

The ||||||| 7111256 section in the middle shows the common ancestor's version. Now it is easy to read: Ada turned 14 into 16, Alan turned 14 into 12. The diff3 style adds the same section; zdiff3 produces tighter output by moving the leading and trailing lines common to both sides out of the block. We recommend turning this setting on once globally and forgetting about it. On a Git older than 2.46, where git config set does not exist, the classic form git config --global merge.conflictStyle zdiff3 does the same job.

Picking One Side and Helper Tools

If you want to take one side of a conflicted file as it is, you do not need to edit it by hand:

$ git restore --theirs src/books.js
$ head -3 src/books.js
const books = [
  { id: 1, title: "Dune", price: 12, isbn: "9780441013593" },
  { id: 2, title: "Neuromancer", price: 11 },

--theirs writes the version from the branch you are merging in, --ours writes your own side. After that you still need git add. In older posts you will see this as git checkout --theirs; it does the same job.

git mergetool opens the conflicted files one by one in a visual tool you have configured (vimdiff, Meld, Beyond Compare, VS Code and so on); the tool is chosen with the merge.tool setting. Since most editors today recognise the markers themselves and offer "Accept Current / Accept Incoming" buttons, many developers never need mergetool at all.

rerere ("reuse recorded resolution") is for people who resolve the same conflict again and again. When you turn it on with git config set rerere.enabled true, Git records the conflicted state and your resolution; you see Recorded preimage for 'src/books.js' during the merge and Recorded resolution for 'src/books.js'. at commit time. When the same conflict shows up again, it says Resolved 'src/books.js' using previous resolution. and applies the resolution itself. Checking the file and running git add is still up to you. For people who often rebase long-lived branches it saves a lot of time.

Git 2.56 added a small but well-placed command to this flow: git add --resolved stages only the files whose conflicts are resolved, and stops if a file still contains conflict markers. Since the commands in this post were tested with 2.54, we do not show its output.

Common Pitfalls

The first is forgetting a marker in the file and committing it. git add does not look at markers; it accepts whatever you give it. The --check we saw in the diff post helps here too; it shows leftover markers in the staged content with line numbers:

$ git add src/books.js
$ git diff --cached --check
src/books.js:2: leftover conflict marker
src/books.js:4: leftover conflict marker
src/books.js:6: leftover conflict marker

Putting this check in a pre-commit hook, or running lint and test steps in CI, catches this kind of accident before it reaches the main branch.

The second is resolving a conflict one-sidedly because "mine is right". When you wipe out the other side with --ours, Alan's change silently disappears and the merge commit barely shows a trace of it. If you are not sure, ask the person who made the change.

The third is carrying on during a conflict without looking at git status. If several files are conflicted, you resolve one and try to commit, and Git stops with Committing is not possible because you have unmerged files.

The fourth is putting off big merges out of fear of conflicts. The longer two branches stay apart, the bigger the conflict grows. Small, frequent merges always hurt less than big, rare ones.

A Small Experiment

★☆☆ Change the same line differently on two branches and merge them. Read the markers, resolve by hand, and finish with git add and git commit. Then look at the merge commit with git show.

★★☆ Recreate the same conflict with merge.conflictStyle set to zdiff3 and find the common ancestor section. Then back out with git merge --abort and confirm that git status is clean.

★★★ Turn on rerere.enabled, resolve a conflict and commit. Find a way to reproduce the same conflict (hint: with a new pair of branches made from the same two branches). Watch Git apply the resolution by itself.

So far everything has happened on one computer, in one repo. We even committed Alan's change in the same repo, under his name. In the next post we move to real teamwork: git clone, origin, remote-tracking branches, fetch and push.