Skip to content

Resolving Merge Conflicts in Git: A Practical Guide

Lesson 6 of 7Intermediate13 min readModern Git Workflows · MergingVerified: Git 2.43.0 on Ubuntu 24.04; every command and output in this lesson was run

A merge conflict is Git declining to guess. Both sides changed the same region of the same file differently, and there is no mechanical rule for which change is correct — so Git stops and asks you.

Conflicts are not errors, not failures, and not a sign anyone did anything wrong. They are the normal consequence of parallel work. What matters is resolving them by understanding both sides, rather than by picking one to make the markers go away.

Git compares three things: the merge base and the two branch tips. For each region of each file:

SituationGit’s action
Only one side changed itTake that change
Neither changed itKeep the base version
Both changed it identicallyTake it once
Both changed it differentlyConflict

That last row is the whole story. Note what does not cause a conflict: two people editing the same file in different places, a branch being old, or a large change. Git is comparing content regions, not files.

Everything below uses this scenario, which you can build yourself in the lab at the end. A configuration file where two branches changed the same setting.

Starting file on main:

service: api
port: 8080
timeout: 30
retries: 3

One branch reduces the timeout to 15; another increases it to 60. Merging them:

Terminal window
git merge feature/tune-timeouts
Auto-merging config.yml
CONFLICT (content): Merge conflict in config.yml
Automatic merge failed; fix conflicts and then commit the result.
Terminal window
git status

What it doesReports the state of the merge, listing files with unresolved conflicts under Unmerged paths.

Why we run itIt is the first thing to run. A merge may touch many files while only one or two conflict.

Expected resultAn Unmerged paths section listing each conflicted file with its conflict type, such as both modified.

Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: config.yml

The short form is quicker to scan:

Terminal window
git status --short
UU config.yml

The two-letter code describes what happened on each side:

CodeMeaning
UUBoth modified
AABoth added the file independently
DUDeleted by us, modified by them
UDModified by us, deleted by them
AUAdded by us, modified by them
UAModified by us, added by them
DDBoth deleted

To list just the conflicted paths, for scripting:

Terminal window
git diff --name-only --diff-filter=U
Terminal window
cat config.yml
service: api
port: 8080
<<<<<<< HEAD
timeout: 15
=======
timeout: 60
>>>>>>> feature/tune-timeouts
retries: 3

Three markers delimit the disagreement:

  • <<<<<<< HEAD — everything below this, until =======, is your version: the branch you are on.
  • ======= — the divider.
  • >>>>>>> feature/tune-timeouts — everything above this, from =======, is their version: the branch being merged in.

Lines outside the markers merged cleanly and are not in dispute.

The default markers show what each side has, not what they started from. That missing context often makes the decision obvious, and zdiff3 supplies it:

Terminal window
git checkout --merge --conflict=zdiff3 config.yml
service: api
port: 8080
<<<<<<< ours
timeout: 15
||||||| base
timeout: 30
=======
timeout: 60
>>>>>>> theirs
retries: 3

Now you can see that both sides changed the same value away from 30 — one down, one up. Without the base you might not realise both were deliberate changes rather than one being a stale copy.

Make it the default:

Terminal window
git config --global merge.conflictStyle zdiff3

You can also read the three versions directly from the index, without markers:

Terminal window
git show :1:config.yml # base
git show :2:config.yml # ours
git show :3:config.yml # theirs
timeout: 30
timeout: 15
timeout: 60

Those numbers are the index stages, which is exactly what the three-way comparison looks like as data:

Terminal window
git ls-files -s config.yml
100644 4cb29ea38f70d7c61b2a3a25b02e3bdf44905402 1 config.yml
100644 cb22e4730ab134e0c3809bda5bff5611fd64ed1d 2 config.yml
100644 f84b015d76af6565e0dacafa414130c0f7e1c214 3 config.yml

