The 3-Point Merge

Why Your IDE's Merge Tool Is Lying to You (And How to Fix It)

👋 Welcome to CTO Teachings’s blog. While we are a recruiting company, we happen to provide a free blog for people just like you. It’s packed with hard-won lessons from CTO—who’s helped managers rise to Engineering Director and CTO roles.

Introduction: The Merge Conflict That Made Me Question Everything

Picture this: It's Friday at 4:47 PM. You've spent three days on a beautiful feature branch. The code is clean, the tests pass, and you're ready to merge to main. You click the merge button and... 47 conflicts detected. Your IDE helpfully shows you two versions of each conflicted file, and you spend the next two hours playing detective, trying to figure out what actually changed and why.

Sound familiar? Here's the dirty secret the tooling vendors don't want you to know: standard merge tools are fundamentally broken. They're showing you the wrong information, and that's why resolving conflicts feels like trying to solve a murder mystery where someone already cleaned up the crime scene.

The problem isn't you. The problem is that Git's default merge—and the tools built on top of it—performs what's called a 2-point merge. What you actually need is a 3-point merge. The difference isn't just academic—it's the difference between understanding intent and blindly guessing.

The Problem with 2-Point Merges

What You See vs. What You Need

When Git encounters a merge conflict, it dumps something like this into your file:

<<<<<<< HEAD
function calculateTotal(items, taxRate) {
  return items.reduce((sum, item) => sum + item.price, 0) * (1 + taxRate);
}
=======
function calculateTotal(items, taxRate, discount) {
  const subtotal = items.reduce((sum, item) => sum + item.price, 0);
  return (subtotal - discount) * (1 + taxRate);
}
>>>>>>> feature-branch

Your IDE shows you these two versions—let's call them B (your HEAD) and C (the incoming branch). It might even try to highlight the differences between B and C. Very helpful, right?

Wrong. Here's the critical information that's missing: what was the original code before either branch touched it?

Without knowing the original (let's call it A, the fork point), you're forced to guess at intent. Did main add the taxRate calculation, or was it always there and feature-branch modified it? Did feature-branch add the discount parameter, or did main remove it? You literally cannot know from a 2-point comparison.

The Proof Is in the Conflict Markers

Here's an easy way to verify this yourself: look at any merge conflict. You'll see the HEAD version and the incoming version. That's it. Two points. The original code—the common ancestor that would tell you what each side actually changed—is nowhere to be found in Git's default output.

Yes, Git internally knows about the merge base. Yes, some IDEs try to simulate a 3-way merge view. But they're building that view on top of Git's 2-point output, and the information loss has already occurred. It's like trying to reconstruct a deleted file from the recycle bin—possible in theory, but you've already lost fidelity.

Why This Makes Conflict Resolution a Nightmare

When you're staring at two different versions of code with no shared context, you're essentially playing a guessing game. Here's what goes wrong:

  • You lose intent. Knowing that main added logging is very different from knowing that feature-branch removed logging. The end state might look the same, but the correct resolution is completely different.

  • You can't identify true conflicts. Some 'conflicts' are actually compatible changes that just happened to touch nearby lines. Without seeing what changed, you might manually resolve things that could have been auto-merged.

  • You risk introducing bugs. When you're guessing, you're gambling. Maybe you keep the wrong version. Maybe you accidentally drop a critical change because it looked like 'the old way.'

  • It takes forever. Every conflict becomes a mini-investigation. You end up running git log, checking commit messages, and asking teammates 'hey, did you change this function?'

The 3-Point Merge: Finally, Some Sanity

Understanding the Three Points

A proper 3-point merge involves three versions of each file:

A (Fork Point) ─── The common ancestor, where the branches diverged

     /          \

    /            \

B (Feature) C (Main)

The magic happens when you compute two diffs:

  • B - A = What changed on the feature branch (the developer's intent)

  • C - A = What changed on main since the branch was created

Now, instead of looking at two arbitrary states and guessing, you can see exactly what each side intended to change. This transforms conflict resolution from archaeology into engineering.

A Real Example

Let's revisit that calculateTotal function. With 3-point merge info, you'd see:

Original (A - Fork Point):

function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price, 0);
}

B - A Diff (Feature Branch Changes):

+ Added 'discount' parameter
+ Added discount subtraction before return

C - A Diff (Main Branch Changes):

+ Added 'taxRate' parameter
+ Added tax multiplication to return

Now the resolution is obvious: both branches added features to the original simple function. Main added tax calculation, feature added discount handling. The correct merge keeps both additions:

function calculateTotal(items, taxRate, discount) {
  const subtotal = items.reduce((sum, item) => sum + item.price, 0);
  return (subtotal - discount) * (1 + taxRate);
}

