Version Control

Conflict Resolution Patterns and Strategies

A practical guide to understanding, preventing, and resolving Git merge conflicts including three-way merges, conflict markers, merge tools, and team strategies.

Conflict Resolution Patterns and Strategies

Merge conflicts are not errors. They are Git telling you that two changes touched the same code and it cannot automatically determine which version to keep. Developers who panic at conflict markers waste time and introduce bugs. Developers who understand the three-way merge model resolve conflicts in seconds.

I resolve conflicts daily across multiple projects. The patterns in this guide cover everything from basic marker reading to team-level strategies that prevent conflicts from happening in the first place.

Prerequisites

  • Git installed (v2.20+)
  • Understanding of Git branching and merging
  • A text editor or merge tool
  • Experience with at least one merge conflict

How Conflicts Happen

Git uses a three-way merge algorithm. It compares three versions of a file:

Base (common ancestor)
  │
  ├── Ours (your branch)
  │
  └── Theirs (the other branch)
  • If only one side changed a section → Git takes that change automatically
  • If both sides changed the same section differently → conflict
  • If both sides made the same change → Git accepts it automatically
# Create a conflict scenario
git checkout -b feature-a
# Edit line 10 of app.js
git commit -am "feat: change greeting to Hello"

git checkout main
git checkout -b feature-b
# Edit the same line 10 of app.js differently
git commit -am "feat: change greeting to Welcome"

# Merge feature-a into main
git checkout main
git merge feature-a  # Clean merge

# Now merge feature-b — conflict
git merge feature-b
# CONFLICT (content): Merge conflict in app.js
# Automatic merge failed; fix conflicts and then commit the result.

Reading Conflict Markers

Git inserts markers into the conflicted file:

function greet(name) {
<<<<<<< HEAD
    var message = "Hello, " + name + "!";
=======
    var message = "Welcome, " + name + "!";
>>>>>>> feature-b
    return message;
}

The markers mean:

<<<<<<< HEAD          ← Start of YOUR changes (current branch)
    your code
=======               ← Separator
    their code
>>>>>>> feature-b     ← End of THEIR changes (incoming branch)

Three-Way Conflict Markers

Enable diff3 style to see the base version too:

git config merge.conflictStyle diff3

Now conflicts show three sections:

function greet(name) {
<<<<<<< HEAD
    var message = "Hello, " + name + "!";
||||||| merged common ancestors
    var message = "Hi, " + name + "!";
=======
    var message = "Welcome, " + name + "!";
>>>>>>> feature-b
    return message;
}

The middle section (|||||||) shows what the line looked like before either branch changed it. This is invaluable — knowing the original helps you understand what each side intended.

zdiff3 Style (Git 2.35+)

git config merge.conflictStyle zdiff3

The zdiff3 style is like diff3 but removes common lines from the conflict markers, showing only the actual differences. This makes complex conflicts easier to read.

Resolving Conflicts

Manual Resolution

  1. Open the conflicted file
  2. Find the conflict markers (<<<<<<<, =======, >>>>>>>)
  3. Choose the correct code (yours, theirs, or a combination)
  4. Remove all conflict markers
  5. Save the file
  6. Stage and commit
# After resolving manually
git add app.js
git commit -m "merge: resolve greeting conflict, use Welcome"

Choosing One Side Entirely

# Keep your version for all conflicts in a file
git checkout --ours app.js
git add app.js

# Keep their version for all conflicts in a file
git checkout --theirs app.js
git add app.js

# During merge, keep ours for everything
git merge feature-b --strategy-option ours

# During merge, keep theirs for everything
git merge feature-b --strategy-option theirs

Aborting a Merge

If you are not ready to resolve:

# Cancel the merge entirely
git merge --abort

# The working tree is restored to the pre-merge state

Merge Tools

Built-in Merge Tool

git mergetool

Git opens each conflicted file in your configured merge tool. After saving, it marks the file as resolved.

Configuring Merge Tools

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

# Use Vim
git config --global merge.tool vimdiff

# Use IntelliJ / WebStorm
git config --global merge.tool intellij
git config --global mergetool.intellij.cmd 'idea merge $LOCAL $REMOTE $BASE $MERGED'

# Disable backup file creation (.orig files)
git config --global mergetool.keepBackup false

VS Code as Merge Editor

VS Code's built-in merge editor (v1.70+) provides a three-way view:

