How to Resolve Git Rebase and Merge Conflicts with Vim

Step-by-step guide to resolving git rebase and merge conflicts using vim/gvimdiff with vim-mergetool: understand LOCAL vs REMOTE, use zdiff3, and tame the layout for a faster, easier workflow.

How to Resolve Git Rebase and Merge Conflicts with Vim

Rebasing in git is sold as the tidy way to keep history clean, and merging is the everyday workhorse. That is until a conflict drops you into gvimdiff with four panes that all look “nearly right”.

If you’re comfortable with git and vim but find conflicts disorientating, it's not just you. The meanings of BASE, LOCAL and REMOTE aren't always clear; earlier commits may already have changed the file you’re looking at; and the default gvimdiff layout rarely explains itself.

In this post we’ll look at what git actually does in both a merge and a rebase, show how to interpret what gvimdiff is showing you, and share a few small tweaks that turn conflict resolution into a steady, repeatable and, above all, understandable workflow.

Configuration

There are some git and vim configurations that will make life easier.

Git

Configure git's merge tool and conflict style by running:

git config --global merge.tool gvimdiff
git config --global merge.conflictstyle zdiff3

Recommended git configuration

Omit --global to make changes to your local configuration (stored in .git/config). Alternatively, edit ~/.gitconfig and add:

[merge]
    tool = gvimdiff
    conflictStyle = zdiff3

Vim

This isn't required, but it will make managing conflicts easier. Add to your ~/.vimrc:

syntax enable
" Highligt diffs
highlight DiffAdd guibg=#E6FFE6
highlight DiffChange guibg=#FFFFE6
highlight DiffDelete guibg=#FFE6E6
highlight DiffText guibg=#FFCC00

" Highlight conflight zones
function! ConflictsHighlight() abort
    syn region conflictStart start=/^<<<<<<< .*$/ end=/^\ze\(=======$\||||||||\)/
    syn region conflictMiddle start=/^||||||| .*$/ end=/^\ze=======$/
    syn region conflictEnd start=/^\(=======$\||||||| |\)/ end=/^>>>>>>> .*$/

    highlight conflictStart guibg=#E6FFE6
    highlight conflictMiddle guibg=#F49BAB
    highlight conflictEnd guibg=#FEFFC4
endfunction

augroup MyColors
    autocmd!
    autocmd BufEnter * call ConflictsHighlight()
augroup END

Recommended vim configuration

I also use git-prompt.sh, which gives status information about the current git repo within the shell prompt.

Sample git repository

If you want to follow through the rest of this post, create a new directory and initialise it as a git repo:

git init

Create a file, f.txt, with one number per line as follows:

1
2
3
4
5
6
7
8
9
10

Add it and commit it:

git add f.txt
git commit -m "Initial version"

Fork a feature branch but remain on master:

git branch feature

Edit f.txt on the master branch to delete line 2, add a line after 3, and change line 7:

1
3
Added master
4
5
6
7 conflict on master
8
9
10

Commit the change

git add f.txt
git commit -m "Edits on master"

Switch to the feature branch (git switch feature) and edit f.txt to add a line at the start, edit line 7 and remove line 9:

Add feature
1
2
3
4
5
6
7 feature conflict
8
10

Commit the change:

git add f.txt
git commit -m "Edits on feature"

Both branches have now diverged from BASE, but most changes don't conflict. Line 7, though, has been changed in both branches.

Merging

The sample above is very simple with one commit per branch after the fork. We could illustrate it like this:

A---B (master)
  \
    C (feature)

More typically there would be more commits on both branches, and possibly other branches involved too. In reality your git repo may look more like this:

A---B---D---F (master)
    \
     C---E (feature)

The principle is the same: two branches that have diverged over time from a common starting point.

