Home DevOps Resolving Git Merge Conflicts: A Practical Guide for Developers

Resolving Git Merge Conflicts: A Practical Guide for Developers

In Plain English 🔥
Imagine two chefs are both improving the same recipe book at the same time — one changes the pasta sauce on page 12, and the other also rewrites that exact same page. When they try to combine their books into one final version, nobody knows which sauce to keep. Git hits the same wall: two developers edited the same line of code, and Git genuinely cannot decide whose version wins. A merge conflict is just Git raising its hand and saying 'I need a human to sort this out.'
⚡ Quick Answer
Imagine two chefs are both improving the same recipe book at the same time — one changes the pasta sauce on page 12, and the other also rewrites that exact same page. When they try to combine their books into one final version, nobody knows which sauce to keep. Git hits the same wall: two developers edited the same line of code, and Git genuinely cannot decide whose version wins. A merge conflict is just Git raising its hand and saying 'I need a human to sort this out.'

Every team that uses Git will eventually hit a merge conflict. It is not a sign something went wrong — it is a sign your team is working in parallel, which is exactly what Git is built for. The problem is that most developers treat conflicts like a fire alarm: panic, smash the keyboard, accept every incoming change, and hope for the best. That approach silently breaks production code and ruins trust in your codebase.

Merge conflicts exist because Git tracks changes at the line level. When two branches modify the same line (or adjacent lines) independently, Git cannot apply both changes automatically without risking data loss. So it stops, marks the battlefield inside the file, and waits for you. The conflict markers Git leaves behind are not cryptic error messages — they are a structured diff you can read and act on deliberately.

By the end of this article you will be able to: read and interpret conflict markers without guessing, manually resolve conflicts with confidence, use a three-way merge tool to handle complex conflicts visually, abort or retry a merge cleanly when things go sideways, and adopt the team habits that prevent unnecessary conflicts in the first place.

Why Git Merge Conflicts Happen — and When to Expect Them

Git merges work beautifully most of the time because the changes on two branches touch different files or different sections of the same file. Git's merge algorithm is smart enough to combine those non-overlapping edits automatically. Conflicts only surface when the algorithm genuinely cannot make a safe decision.

There are three common triggers. First, two developers edit the exact same line in the same file on different branches. Second, one developer edits a block of code while another deletes that entire file. Third, two developers rename the same file to different names. Each of these cases forces Git to stop and defer to a human.

The branch strategy your team uses matters enormously here. Long-lived feature branches that drift far from main are conflict factories — by the time you merge, dozens of lines may have diverged. Short-lived branches merged frequently are the antidote. Knowing this changes how you plan your work, not just how you fix conflicts after they happen.

Conflicts during a rebase feel slightly different from conflicts during a merge because a rebase replays commits one at a time, so you might resolve the same conceptual conflict multiple times. Understanding which operation triggered the conflict changes how you resolve it.

trigger_merge_conflict.sh · BASH
123456789101112131415161718192021222324252627282930313233343536
#!/usr/bin/env bash
# -----------------------------------------------------------
# Demonstration: deliberately create a merge conflict so you
# can see exactly what Git reports and what it writes into
# the conflicted file.
# -----------------------------------------------------------

# 1. Create a fresh repo so this is fully self-contained
mkdir recipe-api && cd recipe-api
git init
git config user.email "demo@thecodeforge.io"
git config user.name "CodeForge Demo"

# 2. Add a shared starting file on main (the "common ancestor")
cat > sauce.py << 'EOF'
def get_sauce():
    return "tomato"
EOF

git add sauce.py
git commit -m "chore: initial sauce implementation"

# 3. Branch A: the backend dev changes the sauce to 'arrabiata'
git checkout -b feature/arrabiata-sauce
sed -i 's/tomato/arrabiata/' sauce.py   # edit the same line
git add sauce.py
git commit -m "feat: switch sauce to arrabiata"

# 4. Back on main: a second dev changes the sauce to 'marinara'
git checkout main
sed -i 's/tomato/marinara/' sauce.py   # edit the SAME line differently
git add sauce.py
git commit -m "feat: switch sauce to marinara"

# 5. Attempt to merge — this is where Git raises its hand
git merge feature/arrabiata-sauce
▶ Output
Auto-merging sauce.py
CONFLICT (content): Merge conflict in sauce.py
Automatic merge failed; fix conflicts and then commit the result.
🔥
Why Git Stops Instead of GuessingGit could pick a winner automatically — but if it chose wrong, you would have a silent bug with no trace in history. Stopping and flagging the conflict is the safer, auditable choice. The conflict IS the safety net.

Reading Conflict Markers — Decoding What Git Actually Wrote in Your File

After a conflict, Git edits the file directly and inserts markers to show you both sides of the disagreement. Most developers scan these markers, pick a side, and move on. That works for trivial conflicts. For anything real, you need to read all three pieces of information Git gives you.

