Sitemap

Git Three-Way Merge: Why Merges Conflict

9 min readJun 18, 2026

--

You add a few new lines, run git merge, and Git reports a conflict. That feels wrong, especially if you picture Git as comparing only your branch against the incoming one. The secret is that Git's three-way merge compares three versions of every file, not two. Once you see the third one, conflicts stop feeling random and start following a rule you can predict.

Press enter or click to view image in full size

Table of Contents

Hands-on: set up the practice repo

Everything below runs in a throwaway repo. Create a folder, start a repo, and make the first commit (we will call it B):

mkdir three-way-merge-practice && cd three-way-merge-practice

git init

git config merge.conflictStyle zdiff3 # show the base inside conflict markers

cat > config.py <<'EOF'
HOST = "localhost"
PORT = 8000
TIMEOUT = 30
MAX_RETRIES = 3
LOG_LEVEL = "INFO"
WORKERS = 4
EOF

git add . && git commit -m "base: add config.py"

That git config line matters, because it shapes everything you will see. By default, when Git hits a conflict it shows only two versions: yours and theirs. The zdiff3 style adds a third block showing the base, the common starting point both sides edited from. Seeing that base is the whole point of this tutorial, so we turn it on up front. We set it on this repo only (no --global), so your other projects are untouched.

Hands-on 1: a clean auto-merge

The goal here is to show that Git merges two edits with no conflict when each side changed a different line.

# your branch: change TIMEOUT from 30 to 60
git switch -c feature-timeout main

cat > config.py <<'EOF'
HOST = "localhost"
PORT = 8000
TIMEOUT = 60
MAX_RETRIES = 3
LOG_LEVEL = "INFO"
WORKERS = 4
EOF

git add . && git commit -m "raise TIMEOUT to 60"


# a teammate's branch: change WORKERS from 4 to 8
git switch -c feature-workers main

cat > config.py <<'EOF'
HOST = "localhost"
PORT = 8000
TIMEOUT = 30
MAX_RETRIES = 3
LOG_LEVEL = "INFO"
WORKERS = 8
EOF

git add . && git commit -m "raise WORKERS to 8"


# merge the teammate's branch into yours
git switch feature-timeout

git merge --no-edit feature-workers # --no-edit skips the editor for the merge message

# view the current file state and notice that both changes are kept without conflict
cat config.py

You run the merge while on feature-timeout. Git prints Merge made by the 'ort' strategy with no conflict, and cat config.py shows both edits: TIMEOUT = 60 and WORKERS = 8.

You are on feature-timeout, and you merge feature-workers in. Your TIMEOUT change stays, and the teammate's WORKERS change is on a different line, so it comes in cleanly too. Different lines, nothing collides, no conflict.

Notice that this explanation only ever looks at the two branches: line them up, keep your change, and take the teammate’s change where it does not touch yours. So far, that two-file view works perfectly. Keep it in mind, and let us move to the next example to see whether it still holds.

Hands-on 2: a content conflict (same line)

Now make two branches change the same line. You and a teammate both branch off main (where TIMEOUT = 30). You set it to 90, the teammate sets it to 120, and you merge the teammate's branch into yours.

# your branch: set TIMEOUT to 90
git switch -c tune-timeout main

cat > config.py <<'EOF'
HOST = "localhost"
PORT = 8000
TIMEOUT = 90
MAX_RETRIES = 3
LOG_LEVEL = "INFO"
WORKERS = 4
EOF

git add . && git commit -m "set TIMEOUT to 90"


# a teammate's branch: set TIMEOUT to 120
git switch -c hotfix-timeout main

cat > config.py <<'EOF'
HOST = "localhost"
PORT = 8000
TIMEOUT = 120
MAX_RETRIES = 3
LOG_LEVEL = "INFO"
WORKERS = 4
EOF

git add . && git commit -m "set TIMEOUT to 120"


# merge the teammate's branch into yours
git switch tune-timeout

git merge hotfix-timeout

This time Git stops: CONFLICT (content): Merge conflict in config.py. Both branches changed the same TIMEOUT line (you set 90, the teammate set 120), so there is a real clash and Git asks you to resolve it.

The two-file view still holds here. Line the two branches up, and the TIMEOUT line plainly disagrees: 90 versus 120. Same line, two values, a clash. Exactly what you would expect.

Look at the fileand you will see this around the TIMEOUT line. You merged while on tune-timeout, so it is HEAD

cat config.py

HOST = "localhost"
PORT = 8000
<<<<<<< HEAD
TIMEOUT = 90 # this is from your branch
||||||| base
TIMEOUT = 30 # this is from the base
=======
TIMEOUT = 120 # this is from your teammate
>>>>>>> hotfix-timeout
MAX_RETRIES = 3
LOG_LEVEL = "INFO"
WORKERS = 4

Everything else in the file auto-merged. Only the one contested line is marked. Remember to run thegit merge --abort command to reset the file so you are ready for the next step.

Hands-on 3: where the two-file view breaks

So far the two-file rule has held: different lines merge, the same line clashes. Now watch it fail. One branch rewrites a line, and the other adds a brand-new line right after it. Two different lines, so the two-file rule predicts a clean merge. See what actually happens.

# your branch: change MAX_RETRIES from 3 to 5
git switch -c rewrite-retries main

cat > config.py <<'EOF'
HOST = "localhost"
PORT = 8000
TIMEOUT = 30
MAX_RETRIES = 5
LOG_LEVEL = "INFO"
WORKERS = 4
EOF

git add . && git commit -m "raise MAX_RETRIES to 5"


# a teammate's branch: insert a new line right after MAX_RETRIES
git switch -c add-backoff main