Before we look at what git does during a merge, some terminology:

  • The HEAD of each branch is a pointer to the tip of that branch (F and E in the above diagram). You can list the SHA-1 checksum for each branch's HEAD with:

    git show-ref --heads
  • The merge base is the commit where two separate branches diverged from one another (B in the above diagram). You can list the merge base for two (or more) branches with:

    git merge-base master feature

What happens during a git merge?

You want to ensure that changes made to the master branch are incorporated into your feature branch. From the feature branch you run:

$ git merge master

This will creates a “merge commit” on the feature branch that includes the history of each branch. It looks like this:

A---B---D---F (master)
    \        \
     C---E----G (feature)

For each file that changed after commit B, a change made in only one branch will propagate to the merge commit, regardless of which branch it was made in.

If a different change is made to the same line(s) in both branches we'll get a merge conflict.

Illustrating a merge conflict

Staying on our feature branch, let's try to merge in the changes from master:

$ git merge master
Auto-merging f.txt
CONFLICT (content): Merge conflict in f.txt
Automatic merge failed; fix conflicts and then commit the result.

git merge resulting in a merge conflict

Before we look at the conflicts, some more terminology. Git refers to the merge base as BASE; to the branch we are merging onto (feature in this case) as LOCAL; and the branch we are merging from (master in this case) as REMOTE.

Git has now inserted conflict markers into f.txt. If we open it in gvim, the syntax highlighting we specified earlier highlights the conflicts:

gvim editing a file with conflict markers

Interpreting conflict markers

The conflict markers show three variants of the conflict.

The top section, from the left angle brackets to the vertical bars and highlighted in green, is labelled "HEAD" and shows the version of the file for the current HEAD, which is the branch we are on (feature) and is also the LOCAL version.

The middle section, from the vertical bars to the equals signs and highlighted in pink, is labelled "0063e66" and shows the version of the file in BASE (0063e66 is the shortened SHA-1 of the merge-base commit).

The bottom section, from the equals signs to the right angle brackets and highlighted in yellow, is labelled "master" and shows the version of the file for the REMOTE file, which is the version we are merging in (master).

Conflict resolution

For a simple conflict like this, we could just edit f.txt, remove the conflict markers (lines of <, |, = and >) and leave whatever we want for line 7 behind (or remove it altogether).

However, it's helpful to look at this simple conflict using gvimdiff which we set up as git's merge tool.

Using gvimdiff

If we run git mergetool, gvimdiff will open and look as follows:

gvimdiff as git's mergetool

There's a lot going on here. Helpfully - to some extent - each window is labelled with the version of the our conflicted file, f.txt, that it contains.

In fact, if we look in our repo directory, we will see that git has created some temporary files:

$ ls
f_BACKUP_14319.txt  f_BASE_14319.txt  f_LOCAL_14319.txt  f_REMOTE_14319.txt  f.txt

The top left window shows the LOCAL file, the version on our feature branch before the merge.

The top middle window shows the BASE file, from when the fork occurred.

The top right window shows the REMOTE file, the version on our master branch before the merge.

Finally, the bottom window shows the our conflicted file, f.txt as it currently appears, complete with conflict markers. This is the version we should edit and is often referred to as the MERGED version.

But why are there so many pink and green lines when only one line is in conflict?

How gvimdiff works

Buffers within vim may be marked to show differences (for example, with :diffthis). You can see which buffers are marked to show differences with

:echo filter(map(tabpagebuflist(), 'bufname(v:val)'), 'bufwinnr(v:val) != -1 && getbufvar(v:val, "&diff")')

Here's the result of running that command on the current gvimdiff:

Within one tab, each buffer so marked is compared with all other buffers so marked. We've set up syntax highlighting to show lines that have been added (green), are missing (red) or are changed (yellow).

One (non-conflicting) line was added at the start of the file on the feature (LOCAL) branch. That means the LOCAL branch differs from both BASE and REMOTE, and the differences are highlighted with the added top line of LOCAL shown in green (the top left and bottom windows), and the "missing" line in red in the other two windows.

