My workflows of using vim-fugitive in Neovim

Recently, while using Git with Neovim, I noticed that my workflow isn’t as smooth as I’d like it to be. I tend to exit Neovim, and then type git commands in the terminal. Before committing code changes, I usually check the diff information using delta. To cut down the number of keystrokes, I’ve also enabled the Oh My Zsh’s git plugin, so I can use a bunch of shortcuts like ga = git add and gcmsg = git commit -m

The main reason for the lack of smoothness is I often find myself switching between Neovim and the terminal, like this.

flowchart 
  i(Edit code) --->|Exit Neovim| j(Use git diff to see diff information) ---> k(The code need to re-edit)
  k --->|Open Neovim| i

That’s when I recalled vim-fugitive, which had been sitting in my plugin list. I recall its GitHub page saying it’s “so awesome, it should be illegal”, but I never really looked into it. So after diving into it today, here’s a summary of the workflow I founded.

Most of the git commands you used in the terminal can be replaced with :Git .... For example, the git blame’s corresponding command in vim-fugitive is :Git blame. You can shorten :Git to just :G.

To show the status of files in a git repo is quite simple, you just need to use

text

:G

The output should be like

text

Head: main
Merge: origin/main
Help: g?

Untracked (1)
? foo.py

Staged (1)
A bar.py

Unpushed to origin/main (1)
7c08152 add .gitignore

There is a status code before each file.

  • '' unmodified
  • M modified
  • T file type changed
  • A added
  • D deleted
  • R renamed
  • c copied
  • U updated but unmerged
  • ? untracked
  • ! ignored

Let’s use the previous example again. If we want to add foo.py to the staging area, place the cursor over foo.py and press s

text

Head: main
Merge: origin/main
Help: g?

Untracked (1)
? foo.py

Staged (1)
A bar.py

Unpushed to origin/main (1)
7c08152 add .gitignore

The change will be

text

Head: main
Merge: origin/main
Help: g?

Staged (2)
A bar.py
A foo.py

Unpushed to origin/main (1)
7c08152 add .gitignore

To undo the staging process is quite easy and intuitive, just press u

Finally, after modifying the file, move the cursor over the file and press cc (Hint: cc = git commit). You will see a buffer pop up and you can edit the commit message. After saving the code changes will be committed. In addition, here are some other commonly used commands

  • ca,ca = git commit --amend
  • ce,ca = git commit --amend --no-edit
  • cw, reword the last commit message

Place the cursor over the untracked file, and press I. You will find that the status code of this file becomes A. Essentially, this helps us execute git add --intent-to-add. The benefit of git add --intent-to-add is that now we can see the diff for the untracked files1, which is something regular git add can’t do. The former corresponds to Changes not staged for commit in git status, while the latter corresponds to Changes to be committed. You can verify this by running git status

To see the git diff in the following buffer

text

Head: main
Merge: origin/main
Help: g?

Staged (2)
A bar.py
A foo.py

Unpushed to origin/main (1)
7c08152 add .gitignore

If the code change is small, you may just place the cursor over the target file and press =. And the output will be like

Tip
Press = again to hide the diff information.

text

Head: main
Merge: origin/main
Help: g?

Staged (2)
A bar.py
A foo.py
@@ -0,0 +1,2 @@
+def main():
+    print("Please enter a integer")

Unpushed to origin/main (1)
7c08152 add .gitignore

However, usually, your code change will be huge. Then a side-by-side view may be more appropriate. To do this, you just need to replace = with dv

Tip
Besides viewing the git diff using dv, you can also edit the file content as you with

To see the git log, just type

text

:G log

The output should be like

text

commit 23ffed83d3ac5b3b7de37ecb29b55e5cf35b5f65
Author: MartinLwx <*****************>
Date:   Thu Oct 10 22:07:02 2024 +0800

    add bar.py

commit 7c081527a2dbadafbaf7907c5b547afc9e725ac9
Author: MartinLwx <*****************>
Date:   Thu Oct 10 22:05:54 2024 +0800

    add .gitignore

Place your cursor over the target commit and press o, and the details will be shown in another buffer.