┌──────────────┬──────────────┐
│   Incoming   │   Current    │
│   (Theirs)   │   (Ours)     │
├──────────────┴──────────────┤
│          Result             │
│   (What will be committed)  │
└─────────────────────────────┘

Click "Accept Incoming," "Accept Current," "Accept Both," or manually edit the result pane.

Conflict Patterns and Solutions

Pattern 1: Adjacent Line Changes

Both sides edited nearby but different lines:

<<<<<<< HEAD
var port = 3000;
var host = "localhost";
=======
var port = 8080;
var host = "0.0.0.0";
>>>>>>> feature-b

Resolution: Decide which values are correct. Often one branch has the right port and the other has the right host:

var port = 8080;
var host = "0.0.0.0";

Pattern 2: One Side Added, Other Modified

<<<<<<< HEAD
function validate(input) {
    if (!input) return false;
    if (input.length < 3) return false;
    return true;
}
=======
function validate(input) {
    if (!input) return false;
    return true;
}
>>>>>>> feature-b

Resolution: Usually keep the additions from both sides:

function validate(input) {
    if (!input) return false;
    if (input.length < 3) return false;
    return true;
}

Pattern 3: Both Sides Added Different Code

function processOrder(order) {
    var total = calculateTotal(order.items);
<<<<<<< HEAD
    var tax = total * 0.08;
    var finalTotal = total + tax;
=======
    var discount = applyDiscount(order.coupon, total);
    var finalTotal = total - discount;
>>>>>>> feature-b
    return finalTotal;
}

Resolution: Both features are needed — combine them:

function processOrder(order) {
    var total = calculateTotal(order.items);
    var discount = applyDiscount(order.coupon, total);
    var tax = (total - discount) * 0.08;
    var finalTotal = total - discount + tax;
    return finalTotal;
}

Pattern 4: File Deleted on One Side

CONFLICT (modify/delete): config.js deleted in feature-b and modified in HEAD.

Resolution: Decide whether the file should exist:

# Keep the file
git add config.js

# Accept the deletion
git rm config.js

Pattern 5: Package Lock Conflicts

package-lock.json conflicts are common and should never be resolved manually:

# Accept either version, then regenerate
git checkout --theirs package-lock.json
git add package-lock.json
npm install
git add package-lock.json

Pattern 6: Rename/Rename Conflict

Both sides renamed the same file differently:

CONFLICT (rename/rename): utils.js renamed to helpers.js in HEAD
and to utilities.js in feature-b.

Resolution: Choose one name, update imports:

git add helpers.js   # Keep this name
git rm utilities.js  # Remove the other

# Update all imports
grep -rl "utilities" src/ | xargs sed -i 's/utilities/helpers/g'
git add .

Rebase Conflicts

Rebase conflicts work the same as merge conflicts but happen commit by commit:

git rebase main
# CONFLICT in commit 1 of 5
# Fix the conflict, then:
git add resolved-file.js
git rebase --continue

# CONFLICT in commit 3 of 5
# Fix again
git add another-file.js
git rebase --continue

# Abort the entire rebase if it gets too complex
git rebase --abort

Rerere: Reuse Recorded Resolution

Git can remember how you resolved a conflict and apply the same resolution automatically next time:

# Enable rerere
git config --global rerere.enabled true

# When you resolve a conflict, Git records the resolution
# Next time the same conflict occurs, Git resolves it automatically

# View recorded resolutions
git rerere status

# Forget a recorded resolution
git rerere forget path/to/file

This is especially useful for long-running branches that need repeated rebasing. You resolve each conflict once, and Git remembers.

Preventing Conflicts

Communication

# See who is working on what
git log --all --oneline --graph --decorate -20

# Check if a file has been modified on another branch
git log --all --oneline -- src/critical-file.js

Small, Frequent Merges

# Merge main into your feature branch regularly
git checkout feature-branch
git merge main

# Or rebase onto main
git rebase main

Merging daily means conflicts are small. Merging after three weeks means conflicts are enormous.

File Organization

Structure code to minimize overlap:

# Bad — one huge file everyone edits
src/
  app.js           # 2000 lines, 5 developers touching it

# Good — modular structure, each developer owns their area
src/
  routes/
    auth.js        # Alice owns this
    orders.js      # Bob owns this
    users.js       # Carol owns this
  services/
    authService.js
    orderService.js
    userService.js

Lock Files

For binary files that cannot be merged, use Git LFS file locking:

