DEV Community

Cover image for How to resolve merge conflicts in Git without guessing: markers, --ours/--theirs and the rebase flip
DevOps Daily
DevOps Daily

Posted on

How to resolve merge conflicts in Git without guessing: markers, --ours/--theirs and the rebase flip

You are rebasing your branch onto main. Git stops on a conflict in limits.py. You want to keep your version, so you type git checkout --ours limits.py, stage it, continue and push. Your change is gone. During a rebase, "ours" is main, and "theirs" is the commit you wrote.

That one swap is why so many people learn how to resolve merge conflicts in Git by trial and error: keep the top half, run the tests, try the bottom half. Most tutorials stop at "delete the markers and commit". This one covers the parts that bite later, including the rebase flip and the clean merge that still breaks the build.

The hands-on part is the Git Merge Conflict Simulator: six lessons and 27 steps. Five open on a repository that is already mid-conflict; the sixth starts just before a clean merge that breaks the code. You type commands into a terminal, edit files in an editor, and watch the "Index stages" and "Ours and theirs" panels change. It does not run real Git: it models the commands each lesson needs, with output based on real Git's. Disclosure: I help build it; it is free, runs in the browser, no signup.

A conflict is three versions, not two

Lesson 1, Read the conflict, opens after git merge feature/retry has stopped. config.yaml started with timeout: 30. Main raised it to 45, the feature branch to 60. The markers show two of the three versions:

service: api
<<<<<<< HEAD
timeout: 45
=======
timeout: 60
>>>>>>> feature/retry
retries: 3
Enter fullscreen mode Exit fullscreen mode

The third version, the common ancestor, is the most useful one and the one the markers hide. While a path is unmerged, Git keeps up to three copies of it in the index: stage 1 is the base, stage 2 is ours, stage 3 is theirs. The lesson has you run git show :1:config.yaml before you touch anything. Once you know the base was 30, you know both sides raised the timeout, so the question is "which increase wins", not "who broke it".

If you want the base inline every time, run git config --global merge.conflictStyle zdiff3 (added in Git 2.35). Conflicts then show a ||||||| section with the base between the two sides.

Resolving means writing the file you want

Lesson 2, Resolve by editing: main added redis==5.0.8 to requirements.txt, and a metrics branch added prometheus-client==0.20.0 at the same spot. The project needs both. Neither --ours nor --theirs gives you that, so you open the editor, delete the three marker lines, keep both packages and save. A save that still has markers or drops a package does not complete the step.

Then git add requirements.txt. That is how you tell Git a conflict is resolved: it replaces the three index stages with the one version you staged. Git does not read the content, so it will stage a file full of markers without complaint. In real Git, git diff --cached --check reports "leftover conflict marker" lines before you commit.

Take a whole side when nobody can merge it by hand

Lesson 3, Take a whole side: assets/logo.png is binary, so Git writes no markers at all. The working tree keeps main's logo and the index holds all three versions. The merge is a rebrand, so you want the incoming file: git checkout --theirs assets/logo.png, then git add and git commit --no-edit. During a merge, --ours is the branch you are on and --theirs is the branch you are merging in. Taking a side copies the whole file, so main's sharper outline is lost.

The rebase flip

Lesson 4, Rebase flips ours and theirs, is the opening story. A rebase checks out main's tip (HEAD is detached there) and replays your commits on top of it, one at a time. So HEAD and --ours mean main plus whatever of yours has been replayed so far, and --theirs is your commit being replayed. The simulator's side panel shows both rules:

During a --ours / HEAD --theirs
merge the branch you are on the branch you are merging in
rebase the rebased result so far your commit being replayed

The git checkout man page says the same: during git rebase and git pull --rebase, ours and theirs "may appear swapped". In the lesson the team agreed your stricter limit of 30 requests per minute beats main's 200, so the right command is git checkout --theirs limits.py, then git add and git rebase --continue. If you already typed --ours and have not staged it, git checkout -m limits.py recreates the conflict.

The escape hatch

Lesson 5, The escape hatch: you merged release/2025.12 when you meant release/2026.09, and six files are conflicted. None of them is worth resolving. git merge --abort tries to put the index and working tree back and removes MERGE_HEAD; git rebase --abort does the same for a rebase. The caveat the lesson spells out: if you had uncommitted changes when you started the merge, --abort may not be able to restore them. Commit or stash first.

Clean merge, broken code

Lesson 6, Clean merge, broken code, has no markers at all. Main renamed settings.get_timeout() to get_request_timeout() and fixed every caller it had. A week-old branch adds a new caller of the old name in a different file. git merge feature/worker succeeds without asking anything, make test fails, and you fix one line in worker.py. Each branch passed its own tests; only the combination is broken. Git merges text, not behaviour, which is why CI should run on the merge result, or a merge queue should test the combined code before it lands.

Try the flip in a real repo

To see lesson 4 on your own machine, build the same situation in a scratch directory:

mkdir conflict-lab && cd conflict-lab
git init -b main
echo "limit = 100" > limits.py
git add limits.py && git commit -m "Add rate limiting"

git switch -c feature/rate-limit
echo "limit = 30" > limits.py
git commit -am "Tighten the default rate limit"

git switch main
echo "limit = 200" > limits.py
git commit -am "Allow 200 requests per minute"

git switch feature/rate-limit
git rebase main
cat limits.py
Enter fullscreen mode Exit fullscreen mode

With the default conflict style on Git 2.39, the rebase stops and cat prints this (your hash will differ, and with zdiff3 set you also get a ||||||| base section):

<<<<<<< HEAD
limit = 200
=======
limit = 30
>>>>>>> f9ce8c6 (Tighten the default rate limit)
Enter fullscreen mode Exit fullscreen mode

The half labelled HEAD is main's line, even though you started on your feature branch. git show :2:limits.py prints limit = 200 and git show :3:limits.py prints limit = 30. Run git checkout --theirs limits.py, git add limits.py and git rebase --continue (save the commit message when the editor opens), and your 30 survives.

Where to go next

Do the six lessons in order: the first three build the model that makes the fourth obvious. The simulator is one of 50+ free DevOps games and simulators. Official references: the "How conflicts are presented" section of git-merge and the --ours/--theirs note in git-checkout.

Top comments (2)

Collapse
 
alexcodebytes profile image
Oleksandr •

Merge conflicts in config.yaml are the ultimate test of developer patience. Most people just blindly click 'Accept Ours' or 'Accept Theirs' and hope the CI/CD pipeline doesn't catch fire, so learning about :1:config.yaml is a lifesaver.

Collapse
 
mrsaynothing profile image
Mr Say Nothing •

The flip deserves the emphasis you gave it: a resolve script that says --ours breaks silently the day someone runs it mid-rebase, because ours is now the other branch. We added a guard that reads the rebase state before any scripted resolution runs — cheap insurance against a keyword that changes sides.