This process is repeated for all differences between any of LOCAL, BASE and REMOTE.

In summary: gvimdiff will show all changes, not just conflicts. In a big merge that can mean a lot of noise.

A cleaner gvimdiff

Alexey Samoshkin has created a vim plugin, vim-mergetool, which greatly improves gvimdiff when it is used to resolve git merge conflicts.

Install the plugin however you prefer to install vim plugins.

Update your ~/.gitconfig file to specify that the merge tool is now vim_mergetool as follows:

[merge]
    # tool = gvimdiff
    tool = vim_mergetool
    conflictstyle = zdiff3

[mergetool "vim_mergetool"]
    cmd = gvim -f -c "MergetoolStart" "$MERGED" "$BASE" "$LOCAL" "$REMOTE"
    trustExitCode = true

For the moment we'll configure vim_mergetool to display the same windows as the native gvimdiff did. Add this to your ~/.vimrc:

set hidden
let g:mergetool_layout = 'lbr,m'

If you've been following along, close the gvimdiff window, abort the current merge, run it again and re-invoke mergetool:

git merge --abort
git merge master
git mergetool

Here's the new gvimdiff window:

This is much cleaner. The top three windows show the local, base and remote files with the non-conflicting changes merged in, but only the conflict is highlighted. The merged window is at the bottom and it has defaulted to taking the "local" version of the conflict.

How vim-mergetool manages files with conflicts

You may have noticed that the top three windows are now named in lowercase rather than uppercase as before ("local" vs "LOCAL"). vim-mergetool has created localremotebase buffers from conflict markers in the MERGED file and then removed the conflict markers from the final version. All resolved (non-conflicting) merges are shown without any special highlighting.

We forced vim-mergetool to display the four windows shown above. The default configuration of vim-mergetool shows only the merged file and the remote. To see how that appears, enter:

:MergetoolToggleLayout mr

Merge example: vim-mergetool showing just merged and remote

The result is an even cleaner layout. By default, vim-mergetool has selected the LOCAL (feature) branch version of the conflict, but this default may be changed as we'll see later. We can work thought the conflicts using the standard ]c and [c vim commands to jump to the next or previous conflict respectively. Once on a conflict, we can use the standard do or dp commands to "obtain" or "put" this version from or to the other window. Note that the remote window is read-only; the only file we can edit is the merged version.

To revert our configuration to this default, remove the let g:mergetool_layout = 'lbr,m' line from your ~/.vimrc - but leave it as it is for the moment.

Finishing the merge

The aim here is to make the left-hand window (the MERGED file) look as we want it to with respect to the conflicts, possibly by simply accepting the automatically-chosen LOCAL resolution as shown by default above. Once we're happy with the left-hand window we can write the change and quit with :xa.

Our file, f.txt, will be shown by git as a change to be committed, which we can then do:

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

Changes to be committed:
	modified:   f.txt

$ git commit -m "Merge changes from master"

Rebasing

A git rebase is another way of merging changes between branches, but there are some subtle and important differences from a git merge.

In the diagram below, we start from the same place as the earlier git merge example: the feature branch was forked from revision B on the master branch, and has had commits C and E added; meanwhile, master has continued with commits D and F.

A---B---D---F (master)
    \
     C---E (feature)

You want to ensure that changes made to the master branch are incorporated into your feature branch. From the feature branch you run:

$ git rebase master

This is what happens:

  1. git finds the merge base of the two branches (B)
  2. It saves the diff introduced by each commit of the branch you’re on (feature) to temporary files
  3. It resets the current branch to point to the last commit of the branch you are rebasing onto (master, commit F in this case)
  4. It applies each of the saved diffs from step 2 in sequence.

This is the end result:

A---B---D---F (master)
            \
             C2---E2 (feature)

The C2 and E2 commits differ from the original C and E commits because they are being reapplied to a different base. The SHA-1 checksums will also differ from the original commits. In other words, we have changed the history for the feature branch. If we had previously pushed the feature branch to a remote repository, we'd need to git push --force now because the remote HEAD no longer exists in our local repo.

