Vim Fugitive in action

21 min read

Vim Fugitive by Tim Pope is a Git wrapper for Vim. Its purpose is to integrate Git inside Vim, providing easier access to the most common features, and some additional ones that would be harder to replicate from the command-line interface (CLI).

Beginners might find Fugitive difficult because it provides a new interface and requires both a good knowledge of Git (concepts and commands from the first 3 chapters of the Git Book) and Vim (Ex commands, buffers, windows, diffs).

The reference documentation for Fugitive is accessible with :help fugitive. It contains the list of commands, keybindings, and object specifiers. The purpose of this article is to bridge the gap between a working knowledge of Git and the use of Fugitive through concrete examples.

§
Introduction

This article iterates on a FizzBuzz implementation in Python to demonstrate the use of Fugitive for common Git operations.

§
FizzBuzz

The algorithm is simple: for ii from 11 to 2020:

  • If 33 and 55 both divide ii, print "FizzBuzz".
  • Else if 33 divides ii, print "Fizz".
  • Else if 55 divides ii, print "Buzz".
  • Else, print ii.

§
Git

Initialize a new Git repository at ~/fizzbuzz:

$ git init ~/fizzbuzz
Initialized empty Git repository in ~/fizzbuzz/.git/

Change directory to this repository:

$ cd ~/fizzbuzz

Then, start vim (or nvim).

§
Fugitive

To install Fugitive, follow the instructions for your Vim plugin manager on VimAwesome. To check that it is properly installed, try to open the documentation with :help fugitive.

The plugin defines the Ex command :Git [args] (abbreviated as :G [args]) that works almost like git in a shell. The only difference is that commands such as :Git diff or :Git log are augmented: their output is redirected to a Fugitive buffer inside Vim that serves as a pager.

In these buffers, Fugitive identifies objects: file names, commit hashes, or diffs. It provides keybindings to act upon them and perform Git operations: staging, diffing, committing, etc. It also provides syntax highlighting for selected output formats.

On the Ex command line, Fugitive extends the revision specifiers defined by Git in git-rev-parse(1). You can use them with the command :Git, but also with extra commands such as :Gedit [object] to edit the specified object in a new buffer.

§
Record changes

The summary buffer constitutes Fugitive's main interface, from which you can stage, diff, and commit files to record changes to a Git repository.

§
Summary view

Run the command :G without arguments to access the main summary buffer (interactive equivalent of git status):

  1 Head: master
  2 Push: origin/master
  3 Help: g?
~
~
~
~                                            
~
~                                                             
~                                                  
~
~
~
~                                                              
~                                                              
~
~                                                     
~                                                              
~
~
~
~
~/fizzbuzz/.git/index [-][RO]                                           1,1  All
:G | only

Note that Fugitive doesn't perform any window management, so you will often end up with a new split, or it will replace the focused buffer. Such commands in Fugitive have variations to split vertically, horizontally, or open a new tab. For the status, you can use Vim's built-in window management commands, like :only to hide windows other than the focused one (same as <C-w><C-o>). You can chain it after a Git command as :G | only.

Inside the buffers it manages, Fugitive defines a number of keybindings for common Git operations. Press g? to quickly open the documentation at the key mappings section.

§
Track files

Edit a new file with :e main.py and append the following content:

main.py
for i in range(1, 21):
	print(i)

Save the file with :w. The summary buffer shows its name under the "Untracked" section, which means it doesn't belong to the repository yet:

  1 Head: master
  2 Push: origin/master
  3 Help: g?
  4 
  5 Untracked (1)
  6 ? main.py
~                                            
~
~                                                             
~                                                  
~
~
~
~                                                              
~                                                              
~
~                                                     
~                                                              
~
~
~
~
~/fizzbuzz/.git/index [-][RO]                                           1,1  All
"main.py" [New] 2L, 33C written

Vim may create an additional main.py.swp file for recovery purposes. You can create a gitignore file to hide these swap files from Git's untracked files, or configure Vim to save them elsewhere (see: :h directory).

Files listed in the summary buffer are an example of Fugitive objects that you can act upon. Git offers the ability to add untracked files without staging them. To track the file with Fugitive, position the cursor on main.py and press I (this is equivalent to git add --intent-to-add main.py):

  1 Head: master
  2 Push: origin/master
  3 Help: g?
  4 
  5 Unstaged (1)
  6 A main.py
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~/fizzbuzz/.git/index [-][RO]                                           1,1  All

In the unstaged state, the file belongs to the worktree. The attribute A indicates a new file. Consult git-status(1) for the full list of attributes.

§
Stage files

To stage main.py, press s over its file name (equivalent to git add main.py):

  1 Head: master
  2 Push: origin/master
  3 Help: g?
  4 
  5 Staged (1)
  6 A main.py
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~/fizzbuzz/.git/index [-][RO]                                           1,1  All

If you stage a file by mistake, you can unstaged it with u (same as git rm --staged main.py).

§
Commit

After staging changes to the index, you can commit them with cc (same as git commit):

  1 Initial commit
  2 # Please enter the commit message for your changes. Lines starting
  3 # with '#' will be ignored, and an empty message aborts the commit.
  4 #
  5 # On branch master
  6 #
  7 # Initial commit
  8 #
  9 # Changes to be committed:
 10 #       new file:   main.py
 11 #
~/fizzbuzz/.git/COMMIT_EDITMSG [+]                                      1,1  All
  1 Head: master
  2 Push: origin/master
  3 Help: g?
  4 
  5 Staged (1)
  6 A main.py
~
~
~
~
~/fizzbuzz/.git/index [-][RO]                                           1,1  All