Resolution is a judgement, not a mechanical operation. Ask:

  1. What was each side trying to achieve? Read the commits: git log --merge -p config.yml shows the commits from both sides that touched the conflicted file.
  2. Are the intentions compatible? Often the answer is a third value that satisfies both — here, perhaps 30 was too long and 15 too short, and the right answer is neither original value.
  3. Does one side supersede the other? Sometimes one change was a stopgap the other replaced.
  4. Do you have enough context? If not, ask whoever wrote the other side. That is faster than guessing.

Edit the file to the intended result and remove all three markers:

service: api
port: 8080
timeout: 30
retries: 3
Terminal window
git add config.yml

What it doesMarks the file as resolved by collapsing its three conflict stages in the index down to a single stage-0 entry containing your resolved content.

Why we run itStaging is how you tell Git a conflict is resolved. There is no separate resolve command.

Expected resultNo output. git status --short changes from UU to M.

Terminal window
git status --short
M config.yml

Run the tests before committing. A resolution that compiles is not necessarily correct.

Terminal window
git commit --no-edit

--no-edit accepts Git’s prepared merge message, which lists the conflicted files. The result is an ordinary merge commit with two parents:

Terminal window
git cat-file -p HEAD | grep -c parent
2

Three exits, with different consequences:

CommandEffect
git merge --abortCancel the merge; restore working tree, index and HEAD to the pre-merge state
git merge --quitLeave merge state but keep the working tree as it is
git checkout --merge <file>Restore the conflict markers for one file, discarding your edits to it

git merge --abort is the safe default when you decide the merge should not happen now. It discards resolution work but touches no commits — both branches are exactly as they were.

git checkout --merge <file> is the undo for one file. If you have mangled a resolution and want to start that file again:

Terminal window
git checkout --merge config.yml
Recreated 1 merge conflict

Occasionally one side is simply correct — a generated file, a lockfile, a vendored dependency.

Terminal window
git checkout --ours config.yml # keep your branch's version entirely
git checkout --theirs config.yml # keep the incoming version entirely
git add config.yml

There are also strategy options that apply a preference to conflicting hunks only, leaving everything else merged normally:

Terminal window
git merge -X ours feature # prefer our side in conflicts
git merge -X theirs feature # prefer their side in conflicts

These are much safer than checkout --ours, because non-conflicting changes from both sides still land. They are still a blunt instrument, and reviewing the result matters. Advanced Merge Strategies covers the distinction between the ours strategy and the -X ours option, which is a genuinely dangerous confusion.

File deleted on one side, modified on the other

Section titled “File deleted on one side, modified on the other”
CONFLICT (modify/delete): config.yml deleted in feature/cleanup and modified in HEAD.

Git cannot merge “changed” with “gone”. Decide explicitly:

Terminal window
git rm config.yml # accept the deletion
# or
git add config.yml # keep the file with your modifications

Ask why it was deleted. A file deleted in a refactor and modified on your branch usually means your change needs to move somewhere else, not that the deletion was wrong.

CONFLICT (add/add): Merge conflict in utils.py

Two people created a file with the same name independently. There is no base version, so the markers show both files in full. Usually the right resolution is to combine them deliberately, then check nothing imports the wrong one.

Git does not record renames; it detects them by comparing content similarity. That mostly works, and occasionally produces:

CONFLICT (rename/rename): Rename "a.py"->"b.py" in branch HEAD rename "a.py"->"c.py" in feature

Both sides renamed the same file differently. Pick a name, git add it, and remove the other.

A subtler case is rename/modify: one side renames the file while the other edits it. Git usually applies the edit to the renamed file, which is what you want — but verify, because detection is heuristic and depends on how much the content changed. Raising merge.renameLimit helps on large merges where detection was skipped for performance.

warning: Cannot merge binary files: logo.png (HEAD vs. feature)
CONFLICT (content): Merge conflict in logo.png

There is no line-based merge for binaries. You must choose one:

Terminal window
git checkout --ours logo.png
git add logo.png

If both versions contain needed work, the merge has to happen in the tool that produced the file, and the result committed as a fresh version.