Also notice that the merge base has moved. Whereas it was B before, it's now F.

Managing merge conflicts from rebase

Merge conflicts from a rebase can be complex, but let's start with a simple case and revisit exactly what we did with the merge above.

First, let's see our commit history. From the feature branch we run:

$ git log --oneline
176107f (HEAD -> feature) Edits on feature
0063e60 Initial version

Now let's rebase onto master with git rebase master. This is what happens:

git rebase with conflicts

Git has pointed to the master branch and tried to reply the diff history. In this case there is only one commit to reply, and the third line of the output shows that commit 176107f caused a conflict.

If we inspect f.txt, we see conflict markers as before, but the top section, HEAD, now shows the master branch and the bottom section the feature branch. That's the opposite way around to what we saw in the merge. What's going on?

Remember that when we rebase onto master, git temporarily points to the most recent master commit before replaying the diffs. That makes the master branch our LOCAL file and the feature branch the REMOTE file.

This is confirmed when we run git mergetool:

The local window, top left, has the master branch and the remote window, top right, has the feature branch.

LOCAL vs REMOTE for merge and rebase

When resolving conflicts in a merge:

  • LOCAL (ours): your current branch (e.g. feature)
  • REMOTE (theirs): the branch you’re merging in (e.g. master)

When resolving conflicts in a rebase:

  • LOCAL: the branch you're rebasing onto (e.g. master)
  • REMOTE: the feature commit being replayed

Fix vim-mergetool for rebase conflicts

If we enter :MergetoolToggleLayout mr as we did before, vim-mergetool shows us the merged file with the conflict from the master branch selected by default, and compares it with the feature branch:

This is typically not what we want - we would usually prefer our feature branch version and want to compare it with the master version. In other words, for a conflict arising from a git rebase, we want to:

  • prefer the changes from the feature (REMOTE) version; and
  • see the merged compared with the master (LOCAL) version

We can achieve that with the following two vim commands:

:MergetoolPreferRemote
:MergetoolSetLayout ml

Now our gvimdiff layout looks like this:

That layout assumes that, for merge conflicts, the feature branch version is correct.

Finishing the rebase

Once we've resolved all conflicts (or we're just happy to accept the version of the file on the left), we write and close all files. The easiest way to do that is :xa in vim.

If we check git status, we can see that all conflicts are resolved and that we only need to run git rebase --continue to clean up:

$ git status
interactive rebase in progress; onto e00fdc5
Last command done (1 command done):
   pick 176107f Edits on feature
No commands remaining.
You are currently rebasing branch 'feature' on 'e00fdc5'.
  (all conflicts fixed: run "git rebase --continue")

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   f.txt

If we now look at the git log, we can see that the SHA-1 for the "Edits on feature" commit has changed, reflecting that this commit was "replayed" on top of the master branch:

$ git log --oneline
0b3ea56 (HEAD -> feature) Edits on feature
e00fdc5 (master) Edits on master
0063e60 Initial version

More complex rebases

Sometimes rebases can cause head-scratching. Let's set up a simple and rather contrived example. Initialise a new directory as a git repository as before, then create f.py as follows:

#!/usr/bin/env python3

def fn(x: int) -> int:
    return x + 2

Add and commit it:

git add f.py
git commit -m "Initial version"

Fork a feature branch but remain on master:

git branch feature

Edit f.py on the master branch as follows:

#!/usr/bin/env python3

def fn(x: int) -> int:
    return x * 2 - 1

Add and commit:

git add f.py
git commit -m "Fix return value of fn"

Make one more edit on the master branch:

#!/usr/bin/env python3

def fn(x: int) -> int:
    return x * 3

Add and commit:

git add f.py
git commit -m "Properly fix return value of fn"

