Practical Git Workflows #4: Resolving Conflicts — Structure, mergetool, and rerere

7 min read

The basic procedure of reading conflict markers and resolving them was covered in Git Basics #3: Branching and Merging — Fast-forward and 3-way. But the conflicts you meet in practice do not only come from merge. They appear in the same form during a rebase, during a cherry-pick, and while reapplying a stash. And there comes a moment when the manual skill of deleting markers and picking code is not enough: you cannot tell which side is ours, the markers do not show what the code originally was, and you find yourself solving the conflict you already solved yesterday. This post is about understanding conflicts through their structure and handling them with tools.

It runs in seven parts.

  • #1 Branching strategies — GitHub Flow and trunk-based
  • #2 rebase vs merge — decision criteria and the golden rule
  • #3 Interactive rebase — cleaning up commits with squash and fixup
  • #4 Resolving conflicts — structure, mergetool, and rerere ← this post
  • #5 Running Pull Requests — review size, commit messages, and draft PRs
  • #6 stash, cherry-pick, bisect — everyday tools
  • #7 Monorepos and Git — sparse-checkout, submodules, and LFS

This post starts from the three ingredients of a conflict — base, ours, and theirs — then covers why the direction looks flipped in a rebase, the zdiff3 setting that makes markers easier to read, editor integration, and rerere.

The structure of a conflict — base, ours, theirs #

Recall the 3-way merge from Git Basics. When Git decides whether something conflicts, it works with three versions as its ingredients.

  • base — the common-ancestor version from before the two branches diverged.
  • ours — the version on the side I am currently standing on (HEAD).
  • theirs — the version on the side being brought in.

If only one side changed relative to base, Git adopts the changed side automatically. A conflict is only when both sides changed differently from base — that is when the decision is handed to a human. In other words, resolving a conflict means deciding what to keep out of two lines of change that both started from base. Once this view is in place, every tool below sits on the same picture.

Why ours and theirs flip in a rebase #

Let’s clear up the number one source of confusion in practice first. In a merge conflict, ours is my branch and theirs is the other branch — matching intuition. But in a conflict during a rebase, it looks reversed.

Conflict during rebase — ours is the main side
$ git switch feature-retry
$ git rebase main
CONFLICT (content): Merge conflict in config.py
Markers inside config.py
<<<<<<< HEAD (ours)
retry_count = 5        ← code from main
=======
retry_count = 10       ← code from my branch
>>>>>>> 9f8e7d6 (theirs)

My code is on the theirs side. The reason lies in how rebase works. Rebase moves onto main and reapplies my commits one by one. While the reapplication is happening, HEAD sits on main’s history, so ours becomes main and the commit being applied right now becomes theirs. The direction did not flip — the reference point moved. This is where the classic accident happens: in a rebase conflict you pick ours “to keep my code” and get the exact opposite. The most reliable prevention is the habit of checking the marker labels against branch names.

zdiff3 — showing base inside the markers #

The weakness of the default conflict markers is that they do not show what the code originally was. Only the two final states are visible, so you have to infer what each side changed from base. Set merge.conflictStyle to zdiff3 and the markers include the base block.

zdiff3 setting
git config --global merge.conflictStyle zdiff3
Before — default markers
<<<<<<< HEAD
retry_count = 5
=======
retry_count = 10
>>>>>>> feature-retry
After — zdiff3 markers
<<<<<<< HEAD
retry_count = 5
||||||| 1a2b3c4 (base)
retry_count = 3
=======
retry_count = 10
>>>>>>> feature-retry

The middle ||||||| block is the common ancestor’s code. It was originally 3, one side changed it to 5 and the other to 10 — that fact reads directly off the markers. Being able to compare the intent of both changes raises the quality of the decision. Among one-line configuration changes, this one has one of the best cost-to-effect ratios.

Solving in an editor — the VS Code merge editor #

Instead of editing markers by hand, you can use a tool that gives you a 3-way view. When VS Code opens a conflicted file it offers a Resolve in Merge Editor button, showing ours, theirs, base, and the result in four panes. If you work terminal-first, git mergetool opens each conflicted file in your configured tool in turn.

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

It is also common to adopt one side wholesale per file rather than resolving inside the file. Generated files and lock files are the typical case. No need to open the file — one line ends it.

Adopt one side per file
git checkout --ours package-lock.json     # keep my side
git checkout --theirs package-lock.json   # keep their side
git add package-lock.json

Even after branch switching moved to switch, checkout --ours/--theirs for this purpose remains idiomatic. If you are in a rebase, double-check that ours and theirs point the opposite way, as sorted out above.

rerere — never solve the same conflict twice #

If you periodically rebase a long-lived branch, or carry the same change across several branches, you end up meeting exactly the same conflict repeatedly. Turn on rerere (reuse recorded resolution) and Git records how you resolved a conflict, then applies that resolution automatically the next time the same conflict appears.

Enable rerere
git config --global rerere.enabled true

Everything after that is automatic. Resolve a conflict and commit, and the resolution is recorded; when the same conflict comes back, the output tells you.

Meeting the same conflict again
CONFLICT (content): Merge conflict in config.py
Resolved 'config.py' using previous resolution.

The file is already filled in with the previous resolution, so you only review the contents and git add.

Note
rerere reapplies the recorded resolution as is, which means a wrong resolution is repeated as is too. When a past record looks suspicious, clear that file’s record with git rerere forget <file> and resolve it again.

When you took a wrong turn — the abort commands #

If things get tangled mid-resolution, going back to the pre-operation state and retrying is faster than forcing your way through. All three operations have an abort command.

SituationAbort commandWhere you return to
During a merge conflictgit merge --abortBefore the merge started
During a rebase conflictgit rebase --abortBefore the rebase started
During a cherry-pick conflictgit cherry-pick --abortBefore the cherry-pick started

Aborting is not failure. Rereading the markers with zdiff3 and retrying — with a smaller target if needed — always beats letting a conflict grow bigger.

Wrap-up #

Four takeaways from this post.

  • A conflict is the decision of what to keep out of two changes that diverged from base, and the ingredients are base, ours, and theirs.
  • In a rebase conflict the reference point moves, so ours is the main side. The habit of checking marker labels prevents accidents.
  • merge.conflictStyle zdiff3 shows base inside the markers and raises the quality of your decisions, and rerere solves recurring conflicts for you.
  • When things go wrong, --abort takes you back to before the operation so you can retry.

With conflicts covered, we move on to the gateway where branches land: the PR. In the next post, “Practical Git Workflows #5: Running Pull Requests — Review Size, Commit Messages, and Draft PRs”, we set the standards for running PRs as a team — the size that gets reviewed well, commit message conventions, and sharing progress with draft PRs.

X