The structure is always the same. The <<<<<<< HEAD line opens the block and the content below it is what YOUR current branch has. The ======= line is the divider. Everything below the divider down to >>>>>>> feature/branch-name is what the incoming branch contributed. Between those three markers is the full picture of the disagreement.

What most guides skip is the base — the version that BOTH branches started from before they diverged. Git stores this internally. Knowing the base tells you whether both developers added new logic (in which case you likely need to keep both), or whether they both rewrote the same existing logic (in which case you need to pick the right one). Without the base, you are reading a debate without knowing what both parties agreed on before it started.

You can surface the base yourself with git diff --diff-filter=U or by configuring Git to use the diff3 conflict style, which embeds the ancestor version directly between the markers. This single setting prevents enormous amounts of bad conflict resolution.

read_conflict_markers.sh · BASH
123456789101112131415161718192021222324252627282930313233343536373839
#!/usr/bin/env bash
# -----------------------------------------------------------
# Part 1: Enable diff3 style BEFORE you merge — this adds the
# common ancestor (base) into the conflict block so you have
# full context. Set it globally so every future conflict is clearer.
# -----------------------------------------------------------

git config --global merge.conflictStyle diff3

# -----------------------------------------------------------
# Part 2: After running the conflict from the previous section,
# cat the file to see all three layers Git embedded.
# -----------------------------------------------------------

cat sauce.py

# -----------------------------------------------------------
# Part 3: The output below shows what diff3 style looks like.
# Read it from top to bottom:
#
#   <<<<<<< HEAD            <- your branch starts here
#     return "marinara"     <- what YOUR branch says
#   ||||||| merged common ancestors  <- BASE (what BOTH branches started from)
#     return "tomato"       <- the original value before anyone changed it
#   =======                 <- incoming branch starts here
#     return "arrabiata"    <- what the INCOMING branch says
#   >>>>>>> feature/arrabiata-sauce
#
# Decision logic:
#   - Both changed "tomato" to something different => editorial conflict, pick one
#   - One added new lines, other didn't change them => keep both
#   - Base is already gone on both sides => both deleted, no conflict needed
# -----------------------------------------------------------

# Part 4: List ALL conflicted files in a repo (useful in large merges)
git diff --name-only --diff-filter=U

# Part 5: See a detailed diff of just the conflicted regions
git diff
▶ Output
# Output of: cat sauce.py
def get_sauce():
<<<<<<< HEAD
return "marinara"
||||||| merged common ancestors
return "tomato"
=======
return "arrabiata"
>>>>>>> feature/arrabiata-sauce

# Output of: git diff --name-only --diff-filter=U
sauce.py
⚠️
Set diff3 Today — Not After Your Next Bad MergeRun `git config --global merge.conflictStyle diff3` right now. Without the ancestor block in your conflict markers, you are always making decisions with incomplete information. This one setting has saved countless hours of debugging bad resolutions.

Resolving Conflicts Three Ways — Manual Edit, git mergetool, and Theirs/Ours

There is no single correct way to resolve a conflict — the right approach depends on how complex the conflict is and what the code is doing. You have three practical options and each has a place.

Manual editing works well for simple conflicts: open the file, delete the markers, keep the correct code, save. This forces you to read the code carefully, which is often exactly what you should do. It breaks down when conflicts span dozens of lines or when you cannot tell which version is correct without seeing both in context side-by-side.

A three-way merge tool (like VS Code, IntelliJ, or vimdiff) shows you the base, the current branch, and the incoming branch in three panes simultaneously, with a fourth result pane you build from clicking to accept each hunk. This is the right approach for complex conflicts involving multiple changed functions.

The --ours and --theirs flags are a power tool for a specific situation: when you know categorically that one entire side is correct. They are NOT a shortcut for laziness. Use --ours to keep the current branch's version of a file in full, and --theirs to take the incoming branch's full version. Misusing these flags is one of the most common ways teams silently discard real code changes.

After any resolution method, the workflow is identical: stage the file, verify the state, and commit.

resolve_conflict_three_ways.sh · BASH
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
#!/usr/bin/env bash
# -----------------------------------------------------------
# METHOD 1: Manual edit
# Open the file in your editor, remove the markers, keep the
# correct content, then stage and commit.
# -----------------------------------------------------------

# Manually edit sauce.py to contain only the correct content:
cat > sauce.py << 'EOF'
def get_sauce():
    # Product decision: use arrabiata for the spicy menu launch
    return "arrabiata"
EOF

# Confirm no conflict markers remain (grep returns nothing = good)
grep -n '<<<<<<<\|=======\|>>>>>>>' sauce.py \
    && echo "WARNING: markers still present" \
    || echo "Clean — no conflict markers found"

# Stage the resolved file
git add sauce.py