Most modern editors highlight conflict regions and offer “accept current / accept incoming / accept both” actions. These are convenient and carry the same risk as --ours/--theirs: they make it easy to click past a decision you have not made.

Terminal window
git mergetool

What it doesLaunches a configured three-way merge tool for each conflicted file in turn.

Why we run itA three-pane view showing base, ours and theirs side by side makes complex conflicts substantially easier to reason about than markers in a single file.

Expected resultYour configured tool opens per file. On exit, the resolved file is staged automatically.

Configure it once:

Terminal window
git config --global merge.tool vimdiff # or meld, kdiff3, vscode…
git config --global mergetool.keepBackup false

keepBackup false stops Git leaving .orig files behind after each resolution — which otherwise get committed by accident with distressing regularity.

After committing a merge, this shows what you actually decided:

Terminal window
git show --cc HEAD
diff --cc config.yml
index 5a130c2,340d91f..63cf3ec
--- a/config.yml
+++ b/config.yml
@@@ -1,4 -1,4 +1,4 @@@
service: api
port: 8080
- timeout: 15
-timeout: 60
++timeout: 30
retries: 3

A combined diff shows only regions where the result differs from both parents — precisely the places you resolved by hand. The two-column prefix indicates which parent each line differs from. This is the best available review of a merge resolution, and it is worth running on any non-trivial merge before pushing.

The same machinery — markers, index stages, git add to resolve — appears in several other operations, with different commands to continue or abort.

OperationContinueAbortSkip this step
git mergegit merge --continuegit merge --abort
git rebasegit rebase --continuegit rebase --abortgit rebase --skip
git cherry-pickgit cherry-pick --continuegit cherry-pick --abortgit cherry-pick --skip
git revertgit revert --continuegit revert --abortgit revert --skip
git stash popResolve, then git addgit checkout --merge <file>

Three differences are worth internalising.

Rebase conflicts arrive one commit at a time. A rebase replays each commit separately, so a conflict in an early commit can recur in every later one that touches the same region. This is why a rebase of a long-lived branch can feel like resolving the same conflict five times — and why rerere is worth enabling if you rebase often.

“Ours” and “theirs” are inverted during a rebase. Because Git checks out the upstream branch and replays your commits onto it, “ours” is the branch you are rebasing onto and “theirs” is your own work. Reaching for --ours out of habit during a rebase discards your own commit’s changes.

--skip drops a commit entirely. If a rebase conflict is because the change is already present upstream, skipping is correct. If you skip because the conflict looked hard, you have silently deleted that commit’s work. Always know which case you are in.

Occasionally you start a merge and find fifty conflicted files. Resolving them one by one under those conditions produces mistakes.

Better options, in order:

Abort and reduce the scope. git merge --abort costs nothing. Then look for a way to make the merge smaller — land part of the branch first, or split it.

Merge in stages. If the branch has distinct phases, merge an intermediate commit rather than the tip:

Terminal window
git merge <a-commit-partway-along-the-branch>

Resolve that smaller set, commit, then merge the rest. Each step is comprehensible.

Rebuild rather than merge. If the branch has drifted so far that reconciling is guesswork, it is sometimes faster to start from current main and reapply the intent — using the old branch as a reference rather than as something to merge.

Get the other author. For conflicts in code you did not write, a five-minute conversation beats an hour of inference. This is the most under-used option.

Whichever route you take, work in a scratch branch so an abandoned attempt costs nothing:

Terminal window
git switch -c merge-attempt main
git merge feature

If it goes badly, delete the branch. main was never touched.

Most conflicts are avoidable, and the techniques are all about reducing divergence:

Integrate frequently. The dominant factor. Short-Lived Branches covers why.

Sync before you finish. Bringing main into your branch a day before you intend to merge means resolving conflicts calmly rather than under pressure.

Keep changes focused. A branch that touches forty files conflicts with everything.

Agree on formatting. Automated formatters remove entire categories of conflict caused by whitespace and line-wrapping differences. Apply formatting in its own commit so it never mixes with logic changes.

Do not reformat unrelated code. A change that reindents a whole file conflicts with every other branch touching it.

Structure files to reduce collisions. Append-only changelogs, alphabetised lists and one-declaration- per-line formats all conflict less than dense, ordered blocks.

Enable rerere if you rebase repeatedly and keep meeting the same conflict:

Terminal window
git config --global rerere.enabled true

Git records how you resolved a conflict and reapplies that resolution when it sees the same one again.

Deleting the markers and keeping whichever side looks plausible. The markers are punctuation. The decision is a judgement about what the code should do.

Using --ours/--theirs to make it stop. Discards the whole file’s changes from one side, including parts that merged cleanly.

Committing markers. Run git diff --cached --check before committing.

Not running tests after resolving. A resolution can be syntactically valid and semantically wrong.

Resolving a rebase conflict with merge intuitions. “Ours” and “theirs” are inverted. Read the content.

Resolving the same conflict repeatedly during a rebase without rerere. A rebase replays commits one at a time, so the same conflict can recur several times.

Panicking. git merge --abort returns you to exactly where you were. Nothing is lost, and you can start again with a clearer head.

A conflict is Git saying: “you both changed this; I do not know what you meant.”

The markers are not damage. They are a question, with each side’s answer quoted so you can compare them. Your job is to write the answer that satisfies both intentions — which is sometimes one side, sometimes the other, and quite often neither.

  • Conflicts occur when both sides change the same region differently, relative to the merge base.
  • git status lists unmerged paths; two-letter codes describe the conflict type.
  • Markers delimit ours, theirs and — with zdiff3 — the common ancestor.
  • The index holds all three versions at stages 1, 2 and 3, readable with git show :N:<file>.
  • git add is how you mark a conflict resolved; there is no separate resolve command.
  • git merge --abort cancels safely; git checkout --merge <file> restarts one file.
  • --ours/--theirs take a whole file and silently discard the other side’s clean changes.
  • git show --cc reviews exactly what you resolved by hand.
  • Frequent integration, focused changes and consistent formatting prevent most conflicts.

Build a conflict deliberately, in a disposable repository. Nothing here can affect real work.

  1. Create the repository and starting file.

    Terminal window
    mkdir ~/conflict-lab && cd ~/conflict-lab && git init
    printf 'service: api\nport: 8080\ntimeout: 30\nretries: 3\n' > config.yml
    git add config.yml && git commit -m "Initial configuration"
  2. Create a branch that raises the timeout.

    Terminal window
    git switch -c feature/tune-timeouts
    sed -i 's/timeout: 30/timeout: 60/' config.yml
    git commit -am "Increase timeout to 60s"
  3. Change the same line differently on main.

    Terminal window
    git switch main
    sed -i 's/timeout: 30/timeout: 15/' config.yml
    git commit -am "Reduce timeout to 15s"
  4. Merge, and predict the outcome first.

    Terminal window
    git merge feature/tune-timeouts
  5. Inspect. Run git status --short (expect UU), then cat config.yml.

  6. Add the base. Run git checkout --merge --conflict=zdiff3 config.yml and look again. Does the base value change your view of the right answer?

  7. Read the three stages: git show :1:config.yml, :2:, :3:.

  8. Resolve to a value you can justify — try 30, satisfying neither side, and consider whether that is actually the right engineering call.

  9. Check for markers: git add config.yml && git diff --cached --check.

  10. Commit with git commit --no-edit, then review your decision: git show --cc HEAD.

  11. Now practise aborting. Reset with git reset --hard HEAD~1, merge again, then run git merge --abort and confirm with git status that you are back to a clean tree.

Step 6 is the one worth slowing down for. Seeing the base version changes how the conflict reads, which is why zdiff3 is worth configuring globally.

Git has more than one merge algorithm, and options that change how the default one behaves. The final lesson in this cluster covers them, including where a strategy can silently discard changes.