Switch to the feature branch (git switch feature) and make a similar series of edits:

  • Change the return line to return x * 2 + 1
  • Commit: git commit -am "Fixup fn"
  • Change the return line to return x * 3
  • Commit: git commit -am "Properly fix fn"

f.py has ended up the same on both branches, but it diverged during the development process. Of course, in reality the changes would be more numerous and complex, and would probably be spread over the longer period of time, but this will serve to illustrate a point.

Test merge

If we carried out a git merge at this point, it would complete without conflict:

$ git merge --no-commit --no-ff master
Automatic merge went well; stopped before committing as requested

Remember, a git merge will apply the differences between BASE and LOCAL, and between BASE and REMOTE. In this case, those two changes would be the same (ie, not in conflict), so the merge would succeed.

Rebase

From the feature branch, let's rebase onto master:

There were conflicts rebasing, and after rebasing f.py looks like this:

If these edits had actually taken place over a long period of time, the above could be confusing. Both branches have return x * 3 so where did these two return lines come from?

The conflict details

The git log on the feature branch:

$ git log --oneline
6fc5a6e (HEAD -> feature) Properly fix fn
8baa6b9 Fixup fn
be4e89c Initial version

git log from feature

The git log on the master branch:

$ git log --oneline
f694e2b (HEAD -> master) Properly fix return value of fn
2139ab6 Fix return value of fn
be4e89c Initial version

git log from master

The current git status:

$ git status
interactive rebase in progress; onto f694e2b
Last command done (1 command done):
   pick 8baa6b9 Fixup fn
Next command to do (1 remaining command):
   pick 6fc5a6e Properly fix fn
  (use "git rebase --edit-todo" to view and edit)
You are currently rebasing branch 'feature' on 'f694e2b'.
  (fix conflicts and then run "git rebase --continue")
  (use "git rebase --skip" to skip this patch)
  (use "git rebase --abort" to check out the original branch)

Unmerged paths:
  (use "git restore --staged <file>..." to unstage)
  (use "git add <file>..." to mark resolution)
	both modified:   f.py

no changes added to commit (use "git add" and/or "git commit -a")

Let's go through this carefully:

  • The merge base is be4e89c
  • Because this is a rebase, git then points to the HEAD of the branch we want to rebase onto (master, f694e2b)
  • Git now replays the diffs from BASE to the HEAD of feature one by one.
  • The first diff is in conflict - in that diff on the feature branch we changed return x + 2 to return x * 2 + 1. However, on the master branch that return line has already changed from return x + 2 so we have a conflict, and git will insert conflict markers as before. This is the pick 8baa6b9 Fixup fn in the status report above, which we can see from the git log command is the first commit after the fork. So far, so good.
  • git now takes the next diff ("Next command to do (1 remaining command): pick 6fc5a6e Properly fix fn" in the status report) and tries to apply it. However, the line it needs to change is already in conflict. git cannot proceed. The last line of the git rebase master command says Could not apply 8baa6b9..., and that is confirmed by the status report.
  • Because of the conflict on top of an existing conflict, git stops.

The solution is to resolve the existing conflicts and continue the rebase with git rebase --continue

The longer term solution to try to avoid these problems is to rebase frequently.

When to use merge versus rebase

So which should you use, merge or rebase? Here's a quick summary:

Merge:

  • Creates a new merge commit with two parents
  • All existing commits remain unchanged.
  • History fully visible.
  • The merge base does not change
  • Possibly one conflict resolution
  • Best for shared branches where history preservation is important

Rebase:

Key points:

  • Each commit of the feature branch is replayed on the master branch creating new commits.
  • There is no merge commit.
  • History is changed and made more linear.
  • The merge base moves forward.
  • Possibly multiple conflict resolutions
  • Best for cleaning up a local feature branch

Summary

Resolving git merge conflicts arising from git merge can be confusing, and those arising from git rebase even more so. Having a good understanding of what is actually happening, and by using the vim-mergetool plugin, conflict resolution becomes much easier.