# Confirm Git sees it as resolved (no 'UU' entries should remain)
git status

# Commit the merge
git commit -m "fix: resolve sauce conflict — use arrabiata for spicy launch"


# -----------------------------------------------------------
# METHOD 2: Configure VS Code as your merge tool, then launch it
# Run these once to set it up globally:
# -----------------------------------------------------------

git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'

# Then during a conflict, run:
# git mergetool
# VS Code opens with the three-way merge editor. Accept hunks
# using the UI buttons. Save and close. Git marks it resolved.


# -----------------------------------------------------------
# METHOD 3: Accept one full side (use carefully and deliberately)
# --ours  = keep the current branch's entire file
# --theirs = take the incoming branch's entire file
# -----------------------------------------------------------

# Scenario: The incoming branch has a regenerated lock file
# (e.g. package-lock.json) and you always want to take the
# incoming version because it was built from the correct
# dependency tree.
git checkout --theirs package-lock.json
git add package-lock.json
# Then commit as normal.

# IMPORTANT: --ours/--theirs operates on WHOLE FILES.
# There is no partial --theirs. If you need to mix lines
# from both sides, you must use method 1 or 2.
▶ Output
# Method 1 output:
Clean — no conflict markers found

On branch main
All conflicts fixed but you are still merging.
(use "git commit" to conclude merge)

Changes to be committed:
modified: sauce.py

[main a3f2c91] fix: resolve sauce conflict — use arrabiata for spicy launch
⚠️
Watch Out: Staging Without ResolvingRunning `git add filename` stages the file regardless of whether conflict markers are still inside it. Git trusts you. If you forget to remove the `<<<<<<<` lines, you will commit literal conflict markers into your source code. Always grep for markers before staging on large conflicts: `grep -r '<<<<<<<' .`

Aborting, Retrying, and Preventing Conflicts Before They Happen

Sometimes mid-conflict you realize the merge itself is wrong — the wrong branches were targeted, or you need to pull in new commits before continuing. Git gives you a clean escape hatch: git merge --abort. This rewinds everything back to exactly where you were before you ran the merge. No damage done.

For rebases the equivalent is git rebase --abort. And if you are mid-cherry-pick, git cherry-pick --abort does the same. Always abort cleanly rather than trying to manually reset files — the abort commands guarantee a clean working tree.

Retryin a merge after aborting is as simple as re-running the original merge command, ideally after fetching new changes or after your teammates have confirmed the affected code.

Prevention is worth ten resolutions. The habits that eliminate most conflicts are: merge or rebase from main frequently (at least daily on active branches), keep feature branches short-lived and narrowly scoped, split large files into smaller modules so fewer developers edit the same file, and communicate before refactoring shared utilities. Code ownership boundaries in large teams reduce conflict rates dramatically because they reduce the probability that two people edit the same lines at the same time.

abort_and_prevent_conflicts.sh · BASH
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
#!/usr/bin/env bash
# -----------------------------------------------------------
# ABORTING: clean escape from a merge gone wrong
# -----------------------------------------------------------

# You are mid-merge and realize the branch targets are wrong
git merge --abort
# Git prints nothing on success — run git status to confirm:
git status
# Expected: "nothing to commit, working tree clean"

# Same pattern for rebase and cherry-pick:
# git rebase --abort
# git cherry-pick --abort


# -----------------------------------------------------------
# PREVENTION PATTERN 1: Sync your feature branch with main daily
# This is the single most effective conflict-prevention habit.
# -----------------------------------------------------------

git checkout feature/payment-refactor

# Option A — Merge main into your branch (preserves history, safer for shared branches)
git fetch origin
git merge origin/main
# If there are conflicts here, they are small because you synced recently.
# Resolve them, then continue your work.

# Option B — Rebase onto main (linear history, better for solo feature branches)
git fetch origin
git rebase origin/main
# Rebase replays YOUR commits on top of the latest main.
# Each commit is replayed individually, so resolve conflicts per-commit if needed.
git rebase --continue   # run this after resolving each commit's conflicts


# -----------------------------------------------------------
# PREVENTION PATTERN 2: Check what a merge WOULD conflict on
# before actually running it (dry-run style inspection)
# -----------------------------------------------------------

# Find files changed on both branches since they diverged:
git fetch origin
MERGE_BASE=$(git merge-base HEAD origin/main)   # find the common ancestor commit
echo "Common ancestor: $MERGE_BASE"

# Files your branch touched:
git diff --name-only "$MERGE_BASE" HEAD

# Files main touched since that ancestor:
git diff --name-only "$MERGE_BASE" origin/main

# The overlap between these two lists = your likely conflict zones
# Review those files with your teammate BEFORE merging
▶ Output
# After git merge --abort:
On branch main
nothing to commit, working tree clean