cat > config.py <<'EOF'
HOST = "localhost"
PORT = 8000
TIMEOUT = 30
MAX_RETRIES = 3
BACKOFF = 2
LOG_LEVEL = "INFO"
WORKERS = 4
EOF

git add . && git commit -m "add BACKOFF after MAX_RETRIES"


# merge the teammate's branch into yours
git switch rewrite-retries

git merge add-backoff

cat config.py # see the conflict

git merge --abort # reset the file

Result: CONFLICT (content): Merge conflict in config.py, even though one branch only rewrote MAX_RETRIES and the other only added a separate BACKOFF line.

Get Yan Li’s stories in your inbox

Join Medium for free to get updates from this writer.

This breaks the two-file rule. The two edits are on different lines, so “different lines, no collision” said this should merge cleanly. But Git stopped anyway. The two-file view just gave the wrong answer, and from the two branch files alone there is no way to see why. To explain it, we finally need the piece we have been ignoring: the third file.

The third input: the merge base and the merge rule

The two-file view carried us through Hands-on 1 and 2, but it failed in Hands-on 3. Here is what Git actually compares.

When you branch off main and your teammate branches off main, both branches share a starting commit. That shared commit is the merge base: the most recent commit both branches have in common. In every hands-on so far it is commit B, the original config.py.

Here is the Hands-on 1 built. Both branches started from commit B on main:

                F   feature-timeout   (TIMEOUT = 60, WORKERS = 4)
/
B (on main) B = TIMEOUT = 30, WORKERS = 4
\
G feature-workers (TIMEOUT = 30, WORKERS = 8)

Step by step:

  1. The setup commit B is on main, and both branches start from it
  2. git switch -c feature-timeout main starts a branch at B, then your TIMEOUT change adds one commit F, so feature-timeout is B -> F
  3. git switch -c feature-workers main starts a second branch at B, then the teammate's WORKERS change adds one commit G, so feature-workers is B -> G

So a merge never compares two files. It compares three:

  • base: the file at commit B, the shared starting point
  • ours: the file on the branch you are on
  • theirs: the file on the branch you are merging in

Git measures each side against the base to learn what each branch actually changed, then applies one rule, spot by spot (a spot is a changed line, or a few changed lines together):

Press enter or click to view image in full size

Now re-read the hands-on with the base in hand:

  • Hands-on 1 (clean). Against base B (TIMEOUT = 30, WORKERS = 4), your branch changed only TIMEOUT (case 2) and the teammate changed only WORKERS (case 3). Each line was changed by exactly one side, so Git keeps both. Two files alone could never know your 4 was the old value and their 8 was the real change. The base is what told Git that.
  • Hands-on 2 (conflict). Both branches moved the TIMEOUT line away from the base 30 (you to 90, they to 120). Same spot, two different changes: case 5.
  • Hands-on 3 (conflict). Your branch rewrote MAX_RETRIES = 3 into 5. The teammate's BACKOFF line was inserted anchored to that same MAX_RETRIES = 3 line, which your branch no longer has. So both sides changed the same spot relative to the base, and Git cannot tell whether BACKOFF belongs before or after your rewritten line. Case 5 again. The edits looked like "different lines," but measured against the base they touch the same spot.

The next example is the last case in the table, case 6, where the file has no base version at all.

Hands-on 4: the add/add conflict (no base at all)

Here both branches create the same new file that did not exist when they branched off main, so neither side has a base version to merge against. That is what makes it an add/add conflict.

# your branch: add a README
git switch -c readme-summary main

printf '# My Project\nA data pipeline.\n' > README.md

git add . && git commit -m "add README (summary)"


# a teammate's branch: add a different README
git switch -c readme-version main

printf '# My Project\nVersion 1.0\n' > README.md

git add . && git commit -m "add README (version)"


# merge the teammate's branch into yours
git switch readme-summary

git merge readme-version

Result: CONFLICT (add/add): Merge conflict in README.md.

Git wants base -> ours and base -> theirs, but there is no base version of this file to diff against: README.md did not exist at commit B. With no common starting point, Git cannot attribute anything to either side, so it cannot auto-merge even lines that happen to match. It hands you the whole file as an add/add conflict.

Reading the conflict markers

By default, the markers show only two sides. The lines between <<<<<<< and ======= are yours; the lines between ======= and >>>>>>> are theirs:

<<<<<<< ours (HEAD / your branch)
your version of this spot
=======
their version of this spot
>>>>>>> theirs (the branch being merged in)

That hides the most useful piece: the base. Turn on the zdiff3 style (we already set it during setup), and the same conflict shows the base in the middle:

<<<<<<< ours (HEAD / your branch)
your version of this spot
||||||| base
the base version of this spot
=======
their version of this spot
>>>>>>> theirs (the branch being merged in)

With the base visible in the middle (between ||||||| and =======), "who changed what" is obvious: compare each side to the middle. For an add/add conflict the base section is empty, which is the visual signature that there was no common ancestor.

The whole point holds across every example: Git looks at three versions, not two, and a merge conflicts only when both sides change the same spot, or both add the same file with no shared base.

Thanks for taking the time to read this article. If you learned something new, please give it a clap (you can tap more than once which tells Medium the piece was worth reading). If you think others may find it helpful, feel free to share it. If you want to stay updated with my future content, hit the follow button. If you have any questions or comments, leave them below. I’ll reply as soon as I can. Thanks for your support!

--

--

Yan Li
Yan Li

Written by Yan Li

Data Engineer at a Fintech startup company. Simplifying data engineering concepts using real-world examples and handwritten visual notes.