Tip
Use ]] and [[ to quickly move among commits

This may be my favorite feature because it’s just so convenient and intuitive. The output of :G blame is

text

23ffed83 1 (MartinLwx 2024-10-10 22:07:02 +0800 1)   def main():
23ffed83 2 (MartinLwx 2024-10-10 22:07:02 +0800 2)       print("Please enter a integer")

You can see clearly who should be blamed if a line goes wrong for some reason. What’s more, we can interact with this buffer. For example, we can press o to see the details of a specific commit.

Many times, after you’ve made multiple code changes, completing several features or bug fixes, you forget to commit along the way. So how do you split different code changes into separate commits? (After all, it’s a good practice to have one commit to do one thing at a time)

Let’s assume the code of foo.py is

python

def main():
    print("Please enter a integer")

And we add the following code changes. All we want to do is split the if, elif, else three cases into three commits.

python

def main():
    i = input("Please enter a integer")
    if i == 0:
        print("i == 0")
    elif i == 1:
        print("i == 1")
    else:
        print("i not in [0, 1]")

Place the cursor over foo.py and press = to see the diff information.

text

Head: main
Push: origin/main
Help: g?

Unstaged (1)
M foo.py
@@ -1,2 +1,8 @@
 def main():
     i = input("Please enter a integer")
+    if i == 0:
+        print("i == 0")
+    elif i == 1:
+        print("i == 1")
+    else:
+        print("i not in [0, 1]")

Unpushed to * (3)
d2d951f add foo.py
23ffed8 add bar.py
7c08152 add .gitignore

Now use V to select the following 2 lines.

text

+    if i == 0:
+        print("i == 0")

And then, press s to stage the partial code change

text

Head: main
Push: origin/main
Help: g?

Unstaged (1)
M foo.py
@@ -2,3 +2,7 @@ def main():
     i = input("Please enter a integer")
     if i == 0:
         print("i == 0")
+    elif i == 1:
+        print("i == 1")
+    else:
+        print("i not in [0, 1]")

Staged (1)
M foo.py

Unpushed to * (3)
d2d951f add foo.py
23ffed8 add bar.py
7c08152 add .gitignore

Finally, use cc to commit.

text

Head: main
Push: origin/main
Help: g?

Unstaged (1)
M foo.py
@@ -2,3 +2,7 @@ def main():
     i = input("Please enter a integer")
     if i == 0:
         print("i == 0")
+    elif i == 1:
+        print("i == 1")
+    else:
+        print("i not in [0, 1]")

Unpushed to * (4)
c46474f add i == 0
d2d951f add foo.py
23ffed8 add bar.py
7c08152 add .gitignore

The above output indicates that we have successfully added a new commit c46474f

Tip
Use ]c and [c to quickly move among different hunk

We just need to do this multiple times :)

git rebase -i is another feature I commonly use. Here, we can try to combine the three commits we just split.

text

68081e9 add else branch
f9dfc93 add i == 1
c46474f add i == 0

First, use :G log to show the commit history

text

commit 68081e928ad8dbbafc8e20ebefaeb7f3dad968bf
Author: MartinLwx <****************>
Date:   Thu Oct 10 23:10:14 2024 +0800

    add else branch

commit f9dfc93d9846a3cec6220b0f91b8e4a84dcb9367
Author: MartinLwx <****************>
Date:   Thu Oct 10 22:57:04 2024 +0800

    add i == 1

commit c46474f9cf8c34071fa95f797e94ffc75b1ee3e2
Author: MartinLwx <****************>
Date:   Thu Oct 10 22:55:03 2024 +0800

    add i == 0

And then, place the cursor over the corresponding commit of add i == 0, and press ri (Hint:ri = rebase -i). Then modify the code as I do

text

r c46474f add i == 0
s f9dfc93 add i == 1
s 68081e9 add else branch

After rebasing, the commit history should be like

text

Head: main
Push: origin/main
Help: g?

Unpushed to * (4)
c814dce merge 3 commits
d2d951f add foo.py
23ffed8 add bar.py
7c08152 add .gitignore

Finally, let’s learn how to resolve the merge conflicts when we merge two branches using vim-fugitive. Suppose on another branch called feature, the foo.py file looks like this.

text

def main():
    i = input("Please enter a integer")
    if i == 0:
        i = i + 1
    else:
        i = i + 2

We can type the following command in main branch.

text

:G merge feature

Now the output of :G should be like

text

Head: main
Push: origin/main
Help: g?

Unstaged (1)
U foo.py

Staged (1)
U foo.py

Unpushed to * (6)
21055dc add else branch
f9dfc93 add i == 1
c46474f add i == 0
d2d951f add foo.py
23ffed8 add bar.py
7c08152 add .gitignore

What we need to focus on are the files in Unstaged, which need to be modified to resolve merge conflicts. To make resolving conflicts easier, move the cursor over the file and press dv, and you’ll see the three buffers open.

  • The leftmost buffer contains the code on the main branch
  • The middle buffer contains the conflicting code
  • The rightmost buffer contains the code on the feature branch

After resolving the conflicts in the middle buffer, use :Gwrite to save the changes and add the file to the staging area

However, sometimes you may just want to use the code from one specific branch. In this case, modifying the conflict in the middle can be cumbersome and pointless. Instead, you can directly move to the left or right buffer (depending on which branch’s code you want) and use :Gwrite! to save and stage it.

In either case, you’ll ultimately need to use cc to commit, and the branch will be successfully merged.

text

Head: main
Push: origin/main
Help: g?

Unpushed to * (8)
d23d66e Merge branch 'feature'
6ad1330 nonsense update
21055dc add else branch
f9dfc93 add i == 1
c46474f add i == 0
d2d951f add foo.py
23ffed8 add bar.py
7c08152 add .gitignore

This is the workflow I’ve discovered with vim-fugitive, and I’ve been using it for a while. For me, I’ve found that the previous feeling of discomfort has disappeared. I feel that using Git in Neovim has become much more efficient because I no longer need to frequently switch contexts between Neovim and the Terminal. And frequent context switching will reduce productivity2