Back to Blog

Have you ever stared at a Git merge conflict and realized you have absolutely no idea what the code looked like before everything went sideways?

It’s a standard Tuesday. You’re merging a feature branch, and Git throws the dreaded "Automatic merge failed" message. You open the file, and you see the classic conflict markers:

<<<<<<< HEAD
export const API_URL = 'https://api.production.com';
=======
export const API_URL = 'https://staging.api.dev';
>>>>>>> feature/new-env

This is what Git gives you by default. It’s called the "merge" style. It shows you what you have (HEAD) and what they have (feature/new-env).

The problem? It’s missing the context. You know what the two endpoints are now, but you don't know what the original value was before both branches started messing with it. Was it a placeholder? Was it a different URL entirely? Without the common ancestor, you’re just guessing at the intent behind the changes.

The "Aha!" Moment: diff3

There is a better way. You can tell Git to show you the "base" version—the state of the code at the point where your two branches last agreed.

Run this in your terminal:

git config --global merge.conflictstyle diff3

Now, let's look at that same conflict again with diff3 enabled:

<<<<<<< HEAD
export const API_URL = 'https://api.production.com';
||||||| merged common ancestor
export const API_URL = 'localhost:3000';
=======
export const API_URL = 'https://staging.api.dev';
>>>>>>> feature/new-env

Suddenly, the story makes sense. The original value was localhost:3000. I updated it to the production URL, and my teammate updated it to the staging URL. Now I know exactly why the conflict happened. I'm not just looking at two competing states; I'm looking at two different *intentions* applied to the same starting point.

Why this changes your workflow

When you only see two sides, you’re performing a binary choice. You either pick A, pick B, or try to smash them together.

When you see the common ancestor, you’re performing logic.

I’ve found this especially useful in complex logic blocks. If I see that the ancestor had a specific if condition, and one branch added an extra check while the other branch refactored the entire block, I can see that the *intent* of the first branch still needs to be preserved inside the new refactor. Without that middle block, I’d probably just overwrite the extra check and introduce a regression.

Take it a step further with zdiff3

If you’re using a relatively modern version of Git (2.35 or newer), there’s an even cleaner version called zdiff3.

The "z" stands for zealous. Standard diff3 can sometimes be noisy, leaving in lines that are actually identical across all three versions just to give you "context." zdiff3 strips away the common prefix and suffix from the conflict hunk, leaving only the lines that actually differ.

To opt into the cleaner version:

git config --global merge.conflictstyle zdiff3

Is there a catch?

The only real "gotcha" is that your conflict markers get longer. If you’re dealing with a massive 50-line conflict, adding another 25 lines for the common ancestor can feel overwhelming at first glance.

But honestly? If a conflict is that big, you need that context more than ever. Solving a conflict by guessing is how bugs get into production. I'd rather scroll an extra ten lines than spend two hours debugging a merge error that happened because I didn't realize we'd both deleted the same function for different reasons.

Set it to zdiff3 and leave it there. Your future, frustrated, mid-rebase self will thank you.