While editing a commit message, you can review the diff from the summary view in the bottom window (or use cvc, equivalent to git commit --verbose, to include the full diff under the commit message for reference). Write and close the message buffer with :wq to commit:

  1 Head: master
  2 Push: origin/master
  3 Help: g?
  4 
  5 Staged (1)
  6 A main.py
~
~
~
~
~
~
~
~
~
~
~
~
~

[master (root-commit) 54a357c] Initial commit
 1 file changed, 2 insertions(+)
 create mode 100644 main.py
Press ENTER or type command to continue

There are other useful keybindings:

  • ca to amend the last commit.
  • cw to reword the last commit.
  • cf to create a fixup commit.
  • crc to revert the commit under the cursor.

Press c? to view the full list.

§
Advanced staging

Up until this point, all the operations performed by Fugitive were available from the Git CLI. Now you will see how to make arbitrary edits of the index to partially stage complex changes.

§
Create a new branch

First, create a feature branch named fizzbuzz with :G switch -c fizzbuzz:

  1 Head: fizzbuzz
  2 Push: origin/fizzbuzz
  3 Help: g?
~
~
~
~                                            
~
~                                                             
~                                                  
~
~                                                              
~                                                              
~                                                              
~                                                              
~
~                                                   
~                                                              
~
~
~
~
~/fizzbuzz/.git/index [-][RO]                                           1,1  All
Switched to a new branch 'fizzbuzz'

Then, update main.py as follows and save these changes:

main.py
for i in range(1, 21):
	if i % 3 == 0:
		print('Fizz')
	elif i % 5 == 0:
		print('Buzz')
	else:
		print(i)

The idea for the next two sections is to split this change into a commit that adds the code to print "Fizz", and another one for "Buzz".

§
Inline diffs

Place the cursor on main.py and press > to display the inline diff:

  1 Head: fizzbuzz
  2 Push: origin/fizzbuzz
  3 Help: g?
  4 
  5 Unstaged (1)
  6 M main.py
  7 @@ -1,2 +1,7 @@
  8  for i in range(1, 21):
  9 -    print(i)
 10 +    if i % 3 == 0:
 11 +        print('Fizz')
 12 +    elif i % 5 == 0:
 13 +        print('Buzz')
 14 +    else:
 15 +        print(i)
~
~
~
~
~
~
~
~/fizzbuzz/.git/index [-][RO]                                           1,1  All

You can close inline diffs with <, and toggle them with =. Fugitive defines additional keybindings that make reviewing easier. For instance, ]c and [c, that also work in regular diff sessions, jump to the next or previous hunk, automatically expanding and closing the inline diffs. These mappings also work on the section titles, but they apply to all the files under them. See :help fugitive-navigation-maps.

For unstaged files, the inline diffs always show the changes that haven't been recorded to the repository yet, between:

  • The worktree and the HEAD if the file isn't in the index (same as git diff HEAD -- main.py).
  • The worktree and the index otherwise (same as git diff -- main.py).

Besides files, Fugitive can directly stage or unstage any hunk or line of an inline diff with s or u. These action apply to the hunk under the cursor (same as selecting a hunk in git add --patch main.py or git reset --patch main.py), but you can also target individual lines.

Try to prepare a commit only for the code that prints "Fizz" by selecting the lines 9 to 11 with V:

  1 Head: fizzbuzz
  2 Push: origin/fizzbuzz
  3 Help: g?
  4 
  5 Unstaged (1)
  6 M main.py
  7 @@ -1,2 +1,7 @@
  8  for i in range(1, 21):
  9 -    print(i)
 10 +    if i % 3 == 0:
 11 +        print('Fizz')
 12 +    elif i % 5 == 0:
 13 +        print('Buzz')
 14 +    else:
 15 +        print(i)
~
~
~
~
~
~
~
~/fizzbuzz/.git/index [-][RO]                                           1,1  All

Then, press s to stage them:

  1 Head: fizzbuzz
  2 Push: origin/fizzbuzz
  3 Help: g?
  4 
  5 Unstaged (1)
  6 M main.py
  7 @@ -1,3 +1,7 @@
  8  for i in range(1, 21):
  9      if i % 3 == 0:
 10          print('Fizz')
 11 +    elif i % 5 == 0:
 12 +        print('Buzz')
 13 +    else:
 14 +        print(i)
 15 
 16 Staged (1)
 17 M main.py
 18 @@ -1,2 +1,3 @@
 19  for i in range(1, 21):
 20 -    print(i)
 21 +    if i % 3 == 0:
 22 +        print('Fizz')
~/fizzbuzz/.git/index [-][RO]                                           1,1  All

Repeat the operation with lines 13 and 14 to get the following staged changes:

  3 Help: g?
  4 
  5 Unstaged (1)
  6 M main.py
  7 @@ -1,5 +1,7 @@
  8  for i in range(1, 21):
  9      if i % 3 == 0:
 10          print('Fizz')
 11 +    elif i % 5 == 0:
 12 +        print('Buzz')
 13      else:
 14          print(i)
 15 
 16 Staged (1)
 17 M main.py
 18 @@ -1,2 +1,5 @@
 19  for i in range(1, 21):
 20 -    print(i)
 21 +    if i % 3 == 0:
 22 +        print('Fizz')
 23 +    else:
 24 +        print(i)
~/fizzbuzz/.git/index [-][RO]                                           1,1  All

Note that inline diffs for staged files show the changes between the index and the HEAD (like git diff --staged main.py). These changes are the ones that get recorded to the repository after a commit.

§
Split changes

To show how to edit the staged version, let's try to add "Buzz" before "Fizz" instead. To that end:

  • Unstage main.py using u.
  • Select the deletion of print(i) on line 9 with V, and stage this change with s.
  • Select lines 11-14 with V, and stage these changes with s.

You should get the following result:

  2 Push: origin/fizzbuzz
  3 Help: g?
  4 
  5 Unstaged (1)
  6 M main.py
  7 @@ -1,4 +1,6 @@
  8  for i in range(1, 21):
  9 +    if i % 3 == 0:
 10 +        print('Fizz')
 11      elif i % 5 == 0:
 12          print('Buzz')
 13      else:
 14 
 15 Staged (1)
 16 M main.py
 17 @@ -1,2 +1,5 @@
 18  for i in range(1, 21):
 19 -    print(i)
 20 +    elif i % 5 == 0:
 21 +        print('Buzz')
 22 +    else:
 23 +        print(i)
~/fizzbuzz/.git/index [-][RO]                                           1,1  All

Notice that the conditional inside the loop in the staged version starts with elif because the code in our worktree contains the branch to print "Fizz". As changes accumulate in the worktree, it will become more and more difficult to commit them independently (you can think of splitting these changes as the inverse of merging them).

Fortunately, one of Fugitive's killer features is being able to edit the content of the index directly. When dealing with lines where an inline diff isn't enough, you can open a full vertical diff between the worktree and the index by pressing dv over a file under the "Unstaged" section:

  2 Push: origin/fizzbuzz
  3 Help: g?
  4 
  5 Unstaged (1)
  6 M main.py
  7 @@ -1,4 +1,6 @@
  8  for i in range(1, 21):
  9 +    if i % 3 == 0:
 10 +        print('Fizz')
 11      elif i % 5 == 0:
~/fizzbuzz/.git/index [-][RO]                                           6,9  16%
  1 for i in range(1, 21):                1 for i in range(1, 21):
    ------------------------------------  2     if i % 3 == 0:
    ------------------------------------  3         print('Fizz')
  2     elif i % 5 == 0:                  4     elif i % 5 == 0:
  3         print('Buzz')                 5         print('Buzz')
  4     else:                             6     else:
  5         print(i)                      7         print(i)
~                                       ~
~                                       ~
~                                       ~
~                                       ~
~/fizzbuzz/.git//0/main.py      1,1  All main.py                        1,1  All

The diff compares the following content:

  • On the left, the version in the index.
  • On the right, the version in the worktree.

Note that if you used dv on a file under the "Staged" section, you would get a diff between the index and the latest commit, same as the associated inline diff.

To fix the staged version, replace the first elif by if in the left window:

  2 Push: origin/fizzbuzz
  3 Help: g?
  4 
  5 Unstaged (1)
  6 M main.py
  7 @@ -1,4 +1,6 @@
  8  for i in range(1, 21):
  9 +    if i % 3 == 0:
 10 +        print('Fizz')
 11      elif i % 5 == 0:
~/fizzbuzz/.git/index [-][RO]                                           6,9  16%
  1 for i in range(1, 21):                1 for i in range(1, 21):
  2     if i % 5 == 0:                    2     if i % 3 == 0:
    ------------------------------------  3         print('Fizz')
    ------------------------------------  4     elif i % 5 == 0:
  3         print('Buzz')                 5         print('Buzz')
  4     else:                             6     else:
  5         print(i)                                print(i)
~                                       ~
~                                       ~
~                                       ~
~                                       ~
~/fizzbuzz/.git//0/main.py      2,5  All main.py                      2,5-1  All

Then, save your changes with :w and return to the summary to see the updated diffs:

  4 
  5 Unstaged (1)
  6 M main.py
  7 @@ -1,5 +1,7 @@
  8  for i in range(1, 21):
  9 -    if i % 5 == 0:
 10 +    if i % 3 == 0:
 11 +        print('Fizz')
 12 +    elif i % 5 == 0:
 13          print('Buzz')
 14      else:
 15          print(i)
 16 
 17 Staged (1)
 18 M main.py
 19 @@ -1,2 +1,5 @@
 20  for i in range(1, 21):
 21 -    print(i)
 22 +    if i % 5 == 0:
 23 +        print('Buzz')
 24 +    else:
 25 +        print(i)
~/fizzbuzz/.git/index [-][RO]                                           1,1  All

You can now commit the staged changes with cc:

  1 Add Buzz
  2 # Please enter the commit message for your changes. Lines starting
  3 # with '#' will be ignored, and an empty message aborts the commit.
  4 #
  5 # On branch fizzbuzz
  6 # Changes to be committed:
  7 #       modified:   main.py
  8 #
  9 # Changes not staged for commit:
 10 #       modified:   main.py
 11 #
~/fizzbuzz/.git/COMMIT_EDITMSG [+]                                      1,8  All
  7 
  8 Staged (1)
  9 M main.py
 10 @@ -1,2 +1,5 @@
 11  for i in range(1, 21):
 12 -    print(i)
 13 +    if i % 5 == 0:
 14 +        print('Buzz')
 15 +    else:
 16 +        print(i)
~/fizzbuzz/.git/index [-][RO]                                          16,1  Bot

The previous commit recorded the addition of the conditional branch to print "Buzz", so now you can record the changes to print "Fizz" in a new commit.

First, stage all the remaining changes:

  1 Head: fizzbuzz
  2 Push: origin/fizzbuzz
  3 Help: g?
  4 
  5 Staged (1)
  6 M main.py
  7 @@ -1,5 +1,7 @@
  8  for i in range(1, 21):
  9 -    if i % 5 == 0:
 10 +    if i % 3 == 0:
 11 +        print('Fizz')
 12 +    elif i % 5 == 0:
 13          print('Buzz')
 14      else:
 15          print(i)
~
~
~
~
~
~
~
~/fizzbuzz/.git/index [-][RO]                                           1,1  All

Then, commit them:

  1 Add Fizz
  2 # Please enter the commit message for your changes. Lines starting
  3 # with '#' will be ignored, and an empty message aborts the commit.
  4 #
  5 # On branch fizzbuzz
  6 # Changes to be committed:
  7 #       modified:   main.py
  8 #
~
~
~
~/fizzbuzz/.git/COMMIT_EDITMSG [+]                                     1,11  All
  5 Staged (1)
  6 M main.py
  7 @@ -1,5 +1,7 @@
  8  for i in range(1, 21):
  9 -    if i % 5 == 0:
 10 +    if i % 3 == 0:
 11 +        print('Fizz')
 12 +    elif i % 5 == 0:
 13          print('Buzz')
 14      else:
~/fizzbuzz/.git/index [-][RO]                                           9,1  80%

§
Review the commit history

One of the fundamental Git operations is inspection of the commit history.

§
Push to a remote

To demonstrate how Fugitive handles unpushed commits, create a bare repository that will serve as a "remote":

$ git init --bare ~/fizzbuzz.git
Initialized empty Git repository in ~/fizzbuzz.git/

Then, define it as the origin with :G remote add origin ~/fizzbuzz.git.

Finally, switch back to the master branch with :G switch master, and push your changes to this remote with :G push -u origin master.

§
Unpushed commits

In main.py, add the if branch that prints "FizzBuzz":

main.py
for i in range(1, 21):
	if i % 15 == 0:
		print('FizzBuzz')
	else:
		print(i)

Stage and commit these changes:

  1 Add FizzBuzz
  2 # Please enter the commit message for your changes. Lines starting
  3 # with '#' will be ignored, and an empty message aborts the commit.
  4 #
  5 # On branch master
  6 # Your branch is up to date with 'origin/master'.
  7 #
  8 # Changes to be committed:
  9 #       modified:   main.py
 10 #
~
~/fizzbuzz/.git/COMMIT_EDITMSG                                         1,12  All
  4 
  5 Staged (1)
  6 M main.py
  7 @@ -1,2 +1,5 @@
  8  for i in range(1, 21):
  9 -    print(i)
 10 +    if i % 15 == 0:
 11 +        print('FizzBuzz')
 12 +    else:
 13 +        print(i)
~/fizzbuzz/.git/index [-][RO]                                          13,1  Bot

Now that the remote tracking branch is set, the summary shows a list of unpushed commits:

  1 Head: master
  2 Merge: origin/master
  3 Help: g?
  4 
  5 Unpushed to origin/master (1)
  6 0f7af9e Add FizzBuzz
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~                                        
~                                        
~/fizzbuzz/.git/index [-][RO]                                           1,1  All

These commits are another example of Fugitive objects that you can interact with. Try to open a commit in a new tab with O:

 ~/f/.g/index  7af9eb53f37ea78ae2aa0fb568b0dd75336463                          X
  1 tree 764acf9c90e2871854423a47aea662b51dd6ebd0
  2 parent 54a357ca6a89e8289f9aae4bbe54aba0897a58d2
  3 author gdzx <gdzx@localhost> Tue Oct 5 17:53:14 2021 +0200
  4 committer gdzx <gdzx@localhost> Sun Oct 10 21:53:08 2021 +0200
  5 
  6 Add FizzBuzz
  7 
  8 
  9 diff --git a/main.py b/main.py
 10 index 191278d..a8c1a91 100644
 11 --- a/main.py
 12 +++ b/main.py
 13 @@ -1,2 +1,5 @@
 14  for i in range(1, 21):
 15 -    print(i)
 16 +    if i % 15 == 0:
 17 +        print('FizzBuzz')
 18 +    else:
 19 +        print(i)
~
~
~/fizzbuzz/.git//0f7af9eb53f37ea78ae2aa0fb568b0dd75336463 [-][RO]       1,1  All

This view shows the output of the command git show -p --format=raw 0f71f9e, but it is interactive: you can press O on the tree, on the parent, on the changed files (to see the previous version on --- or the current on +++), and on the hunks.

The summary only shows local commits that haven't been pushed to a remote. This is convenient because you can safely modify them, without the risk of upsetting your coworkers with a forced push.

§
Commit log

If you want to see someone else's work, or view the history for another branch, you need to open the full commit log. For that, Fugitive provides two commands.

The first one is :G log, equivalent to git log, except that Fugitive captures the output into a buffer:

  1 commit 0f7af9eb53f37ea78ae2aa0fb568b0dd75336463
  2 Author: gdzx <gdzx@localhost>
  3 Date:   Tue Oct 5 17:53:14 2021 +0200
  4 
  5     Add FizzBuzz
  6 
  7 commit 54a357ca6a89e8289f9aae4bbe54aba0897a58d2
  8 Author: gdzx <gdzx@localhost>
  9 Date:   Mon Oct 4 00:23:27 2021 +0200
 10 
 11     Initial commit
~
~
~
~
~
~
~
~
~
~
~
/tmp/nvimuA9CbG/9 [-]                                                   1,1  All
:G log

You can jump between commits with [[ and ]], open the commit under the cursor with o, and use any Vim commands (like / to search for a word). Additionally, this command accepts the same arguments as git log, for instance, you can use :G log --oneline --decorate --graph --all for a fancy output:

  1 * 0f7af9e (HEAD -> master) Add FizzBuzz
  2 | * dc7d547 (fizzbuzz) Add Fizz
  3 | * f9b0fb8 Add Buzz
  4 |/  
  5 * 54a357c (origin/master) Initial commit
~
~                                            
~
~                                                             
~                                                  
~
~
~
~                                                              
~                                                              
~
~                                                   
~                                                              
~
~
~                                         
~                                         
/tmp/nvimuA9CbG/10 [-]                                                  1,1  All
:G log --oneline --decorate --graph --all

Unfortunately, this command outlines a limitation of Fugitive: it doesn't support syntax highlighting for arbitrary Git commands, although you can act on the recognized objects identifiers.

As an alternative, Fugitive has :Gclog[!] to load the history into the quickfix list (when ! is given, it doesn't jump to the first entry upon invocation):

  1 tree 764acf9c90e2871854423a47aea662b51dd6ebd0
  2 parent 54a357ca6a89e8289f9aae4bbe54aba0897a58d2
  3 author gdzx <gdzx@locahost> Tue Oct 5 17:53:14 2021 +0200
  4 committer gdzx <gdzx@localhost> Tue Oct 5 17:53:14 2021 +0200
  5 
  6 Add FizzBuzz
  7 
  8 
  9 diff --git a/main.py b/main.py
 10 index 191278d..a8c1a91 100644
 11 --- a/main.py
~/fizzbuzz/.git//0f7af9eb53f37ea78ae2aa0fb568b0dd75336463 [-][RO]       1,1  Top
  1 0f7af9e|| Add FizzBuzz                                                      
  2 54a357c|| Initial commit
~
~
~
~
~
~
~
~
[Quickfix List] :Gclog                                       1,1             All

If you have practical Vim keybindings, you should be able to navigate the quickfix list with ]q and [q. The analogous command :Gllog uses the location list instead.

§
Going further

If getting a colored output is more important than being able to interact with the buffer, you can use the built-in terminal to run a regular Git command, for instance, :term git log --oneline --graph --all:

  1 * 0f7af9e (HEAD -> master) Add FizzBuzz
  2 | * dc7d547 (fizzbuzz) Add Fizz
  3 | * f9b0fb8 Add Buzz
  4 |/
  5 * 54a357c (origin/master) Initial commit
  6 
  7 [Process exited 0]
  8 
  9 
 10 
 11 
 12 
 13 
 14 
 15 
 16 
 17 
 18 
 19 
 20 
 21 
 22 
term://~/fizzbuzz//68189:git log --oneline --graph --all [-]            1,1  All
:term git log --oneline --graph --all

See Fugitive-compatible plugins such as gv.vim for a better integration of the Git log into Vim.

§
Merge conflicts

This section shows how to merge the branch fizzbuzz with master and resolve the conflicts using Fugitive.

§
Unmerged files

On branch master, run :G merge fizzbuzz. The summary shows a conflict:

  1 Head: master
  2 Merge: origin/master
  3 Help: g?
  4 
  5 Unstaged (1)
  6 U main.py
  7 @@@ -1,5 -1,7 +1,12 @@@
  8   for i in range(1, 21):
  9 ++<<<<<<< HEAD
 10  +    if i % 15 == 0:
 11  +        print('FizzBuzz')
 12 ++=======
 13 +     if i % 3 == 0:
 14 +         print('Fizz')
 15 +     elif i % 5 == 0:
 16 +         print('Buzz')
 17 ++>>>>>>> fizzbuzz
 18       else:
 19           print(i)
 20 
 21 Staged (1)
 22 U main.py
~/fizzbuzz/.git/index [-][RO]                                           1,1  All

main.py has the status U under both the "Unstaged" and "Staged" sections which means "Unmerged, both modified". "Both" alludes to the two ancestor branches, master and fizzbuzz, that both modified the file in a conflicting way. Indeed, the inline diff shows that it contains conflict markers with two sections, corresponding to the versions on these two branches.

Note that under "Staged", main.py also appears without any inline diff, that would compare the HEAD to the index. But the file isn't actually staged, this is only a reminder that you cannot commit other staged changes without resolving this conflict first.

§
2-way diff

Pressing dv on an unstaged file opens a 2-way diff to solve the conflict:

  2 Merge: origin/master
  3 Help: g?
  4 
  5 Unstaged (1)
  6 U main.py
  7 @@@ -1,5 -1,7 +1,12 @@@
  8   for i in range(1, 21):
  9 ++<<<<<<< HEAD
 10  +    if i % 15 == 0:
 11  +        print('FizzBuzz')
~/fizzbuzz/.git/index [-][RO]                                           6,1   6%
  1 for i in range(1, 21):  1 for i in range(1, 21):  1 for i in range(1, 21):
  2     if i % 15 == 0:     2 <<<<<<< HEAD            2     if i % 3 == 0:
  3         print('FizzBuz  3     if i % 15 == 0:     3         print('Fizz')
    ----------------------  4         print('FizzBuz  4     elif i % 5 == 0:
    ----------------------  5 =======                 5         print('Buzz')
    ----------------------  6     if i % 3 == 0:        ----------------------
    ----------------------  7         print('Fizz')     ----------------------
    ----------------------  8     elif i % 5 == 0:      ----------------------
    ----------------------  9         print('Buzz')     ----------------------
    ---------------------- 10 >>>>>>> fizzbuzz          ----------------------
  4     else:              11     else:               6     else:
<.git//2/main.py  1,1  Top <izzbuzz/main.py  1,1  Top <.git//3/main.py  1,1  Top

This time, there are 2 ancestors for a total of 3 versions:

  • Left: "ours", which corresponds to the HEAD (tip of the branch master).
  • Center: the current working copy.
  • Right: "theirs", the tip of the branch fizzbuzz you are trying to merge with master.

Conveniently, the conflict resolution markers point to the correct side. Here, you have to merge the two sides by hand:

  2 Merge: origin/master
  3 Help: g?
  4 
  5 Unstaged (1)
  6 U main.py
  7 
  8 Staged (1)
  9 U main.py
 10 
 11 Unpushed to origin/master (1)
~/fizzbuzz/.git/index [-][RO]                                           6,1  50%
  1 for i in range(1, 21):  1 for i in range(1, 21):  1 for i in range(1, 21):
  2     if i % 15 == 0:     2     if i % 15 == 0:     2     if i % 3 == 0:
  3         print('FizzBuz  3         print('FizzBuz  3         print('Fizz')
    ----------------------  4     elif i % 3 == 0:    4     elif i % 5 == 0:
    ----------------------  5         print('Fizz')   5         print('Buzz')
    ----------------------  6     elif i % 5 == 0:      ----------------------
    ----------------------  7         print('Buzz')     ----------------------
  4     else:               8     else:               6     else:
  5         print(i)        9         print(i)        7         print(i)
~                         ~                         ~
~                         ~                         ~
<.git//2/main.py  1,1  All main.py           1,1  All <.git//3/main.py  1,1  All

If you use :w to save the changes, the conflict is not marked as resolved. You need to either stage the file with s from the summary, or use :Gwrite to save and stage the file with a single command.

Often, one of the two ancestors contains the exact changes you want to merge. Move to its side with <C-w>h or <C-w>l and run :Gwrite! to overwrite and stage the working copy (like git checkout --ours -- main.py or with git checkout --ours -- main.py).

Finally, note the components //2 and //3 in the file paths. With d2o and d3o, you can obtain a hunk from either sides (like :diffget //2 or :diffget //3). You can do the same from the summary view with the commands 2X and 3X, applied to a hunk, or even to an entire file.

§
3-way diff

More complex merges often call for a 3-way merge to also have the common ancestor of the two branches in sight. With git checkout --conflict=diff3 -- main.py, you can update an unmerged file with the content of the ancestor, also called "base", added between the conflict markers. The interactive equivalent with Fugitive is to open main.py and run :Gdiffsplit :1 | Gvdiffsplit!:

 ~/f/.g/index  4 main.py                                                       X
  1 for i in range(1, 21):  1 for i in range(1, 21):  1 for i in range(1, 21):
  2     if i % 15 == 0:     2     print(i)            2     if i % 3 == 0:
  3         print('FizzBuz    ----------------------  3         print('Fizz')
  4     else:                 ----------------------  4     elif i % 5 == 0:
  5         print(i)          ----------------------  5         print('Buzz')
<.git//2/main.py  5,1  All <.git//1/main.py  2,1  All <.git//3/main.py  7,1  Top
  2 <<<<<<< ours
  3     if i % 15 == 0:
  4         print('FizzBuzz')
  5     else:
  6         print(i)
  7 ||||||| base
  8     print(i)
  9 =======
 10     if i % 3 == 0:
 11         print('Fizz')
 12     elif i % 5 == 0:
 13         print('Buzz')
 14     else:
 15         print(i)
 16 >>>>>>> theirs
main.py                                                                 7,1  Bot

This command chains two subcommands:

  • :Gdiffsplit :1 starts a diff between the current file (main.py) and the object :1 . The documentation states that :1:% corresponds to the current file's common ancestor during a conflict (with % indicating the current file, which the default when omitted).
  • :Gvdiffsplit! starts a vertical diff between the current file and all its direct ancestors (Fugitive understands that the diff concerns main.py, even though after the previous command, the active buffer is the base version).

The windows are organized as follows:

  • Top-left: "ours" corresponding to the HEAD.
  • Top-center: "base" corresponding to the common ancestor of master and fizzbuzz.
  • Top-right: "theirs" corresponding to the tip of fizzbuzz.
  • Bottom: the working copy.

This merging strategy is overkill in many situations, but sometimes you have to reference all these versions while you edit the merged copy.

You can now merge the changes as in the previous section and commit them.

§
Rebase

Instead of merging the previous changes, another strategy is to rebase the branch fizzbuzz onto master, and then perform a fast-forward merge to keep the history linear.

§
Stash

First, reset the HEAD of master to HEAD^ with :G reset HEAD^. This command rewinds master to the commit before the previous merge, keeping the working directory as-is with the latest version of main.py (you could use the option --hard, but this is the occasion to handle a dirty working directory).

The stash is useful when you want to perform a rebase with uncommitted changes, because this operation requires a clean worktree. Fugitive provides a number of keybindings to push to, and pop from, the stash. The first one is czz to stash modified worktree files and excluding untracked or ignored files (equivalent to git stash).

To get the changes back, you can use czp to pop the changes without trying to restore the index (equivalent to git stash pop). There are variants that also restore the index (czP) or apply the changes without actually deleting the stash entry (cza).

§
Rebase conflict

Ensure the working directory is clean by using czz or :G reset --hard (discarding any worktree changes). Then, start the interactive rebase of fizzbuzz onto master with :G rebase master fizzbuzz. As with the previous merge, there is a conflict:

  2 Help: g?
  3 
  4 Rebasing fizzbuzz (2)
  5 pick dc7d5477ed5 Add Fizz
  6 stop f9b0fb8f4cc Add Buzz
  7 
  8 Unstaged (1)
  9 U main.py
 10 @@@ -1,5 -1,5 +1,10 @@@
 11   for i in range(1, 21):
 12 ++<<<<<<< HEAD
 13  +    if i % 15:
 14  +        print('FizzBuzz')
 15 ++=======
 16 +     if i % 5 == 0:
 17 +         print('Buzz')
 18 ++>>>>>>> f9b0fb8 (Add Buzz)
 19       else:
 20           print(i)
 21 
 22 Staged (1)
 23 U main.py
~/fizzbuzz/.git/index [-][RO]                                           9,1  Bot

Note that the summary shows the rebase todo, that you can edit with re (same as git rebase --edit-todo, except the commits are listed in reverse order).

Use dv on main.py to open a 2-way diff:

  5 pick dc7d5477ed5 Add Fizz
  6 stop f9b0fb8f4cc Add Buzz
  7 
  8 Unstaged (1)
  9 U main.py
 10 @@@ -1,5 -1,5 +1,10 @@@
 11   for i in range(1, 21):
 12 ++<<<<<<< HEAD
 13  +    if i % 15:
 14  +        print('FizzBuzz')
~/fizzbuzz/.git/index [-][RO]                                           9,1  30%
  1 for i in range(1, 21):  1 for i in range(1, 21):  1 for i in range(1, 21):
  2     if i % 15:          2 <<<<<<< HEAD            2     if i % 5 == 0:
  3         print('FizzBuz  3     if i % 15:          3         print('Buzz')
    ----------------------  4         print('FizzBuz    ----------------------
    ----------------------  5 =======                   ----------------------
    ----------------------  6     if i % 5 == 0:        ----------------------
    ----------------------  7         print('Buzz')     ----------------------
    ----------------------  8 >>>>>>> f9b0fb8 (Add B    ----------------------
  4     else:               9     else:               4     else:
  5         print(i)       10         print(i)        5         print(i)
~                         ~                         ~
<.git//2/main.py  1,1  All <izzbuzz/main.py  1,1  All <.git//3/main.py  1,1  All

"Ours" on the left corresponds to the last commit on the rebased branch that you are building (initially, this is the root commit on master), whereas "theirs" on the right corresponds to the last commit picked from fizzbuzz.

Resolve the conflict as you did previously and save with :Gwrite:

  1 Head: a3245b9b7da
  2 Help: g?
  3 
  4 Rebasing fizzbuzz (2)
  5 pick dc7d5477ed5 Add Fizz
  6 stop f9b0fb8f4cc Add Buzz
  7 
  8 Staged (1)
  9 M main.py
 10 @@ -1,5 +1,7 @@
 11  for i in range(1, 21):
 12      if i % 15:
 13          print('FizzBuzz')
 14 +    elif i % 5 == 0:
 15 +        print('Buzz')
 16      else:
 17          print(i)
~
~                                                      
~                                                      
~                                                      
~                                                      
~/fizzbuzz/.git/index [-][RO]                                           9,1  All

With your changes staged, you can continue the rebase with rr (same as git rebase --continue), until the next conflict:

  3 
  4 Rebasing fizzbuzz (2)
  5 stop dc7d5477ed5 Add Fizz
  6 done f9b0fb8f4cc Add Buzz
  7 
  8 Unstaged (1)
  9 U main.py
 10 @@@ -1,6 -1,6 +1,11 @@@
 11   for i in range(1, 21):
 12 ++<<<<<<< HEAD
 13  +    if i % 15:
 14  +        print('FizzBuzz')
 15 ++=======
 16 +     if i % 3 == 0:
 17 +         print('Fizz')
 18 ++>>>>>>> dc7d547 (Add Fizz)
 19       elif i % 5 == 0:
 20           print('Buzz')
 21       else:
 22 
 23 Staged (1)
 24 U main.py
~/fizzbuzz/.git/index [-][RO]                                           9,1  Bot

Since this conflict is simple enough, there is no need for a diff. Just open the file in a new tab by pressing O:

 ~/f/.g/index  ~/f/main.py                                                     X
  1 for i in range(1, 21):
  2 <<<<<<< HEAD
  3     if i % 15:
  4         print('FizzBuzz')
  5 =======
  6     if i % 3 == 0:
  7         print('Fizz')
  8 >>>>>>> dc7d547 (Add Fizz)
  9     elif i % 5 == 0:
 10         print('Buzz')
 11     else:
 12         print(i)
~
~
~
~
~
~
~
~
~
~/fizzbuzz/main.py                                                      1,1  All

Make the appropriate modifications and save it with :Gwq (shortcut for :Gwrite | :quit):

  1 Head: ed1fba7b198
  2 Help: g?
  3 
  4 Rebasing fizzbuzz (2)
  5 stop dc7d5477ed5 Add Fizz
  6 done f9b0fb8f4cc Add Buzz
  7 
  8 Staged (1)
  9 M main.py
 10 @@ -1,6 +1,8 @@
 11  for i in range(1, 21):
 12      if i % 15:
 13          print('FizzBuzz')
 14 +    elif i % 3 == 0:
 15 +        print('Fizz')
 16      elif i % 5 == 0:
 17          print('Buzz')
 18      else:
~
~
~
~
~/fizzbuzz/.git/index [-][RO]                                           9,1  All

Finally, complete the rebase with rr and unstash the changes you made previously with czp, if any.

§
Keybindings

Fugitive defines helpful keybindings for starting a rebase. Here are some examples:

  • Rebase from the ancestor of the commit under the cursor:
    • ri: interactive rebase.
    • rf: autosquash rebase (automatically meld fixup and squash commits created with cf and cs).
  • ru: rebase against the upstream branch.
  • Modify the commit under the cursor:
    • rw: reword the message.
    • rm: modify the commit.
  • During a rebase:
    • rr: continue.
    • ra: abort.
    • rs: skip.
    • re: edit the todo.

§
Advanced merge

Real-world merges typically contain many conflicting files. Fugitive provides the command :G mergetool, similar to git mergetool, to iterate on these conflicts.

§
Preparation

Create a new Git repository and commit a few files a.py, b.py, and c.py, based on the following template:

a.py
def a():
	print('a')

On a new branch named feat, commit the following changes:

  • In a.py, replace print('a') with print('A').
  • Remove b.py.
  • In c.py, replace print('c') with print('C').

Then, switch back to master, and commit these changes:

  • In a.py, replace print('a') with print('aa').
  • In b.py, replace print('b') with print('bb').
  • In c.py, replace print('c') with print('cc').

§
With the CLI

Run git merge feat to merge feat with master:

$ git merge feat
Auto-merging c.py
CONFLICT (content): Merge conflict in c.py
CONFLICT (modify/delete): b.py deleted in feat and modified in HEAD. Version HEAD of b.py left in tree.
Auto-merging a.py
CONFLICT (content): Merge conflict in a.py
Automatic merge failed; fix conflicts and then commit the result.

The output shows two content conflicts, and one modify/delete conflict that Git cannot resolve automatically. To iterate on these files, you can run git mergetool with the option -t to choose a diff tool:

  • vimdiff1 or nvimdiff1 for a simple local/remote diff.
  • vimdiff2 or nvimdiff2 for a 2-way merge.
  • vimdiff, nvimdiff, vimdiff3, or nvimdiff3 for a 3-way merge.

Content conflicts open the diff tool, whereas modify/delete conflicts only ask whether to keep the modified version or not.

$ git mergetool -t nvimdiff
Merging:
a.py
b.py
c.py

Normal merge conflict for 'a.py':
  {local}: modified file
  {remote}: modified file
[Opens merge resolution tool.]

Deleted merge conflict for 'b.py':
  {local}: modified file
  {remote}: deleted
Use (m)odified or (d)eleted file, or (a)bort?

Note that after normally closing the merge tool, Git marks the conflict for a.py as solved by staging the file. Instead, if you want to discard the changes you've made, use :cquit to exit Vim and return the status code 1 to Git.

To take advantage of Fugitive, including the diff keymappings d2o and d3o, you can configure a custom merge tool. The following commands register the diff tool fugitive3 for 3-way diffs using Fugitive:

$ git config --global mergetool.fugitive3.cmd \
	'nvim -f -c "Gdiffsplit :1 | Gvdiffsplit!" "$MERGED"'
$ git config --global merge.tool fugitive3

The main drawback of git mergetool is that it requires closing the editor, because even if you run it in another terminal, Vim will prevent you from editing files that are already open in the other instance.

§
With Fugitive

Let's try to do the same with Fugitive and stay inside Vim. First, run :G reset --hard to discard any changes in the working directory, and initiate the merge again with :G merge feat:

  1 Head: master
  2 Push: origin/master
  3 Help: g?
  4 
  5 Unstaged (3)
  6 U a.py
  7 D b.py
  8 U c.py
  9 
 10 Staged (3)
 11 U a.py
 12 U b.py
 13 U c.py
~
~
~
~
~
~
~
~
~
~/mergetool/.git/index [-][RO]                                          1,1  All

From the summary, you can already solve the conflict for b.py, modified on master, and deleted on feat:

  • If you want to delete this file for the merge commit, then stage the deletion with s.
  • If you want to keep the version in master, then unstage the deletion with u.

You can manually iterate on the remaining conflicts from the summary view, but Fugitive provides :G mergetool to make this task easier. This command loads the unmerged files into the quickfix list. After merging the conflicting hunks, you can stage the result with :Gwrite, and switch to the next item in the quickfix list with :cnext (or ]q if mapped).

  1 def a():                                                                    
  2 <<<<<<< HEAD
  3     print("aa")
  4 =======
  5     print("A")
  6 >>>>>>> feat
~
~
~
~
~
a.py                                                                    1,1  All
  1 a.py|2| def a()                                                             
  2 c.py|2| def c()
~
~
~
~
~
~
~
~
[Quickfix List] :Git --no-literal-pathspecs mergetool        1,1             All

For more complicated merges, you will likely want the same 2 or 3-way merge view as with :Gvdiffsplit. The main issue with :Git mergetool is that it doesn't manage Vim windows. If you switch to the next quickfix entry, Vim merely replaces the buffer of the focused window, while the others remain unchanged, eventually with the leftover content from a previous diff.

Here's a workflow that circumvents this limitation:

  1. Run :G mergetool.
  2. (If you need a diff view, run :Gvdiffsplit! or any other variant.)
  3. Resolve the conflicts, then write and stage the result with :Gwrite.
  4. (Close other windows, if any, with <C-w><C-o>.)
  5. Go to the next quickfix entry with ]q or :cnext.
  6. If there are remaining conflicts, go back to 2.

§
Conclusion

Fugitive is an invaluable tool to work with Git. It provides easy access to common CVS features, but also partial staging operations that would be quite inefficient with Git's command-line interface. Other features not covered in this article include:

  • :Git blame to get the latest change for each line on the current file in a separate buffer that defines keymaps of its own (:h :Git_blame).
  • :GMove to move a file like git mv, and other commands of the like (:h fugitive-commands for the full list).

Hopefully, you now know enough to explore these features on your own to work with Git inside Vim efficiently.


Thanks to Keith Edmunds for pointing out imprecisions and mismatches between the terminal screenshots and the surrounding instructions.