# After git merge-base:
Common ancestor: 4d8a1f3c2e9b0a7f6d5c4b3a2e1f0d9c8b7a6f5e

# Files your branch touched:
sauce.py
routes/menu.py

# Files main touched:
sauce.py
tests/test_sauce.py

# Overlap: sauce.py — coordinate with teammate before merging!
⚠️
Pro Tip: The Daily Sync HabitSet a calendar reminder: every morning before writing new code, run `git fetch origin && git rebase origin/main` on your feature branch. Teams that do this report conflicts shrinking from multi-hour ordeals to five-minute fixes. The longer you wait, the more divergence accumulates.
Aspectgit mergegit rebase
Conflict frequencyOnce per merge operationOnce per replayed commit — can be multiple times
History shapePreserves branch topology with a merge commitCreates linear history, no merge commit
Safe on shared branches?Yes — non-destructive, history is additiveNo — rewrites commits, dangerous if others have the branch
Conflict resolution granularityResolve all conflicts in one sessionResolve conflicts commit-by-commit
Undo if wronggit merge --abortgit rebase --abort
Best forMerging completed feature branches to mainKeeping a solo feature branch up to date with main
Conflict markers placementAll conflicts surfaced at once in affected filesConflicts appear per-commit as each is replayed
Audit trailMerge commit shows what was merged and whenNo merge commit — cleaner log, less traceability

🎯 Key Takeaways

  • A merge conflict is Git doing its job correctly — it stops when it cannot safely combine changes, rather than silently picking a winner and hiding a bug in your code.
  • Enable git config --global merge.conflictStyle diff3 immediately — without the ancestor block in your conflict markers you are always resolving conflicts with incomplete information.
  • Never use git checkout --ours . or --theirs . (dot = all files) casually — these silently discard entire file versions across your whole working tree with no undo prompt.
  • The most effective conflict prevention strategy is syncing your feature branch with main daily using git fetch && git rebase origin/main — small frequent syncs beat one giant painful merge every time.

⚠ Common Mistakes to Avoid

  • Mistake 1: Accepting all incoming or all current changes blindly — Symptom: code appears to compile but features go missing or tests fail silently after the merge — Fix: never use git checkout --theirs . (dot = all files) without reading what you are discarding. Always diff the result with git diff HEAD~1 after a merge commit to verify both sides contributed the right code.
  • Mistake 2: Committing conflict markers into the codebase — Symptom: your CI pipeline fails with SyntaxError or teammates open a file and find raw <<<<<<< HEAD text in production source — Fix: add a pre-commit hook that scans for markers: grep -r '<<<<<<<' --include='.py' --include='.js' --include='*.ts' . && exit 1. Tools like Husky make this trivial to enforce across the team.
  • Mistake 3: Running git merge --continue instead of git commit after resolving a standard merge conflict — Symptom: Git prints 'fatal: There is no MERGE_HEAD' or creates an unexpected commit state — Fix: for a regular merge, resolve conflicts, git add the files, then run git commit. Use --continue only for rebase and cherry-pick operations. Merge and rebase have different continuation commands because they work differently internally.

Interview Questions on This Topic

  • QWalk me through exactly what you do when you hit a merge conflict on a file that two teammates have both heavily modified. What information do you look at before deciding how to resolve it?
  • QWhat is the difference between `git merge --ours` and `git checkout --ours filename`? When would you use each, and what are the risks?
  • QIf you are in the middle of a rebase and hit conflicts on three consecutive commits, but you realize the rebase itself was the wrong approach, what do you do? What state does your branch end up in after you abort?

Frequently Asked Questions

How do I resolve a Git merge conflict without losing anyone's changes?

Open the conflicted file and read all three sections between the markers: your branch's version, the base (ancestor), and the incoming branch's version. Manually edit the file to combine both contributions correctly — you are not forced to pick one side entirely. Delete the markers, stage the file with git add, and commit. Using git config --global merge.conflictStyle diff3 first gives you the ancestor version inside the markers, which makes it much easier to see what both sides actually changed.

What does it mean when Git says 'Automatic merge failed; fix conflicts and then commit the result'?

Git successfully started the merge but found at least one file where both branches changed the same lines. It has already merged everything it could automatically — only the conflicted files need your attention. Run git diff --name-only --diff-filter=U to list every file still in conflict, resolve each one, stage them with git add, then run git commit to complete the merge.

Is it safe to use `git merge --abort` if I change my mind mid-conflict?

Yes, completely safe. git merge --abort rewinds your repository to the exact state it was in before you ran the merge command — your branch, your commits, and your working tree are all restored. It is the correct tool when you realize mid-merge that you targeted the wrong branch or need to fetch updated changes first. The same guarantee applies to git rebase --abort and git cherry-pick --abort for those operations.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousGit Tags and ReleasesNext →Introduction to Docker
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged