Resolving Merge Conflicts

When two branches change the same part of a file in different ways, Git can’t automatically decide which version is “correct” — it stops mid-operation and asks you to decide. This happens constantly in real projects: two teammates edit the same function, or you rebase a long-lived feature branch onto a main that has moved on since you branched. A merge conflict is not an error and doesn’t mean you did something wrong; it’s Git being honest that it needs a human judgment call. This lesson walks through exactly what happens inside Git when a conflict occurs, and how to read, resolve, and commit through one with confidence.

Overview: How Merge Conflicts Happen

Every commit in Git points to a tree object — a snapshot of the project’s files and directories — which in turn points to blob objects holding file contents. When you run git merge <branch>, Git doesn’t just smash the two trees together; it performs a three-way merge using three inputs:

  • The merge base — the most recent commit that is an ancestor of both branches, found automatically by walking the commit graph.
  • Ours — the tip of the branch you currently have checked out (HEAD).
  • Theirs — the tip of the branch you’re merging in, recorded temporarily in MERGE_HEAD.

For each file, Git compares what changed between the base and each side. If only one side changed a given region of a file, Git applies that change automatically — this is why most merges complete with no conflicts at all. A conflict happens only when both sides changed the same lines (or one side edited a file the other side deleted, or both sides added a file with the same name but different content). Git has no way to know which version, or what combination, is correct, so it stops and hands the decision to you.

When a conflict is detected, several things happen at once. Git inserts special conflict markers directly into the working-tree copy of the affected file, showing both versions side by side. In the index (the staging area), the conflicted path is recorded not as one entry but as three “stages” — stage 1 for the common ancestor, stage 2 for our version, and stage 3 for their version — which is why Git can still show you a diff for each side individually. A file .git/MERGE_HEAD is written recording the commit being merged in, and .git/MERGE_MSG holds a draft commit message. Nothing is committed yet: HEAD still points at your previous commit until you finish resolving and run git commit.

The same underlying machinery runs during git rebase, but the roles are subtly different. A rebase replays your branch’s commits, one at a time, on top of a new base commit. At each conflicting commit, HEAD temporarily points at the new base you’re rebasing onto, and the incoming side is the commit being replayed from your own branch. That means “ours” and “theirs” effectively swap meaning compared to a normal merge — a detail covered in Common Mistakes below.

Syntax

Resolving a conflict isn’t a single command — it’s a short workflow. The general shape looks like this:

git merge feature/update-readme
# Git reports CONFLICT and pauses
# 1. open each conflicted file and edit it to the correct final content
# 2. stage the resolved file(s)
git add README.md
# 3. finish the operation
git commit          # merge: opens/accepts a merge commit message
git rebase --continue   # rebase: moves on to the next replayed commit

The commands you’ll reach for while resolving a conflict:

Command What it does
git status Lists which paths are unmerged and reminds you of next steps.
git diff Shows the conflicting hunks, marked with <<<<<<< / ======= / >>>>>>>.
git add <file> Marks a conflict as resolved by staging your final version of the file.
git commit --no-edit Completes a merge using Git’s auto-generated merge commit message.
git merge --abort Cancels an in-progress merge and restores the pre-merge state.
git rebase --continue After staging fixes, applies the next commit in the rebase.
git rebase --skip Drops the current commit being replayed entirely.
git rebase --abort Cancels the rebase and restores the branch to its pre-rebase tip.
git checkout --ours <file> Discards the conflict and takes “our” side’s whole file.
git checkout --theirs <file> Discards the conflict and takes “their” side’s whole file.
git restore --ours <file> Modern equivalent of git checkout --ours for just the working-tree file.
git mergetool Launches a configured visual merge tool to resolve conflicts interactively.

Examples

Example 1: A basic merge conflict

Two branches both edit the first line of README.md, then one is merged into the other.

git switch -c feature/update-readme main
echo "Welcome to Project Atlas - the fast way to manage tasks." > README.md
git add README.md
git commit -m "docs: rewrite intro paragraph in README"

git switch main
echo "Welcome to Project Atlas, a lightweight task manager." > README.md
git add README.md
git commit -m "docs: tweak README intro wording"

git merge feature/update-readme

Output:

Auto-merging README.md
CONFLICT (content): Merge conflict in README.md
Automatic merge failed; fix conflicts and then commit the result.

Both branches changed the same line since their common ancestor, so Git can’t pick a winner automatically. Running git status confirms the file is unmerged:

git status
On branch main
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

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

Opening README.md shows Git’s conflict markers inserted directly into the file:

<<<<<<< HEAD
Welcome to Project Atlas, a lightweight task manager.
=======
Welcome to Project Atlas - the fast way to manage tasks.
>>>>>>> feature/update-readme

Everything between <<<<<<< HEAD and ======= is your current branch’s version; everything between ======= and >>>>>>> feature/update-readme is the incoming branch’s version. You edit the file down to a single correct result — one side, the other, or a hand-written combination — and delete all three marker lines yourself; Git never removes them for you. Suppose you decide to combine both intros. After editing the file to its final text, stage and commit:

git add README.md
git commit --no-edit
[main 9f3a1c2] Merge branch 'feature/update-readme'

git commit with no message argument opens your editor pre-filled with a default merge message (here accepted with --no-edit); the resulting commit has two parents — the previous tip of main and the tip of feature/update-readme — which is what makes it a true merge commit rather than a regular one.

Example 2: A conflict during rebase

Rebasing replays commits one at a time, so conflicts are resolved per-commit instead of all at once.

git switch feature/user-auth
git rebase main
Auto-merging src/auth.js
CONFLICT (content): Merge conflict in src/auth.js
error: could not apply a1b2c3d... feat: add password reset flow
Resolve all conflicts manually, mark them as resolved with
"git add/rm <pathspec>", then run "git rebase --continue".
You can instead skip this commit: run "git rebase --skip".
To abort and get back to the state before "git rebase", run "git rebase --abort".

Here HEAD temporarily points at the commit on main that the rebase is replaying onto, and the conflict markers label the incoming side with the short SHA and subject of the commit being replayed, not a branch name. Resolve exactly as before, then continue the rebase rather than committing normally:

git add src/auth.js
git rebase --continue
[detached HEAD 7d4e2f1] feat: add password reset flow
Successfully rebased and updated refs/heads/feature/user-auth.

Because a rebase can touch many commits, this conflict-resolve-continue cycle may repeat several times before it finishes — normal for a branch with a long history of changes to the same file.

Example 3: Backing out, or taking a whole side

Sometimes the safest move is to stop entirely and think, or the conflicted file is something like a lockfile where you want to keep one side wholesale rather than hand-edit it.

git merge --abort

This cancels the in-progress merge completely and restores your branch, working tree, and index to exactly how they were before you ran git merge — as if it never happened. The rebase equivalent is git rebase --abort. For a file like a package manager lockfile, where hand-combining both sides makes no sense, you can take one side’s version wholesale instead of editing markers:

git checkout --theirs package-lock.json
git add package-lock.json
git commit --no-edit

This discards your side’s version of that one file, accepts the incoming branch’s version outright, and stages it as resolved. For a more visual approach on any conflicted file, git mergetool opens a configured diff tool (VS Code, vimdiff, and Meld are common choices) that lays out both sides and the base side by side.

How It Works Step by Step

When git merge hits a conflicting file, this is the underlying sequence:

  1. Git locates the merge base — the nearest common ancestor commit of HEAD and the branch being merged — by walking the commit graph.
  2. Git performs a three-way diff per file: base → ours, and base → theirs.
  3. Non-overlapping changes are applied automatically to both the working tree and the index; the file is fully staged and no conflict is reported.
  4. For an overlapping hunk, Git writes both versions into the working-tree file, wrapped in <<<<<<< / ======= / >>>>>>> markers, and leaves the path unmerged in the index — recorded as three stage entries (base, ours, theirs) instead of one.
  5. Git writes .git/MERGE_HEAD (pointing at the commit being merged in) and a draft message in .git/MERGE_MSG, then stops and reports which paths conflicted.
  6. You edit the working-tree file to its final content and remove the markers by hand — Git has no idea what “correct” looks like, so it never removes them itself.
  7. git add <file> replaces the three staged entries for that path with a single new blob object, content-addressed by its SHA-1 hash, representing your resolution, clearing the “unmerged” state.
  8. Once every conflicted path is staged, git commit creates a new commit object whose tree reflects the fully resolved snapshot, with two parent commits (your previous HEAD and MERGE_HEAD). The branch pointer moves to this new commit, and Git deletes MERGE_HEAD/MERGE_MSG since the operation is complete.

A rebase runs the same per-file conflict logic, but instead of one merge commit with two parents, it produces a new, single-parent commit for each replayed commit, applied one at a time under .git/rebase-merge/ until every original commit has been reapplied — at which point the branch ref is updated to the final new commit and the old commits become unreferenced.

Common Mistakes

Mistake 1: Committing with conflict markers still in the file

It’s easy to resolve most of a conflict and miss a marker buried lower in the file, especially in a large diff:

function calculateTotal(items) {
<<<<<<< HEAD
  return items.reduce((sum, i) => sum + i.price, 0);
=======
  return items.reduce((sum, i) => sum + i.price * i.qty, 0);
>>>>>>> feature/cart-quantity
}

Git will happily let you git add and commit this — the markers are just text to Git once you’ve staged the file — and this now ships broken code. Before staging a resolved file, search for leftover markers and, ideally, run the code or tests:

grep -rn "<<<<<<<\|=======\|>>>>>>>" src/

Mistake 2: Assuming –ours always means “my branch”

During a plain merge, --ours is the branch you had checked out and --theirs is the branch you’re merging in — matching intuition. During a rebase it flips: Git is replaying your commits onto the target branch, so --ours refers to the branch you’re rebasing onto and --theirs refers to the commit from your own branch being replayed.

Operation --ours means --theirs means
git merge feature (on main) main, your checked-out branch feature, the branch being merged in
git rebase main (on feature) main, the new base feature‘s own commit being replayed

Double-check with git status or git log --oneline --graph --all before trusting either side blindly.

Mistake 3: Force-pushing over a shared branch instead of resolving properly

Under pressure it’s tempting to run git push --force to “just make the conflict go away” after a local rebase. If anyone else has already pulled the branch, a bare --force silently overwrites their copy’s history on the remote, discarding commits they may not have locally. Prefer git push --force-with-lease, which refuses the push if the remote has commits you haven’t seen — and remember the golden rule of rebasing: never rebase, or force-push a rebase of, a branch that other people have already based work on.

Best Practices

  • Pull or fetch frequently so branches don’t drift far apart before you merge — small, frequent merges produce small, easy conflicts.
  • Run git status immediately after any failed merge or rebase; it tells you exactly which paths need attention and what command to run next.
  • Read both sides of a conflict before choosing — use git diff to review the whole hunk, not just the marker text, and understand why each side changed the line.
  • Run your test suite, or at least start the app, after resolving and before you commit — a syntactically clean merge can still be logically wrong.
  • For non-mergeable generated files, such as lockfiles or compiled assets, regenerate them after resolving rather than hand-merging their contents.
  • Use git log --merge to see just the commits that touched the conflicting paths on both sides — useful for figuring out whose intent to preserve.
  • Enable git config rerere.enabled true on branches where the same conflict tends to recur, such as a long-lived branch merged from main repeatedly — “reuse recorded resolution” remembers how you resolved a conflict and reapplies it automatically next time.
  • Prefer git push --force-with-lease over a bare --force any time you must push history you’ve rewritten.
  • When in doubt, git merge --abort or git rebase --abort costs nothing — it’s always safer to back out and re-approach than to guess on a conflict you don’t understand.

Practice Exercises

  1. Create a repo with a file menu.txt on main. Branch off as feature/prices, change a price on one line, and commit. Switch back to main, change the same line to something else, and commit. Merge feature/prices into main and resolve the resulting conflict by hand, ending with a single clean commit.
  2. Starting from the state left by exercise 1, create a second feature branch that edits the same line again, then rebase it onto the updated main instead of merging. Notice how the conflict markers and Git’s “onto”/”replaying” language differ from the merge case.
  3. In a throwaway repo, deliberately leave a conflict marker in a resolved file, stage it, and commit it. Then write the grep command you’d use to catch that mistake before it ever reaches a commit.

Summary

  • A merge conflict happens when both branches change the same region of a file, or one edits what the other deletes, since their common ancestor — Git can’t pick a side automatically.
  • Conflicted files get literal <<<<<<</=======/>>>>>>> markers written into the working tree, and are recorded as three “stages” in the index until resolved.
  • Resolve by editing the file to its final content, removing all markers, then git add the file and git commit for a merge, or git rebase --continue for a rebase.
  • git merge --abort and git rebase --abort safely cancel an in-progress operation if you want to back out.
  • --ours/--theirs flip meaning between merge and rebase — verify which side is which before trusting either blindly.
  • Never force-push a rewritten shared branch with a bare --force; use --force-with-lease, and never rebase a branch others have already built on.