No guessing. No archaeology. Just clear, obvious intent from both sides.

Why This Works Better for AI-Assisted Merging

If you're using AI tools to help resolve conflicts (and if you're not, you're missing out), the 3-point approach becomes even more valuable. AI models are excellent at understanding intent when given the right context. Feed an AI two random code snippets and ask 'which one is right?'—you'll get a coin flip. Feed it the original plus two diffs showing what each side changed, and it can reason about the actual problem.

Think of it this way: would you rather ask a new developer to 'pick the right version' or 'here's what existed before, here's what team A changed and why, here's what team B changed and why—now combine them intelligently'? The second question has an answer. The first is a trap.

Implementing the 3-Point Merge Workflow

Prerequisites and Philosophy

Before we dive into the mechanics, there's an important philosophical point: all developers should be using squash merges (technically 'git merge --squash' which is really a rebase that replays all your commits as a single commit). This keeps your history clean and, critically, makes 3-point merges reliable.

For 3-point merges to work well, you need one of these to be true:

  1. Any previous merges from main to your feature branch were 3-point merges, OR

  2. A squash merge back to main is clean (no conflicts)

If neither is true, you'll need to deal with the main merge first. It's turtles all the way down, but at least they're well-organized turtles.

The Complete Workflow

Step 1: Find the Fork Point and Record A, B, C

First, identify your three points. You'll want to save these somewhere (a simple JSON file works well):

  • FORK_POINT (A): git merge-base feature-branch main

  • FEATURE_HEAD (B): The current HEAD of your feature branch

  • MAIN_HEAD (C): The current HEAD of main

Step 2: Create a Backup Branch

Always. Always create a backup. Future you will thank present you.

git checkout -b username/myFeatureBackup1
git checkout username/myFeature

Step 3: Update Main and Create a Squash Branch

git checkout main && git pull origin main
git checkout -b username/myFeatureSquash

Step 4: Attempt the Squash Merge

git merge --squash username/myFeature

If this succeeds cleanly: commit and skip to Step 11. Pop the champagne. If it fails with conflicts: continue to Step 5. Put down the champagne.

Step 5: Get the Conflicted Files List

git diff --name-only --diff-filter=U > conflicted-files.txt

Step 6-8: Extract the Three Versions and Diffs

For each conflicted file, create a directory and extract:

# Full files at each point
git show $FORK_POINT:$file > A-forkpoint.txt
git show $FEATURE_HEAD:$file > B-feature.txt
git show $MAIN_HEAD:$file > C-main.txt
# The magic diffs that show intent
git diff $FORK_POINT $FEATURE_HEAD -- $file > B-A.diff
git diff $FORK_POINT $MAIN_HEAD -- $file > C-A.diff

Step 9: Resolve Conflicts

Now you have everything you need. You can resolve manually using the diffs as your guide, or feed the full context to an AI assistant. Either way, you're working with complete information instead of incomplete fragments.

Steps 10-11: Clean Up and Finalize

git branch -D username/myFeature              # Delete old feature branch
git branch -m username/myFeature              # Rename squash branch
git push -u --force-with-lease origin         # If PR exists

File Structure Reference

When you're done extracting, your merge directory should look like this:

merge-myFeature/
├── hashes.json                    # A, B, C commit hashes
├── conflicted-files.txt           # List of conflicted files
└── src__app__component.ts/        # Per conflicted file
    ├── A-forkpoint.txt           # Original code
    ├── B-feature.txt             # Feature branch version
    ├── C-main.txt                # Main branch version
    ├── B-A.diff                  # What feature changed
    └── C-A.diff                  # What main changed

Conclusion: Stop Guessing, Start Merging

The next time you're staring at a merge conflict, remember: you're not bad at merging. You've just been given incomplete information. Standard merge tools show you two points when you need three.

By extracting the fork point and computing the B-A and C-A diffs, you transform merge conflicts from 'guess which code is right' into 'combine two well-understood changes.' It's the difference between stumbling in the dark and working with the lights on.

Key Takeaways:

  1. 2-point merges hide intent by only showing end states

  2. 3-point merges reveal intent by showing what each side changed from the common ancestor

  3. The B-A diff shows feature branch intent; the C-A diff shows main branch intent

  4. This approach works better for both human and AI-assisted resolution

  5. Use squash merges to keep your history clean and 3-point merges reliable

Your Friday afternoons will thank you.

If you are ever looking for a job or expanding your team, connect with Shelly, Yuliia, or Harold on LinkedIn.

Want to dive deeper into hiring strategies and level up your leadership? Subscribe to our Beehiiv’s blog for more insights!

Thanks for reading,

CTO Teachings

Reply

or to participate.