在 Neovim 里面使用 vim-fugitive 的工作流

最近在使用 Git + Neovim 的时候,发现我的工作流还是有一些不那么顺畅的地方。我习惯性退出 Neovim,然后在命令行写 Git 相关的命令,并且在提交代码变更前我习惯用 delta 查看 diff 信息。为了减少要打的字符,我还开启了 Oh My Zshgit 插件,这样我就可以用一堆缩写了,比如 ga = git addgcmsg = git commit -m

不顺畅主要的原因是我发现我经常在 Neovim 和命令行之间切换,就像这样

flowchart 
  i(编辑代码) --->|退出 Neovim| j(用 git diff 看下 diff 信息) ---> k(发现有点小问题,需要修改)
  k --->|打开 Neovim| i

就在这时我想起了躺在我插件列表的 vim-fugitive,我记得它在 GitHub 主页 说过 it’s “so awesome, it should be illegal”,当时没有深究。于是今天我经过一番钻研之后,下面是我总结的一些使用 vim-fugitive 的工作流

大多数命令行的的 Git 命令,你都可以替换为 :Git ...,比如 git blame 就是 :Git blame,其中 :Git 可以缩写:G

vim-fugitive 查看当前仓库的状态非常简单,直接用

text

:G

输出类似下面这样

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

其中

  • Untracked 表示新文件,还没有用 git 管理
  • Staged 表示添加到 staging area 的文件
  • Unpushed to origin/main 表示本地的 Commit

每个文件前面有一个文件状态码,不同的状态码的含义并不同

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

还是用前面的例子,如果我们想要把 foo.py 添加到 staging area 里面,就将光标停在 foo.py 上,然后按下 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

你应该可以看到变成了下面这样

text

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

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

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

撤销 staging 也很容易,按 u 就行,这个跟 Vim 的操作是一致的,很好记

最后,修改完文件之后,移动光标到文件上,按 cc,就会执行 git commit,可以看到弹出了一个 buffer 让我们填写 commit message,保存退出 commit 就添加成功了。除此之外,还有下面这些常用的命令

  • caca = git commit --amend,将修改合并到 last commit 并且重新填写 commit message,
  • ceca = git commit --amend --no-edit,将修改合并到 last commit 并且保持 commit message 不变
  • cw,用来修改 last commit message

光标停留在 untracked file 上,然后按下 I,就可以看到文件的状态变成了 A。本质是帮我们执行了 git add --intent-to-addgit add --intent-to-add 的好处是本来 untracked file 也可以看 diff 了1,普通的 git add 做不到这点,前者对应 git statusChanges not staged for commit,后者对应 Changes to be committed,可以用 git status 验证一下

另外一个常用的功能是,查看文件的 diff 信息看我们具体修改了什么,比如下面的

text

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

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

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

如果该文件的代码变更并不多,那么直接将光标停在文件上按下 = 就可以看到 diff 信息了

技巧
再次按下 = 就可以将 diff 信息关闭

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

但如果该文件的代码变更很大,那么可能 side-by-side 的 diff 信息展示更合理,此时只需要将光标停在文件上按下 dv 就可以,

技巧
如果用 dv 看 diff 信息,那么除了看之外,你还可以进行修改

直接用下面的命令就可以看到所有的 Commit 的信息

text

:G log

可以看到新打开的 buffer 里展示了如下的信息

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

将光标停留在 commit 上 按下 o 键 就可以在新的 buffer 看到这个 commit 的详细信息

技巧
]][[ 可以快速在不同的 commit 之间移动

这里单独挑 git blame 出来是因为 vim-fugitive 的 git blame 展示非常直观,左边清楚展示了是什么 commit 引入的变更,以及是哪个作者引入的。对应的命令是 :G blame

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")

更厉害的是,这个 :G blame 的输出是可以交互的,比如你想要展示 commit 更详细的信息,只需要将光标停留在 commit 上,然后按下 o

很多时候,你修改了代码完成了多个功能的开发或者是 Bug 修复,但是你中途忘记一边改一边提交 commit 了,那么要如何拆分不同的代码变更分别提交 commit?(毕竟一个 commit 解决一个问题是好的实践)

先来看 foo.py 文件,假设它本来是这样子的

python

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

然后我们做了如下的代码变更,并且假设每个分支代表一个特性的添加或者是功能的修复

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]")

将光标停留在文件上,按 = 就可以看到 diff 信息

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

接下来用 V 选中如下 2 行

text

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

然后按下 s 单独 stage 部分代码变更,此时下方就会出现一个 Staged 的文件

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

cc 提交 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

可以看到我们新添加了一个 commit c46474f add i == 0,这个步骤可以多次执行

技巧
]c[c 可以在不同的 diff 信息之间快速移动

如法炮制,就完成了代码变更的拆分

git rebase -i 是我另外一个常用的功能,这里我们可以尝试将刚才拆分的 3 个 commit 合并

text

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

先用 :G log 展开 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

然后将光标停留在 add i == 0 这一个对应的 commit 上,按下 ri(Hint:ri = rebase -i),然后进行如下修改

text

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

合并之后

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

最后看一下,分支合并的时候要如何使用 vim-fugitive,假设在另外一个叫作 feature 的分支上,foo.py 代码长这样子

text

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

我们在 main 分支下用

text

:G merge feature

此时用 :G 看到的会是这样子

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

其中我们需要关注的是 Unstaged 里面的文件,需要修改这些文件解决合并冲突。为了解决方便冲突,光标在文件上停留然后按下 dv,就可以看到打开了 3 个 buffer

  • 左边的是发起合并的分支的代码,也就是 main 分支
  • 中间的是冲突的代码
  • 右边的是被合并的分支的代码,也就是 feature 分支

只需要在中间的 buffer 修改好代码冲突,用 :Gwrite 就完成了文件的保存以及将其添加到 staging area 里

但有时候,你可能只是想要采用某一个分支的代码,那么在中间修改冲突比较繁琐而且没啥意义,这个时候直接移动到左边的 buffer 或者右边的 buffer(取决于你想要哪一个分支的代码),然后用 :Gwrite! 进行文件保存以及将其添加到 staging area 里

无论是上面哪一种情况,最后都需要用 cc 提交一下 commit,分支就合并好了

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

以上就是我发现的 vim-fugitive 的工作流,并且使用了有一会。对于我而言,我发现确实之前的不顺畅感消失了,我感觉在 Neovim 里面用 Git 更加高效了,因为我不再需要频繁地在 Neovim、Terminal 之间进行上下文切换。而频繁的上下文切换可是会降低生产力的2