# Lock a file before editing
git lfs lock assets/design.psd

# See locked files
git lfs locks

# Unlock when done
git lfs unlock assets/design.psd

Complete Working Example: Complex Conflict Resolution

#!/bin/bash
# Demonstrate a multi-file conflict resolution

# Setup
mkdir conflict-demo && cd conflict-demo
git init

# Create initial files
cat > server.js << 'SCRIPT'
var express = require("express");
var app = express();
var port = 3000;

app.get("/", function(req, res) {
    res.send("Home page");
});

app.get("/api/users", function(req, res) {
    res.json({ users: [] });
});

app.listen(port, function() {
    console.log("Server on port " + port);
});
SCRIPT

git add . && git commit -m "feat: initial server setup"

# Branch A: Add authentication
git checkout -b add-auth
cat > server.js << 'SCRIPT'
var express = require("express");
var jwt = require("jsonwebtoken");
var app = express();
var port = 3000;

app.use(express.json());

app.get("/", function(req, res) {
    res.send("Home page");
});

app.post("/api/login", function(req, res) {
    var token = jwt.sign({ user: req.body.user }, "secret");
    res.json({ token: token });
});

app.get("/api/users", function(req, res) {
    res.json({ users: [] });
});

app.listen(port, function() {
    console.log("Server on port " + port);
});
SCRIPT
git commit -am "feat: add JWT authentication endpoint"

# Branch B: Add logging and change port
git checkout main
git checkout -b add-logging
cat > server.js << 'SCRIPT'
var express = require("express");
var morgan = require("morgan");
var app = express();
var port = 8080;

app.use(morgan("combined"));

app.get("/", function(req, res) {
    res.send("Home page");
});

app.get("/api/users", function(req, res) {
    res.json({ users: [] });
});

app.listen(port, function() {
    console.log("Server on port " + port);
});
SCRIPT
git commit -am "feat: add request logging and change port"

# Merge both into main
git checkout main
git merge add-auth    # Clean merge

git merge add-logging
# CONFLICT! Both branches modified server.js

# Resolution: Keep auth AND logging, use port 8080
# Read the conflict markers, then resolve

Common Issues and Troubleshooting

Conflict markers left in committed code

Someone committed a file with <<<<<<< markers still in it:

Fix: Search the codebase for conflict markers and remove them: git grep -l '<<<<<<<'. Set up a pre-commit hook that rejects files containing conflict markers.

Merge tool opens but shows empty diff

The merge tool cannot find the base, local, or remote versions:

Fix: Check your mergetool configuration with git config --list | grep merge. Ensure the tool's cmd path uses the correct variable names ($LOCAL, $REMOTE, $BASE, $MERGED).

Same conflict keeps recurring on every rebase

You are rebasing a long-lived branch and resolving the same conflict repeatedly:

Fix: Enable rerere with git config rerere.enabled true. After resolving a conflict once, Git remembers the resolution and applies it automatically on subsequent rebases.

Cannot tell which version is correct

The conflict involves complex code changes and you are unsure what each side intended:

Fix: Use diff3 conflict style (git config merge.conflictStyle diff3) to see the original base version. Use git log --merge -p -- filename to see the commits that caused the conflict. Talk to the other developer.

Best Practices

  • Enable diff3 or zdiff3 conflict style. Seeing the base version makes conflicts dramatically easier to understand. Set it globally: git config --global merge.conflictStyle zdiff3.
  • Merge main into your branch frequently. Daily merges create tiny conflicts. Monthly merges create massive ones. The total work is the same — but tiny conflicts are trivial and massive ones are error-prone.
  • Never resolve package-lock.json manually. Accept either version and run npm install to regenerate it. Manual edits to lock files cause subtle dependency issues.
  • Use a proper merge tool. VS Code's merge editor shows three panes and lets you accept changes with one click. Command-line conflict markers are harder to parse, especially for complex conflicts.
  • Enable rerere for long-running branches. Record conflict resolutions so Git applies them automatically. This eliminates repetitive work during repeated rebases.
  • Add a pre-commit hook for conflict markers. A simple grep for <<<<<<< in the pre-commit hook prevents accidentally committing unresolved conflicts.
  • Keep files small and modular. Two developers changing different functions in different files never conflict. Two developers changing the same 500-line file conflict constantly.
  • Communicate before starting large refactors. A refactor that touches 50 files will conflict with everything. Coordinate timing so others can merge their work first.

References

Powered by Contentful