从「原子化」提交到妙用 rebase,一次搞定清爽的 Git 提交记录 - 先锋笔记

/ 0评 0

作为一名软件开发者,Git 是我离不开的版本管理工具,它可以记录并追溯开发进程,功能强大,用法灵活多样。围绕着它诞生的协作平台 GitHub、Gitee 等也深入人心。

Git 本身就是一门学问,其重要性不亚于软件开发。在长期的开源软件开发维护的过程中,我长期与 Git 打交道,对 Git 有了更多的感悟,也探索了一些非常实用的技巧。这些技巧,充分发挥了 Git 本身的特性,极大改善了我使用 Git 管理项目的体验。

下面,我就来分享让我受益最深的几点技巧,希望能给读者朋友们一些启发。

「原子化」提交,一个提交只做一件事

小时候,老师和长辈或许都告诫我们,在做事情的时候要「一心一意」,不要分散注意力到其他的事情上,这样做事才会高效、有成果。

一旦没有遵循「一心一意」的原则,那么干正事的时候碰壁就再寻常不过。就拿我本人来说,做正事的时候「摸鱼」,狂欢一阵子后再回来,面对还没有完成的正式任务,我是容易一脸懵的:我做了啥?进度怎么样了?我刚刚是怎么攻关难题的来着?——显然,这导致了时间和精力的浪费,更不利于事后复盘和提升。

不单单是做事,在使用 Git 的过程中,做每一个提交时也需要「一心一意」,也就是「原子化」(atomic)——确保每个提交只完成一项任务。

1)引例

可能一些开发者在组织 Git 提交时,习惯让一个提交做多件事情,把多项各自独立且不相关联的功能修改都放在同一个提交里。比如:

这种提交方式看似省事儿,但无疑会给后续的开发工作「埋雷」。

2)为什么会「埋雷」?

根据笔者自己的经验体会,如果一个提交做了不止一件事情,那么有可能会在你日后开发的时候,给你带来一些「麻烦」:

0x00:难以排错

也许在开发过程中,某个提交给项目引入了 Bug。当你好不容易找到了存在问题的提交,但却发现这个提交做了不止一件事,你还要花费额外的精力来定位造成 Bug 的地方。

而如果你有不止一个提交都没有做到「一心一意」,那么你就很难直接浏览提交记录来找到问题所在,你就不得不翻阅一个个提交,浪费成倍的精力。

0x01:不便复盘

很多时候,开发者需要复盘自己的提交记录,看看自己做了哪些工作。然而,假如一个提交做了不止一件事,那么就不便于复盘开发过程。

例如,当你想要回顾某个功能的实现过程,找出实现该功能的提交时,却发现关键的代码都被混在几个大的、综合的提交里,难以找出。好比是考前复习的时候,你总结的关键知识点混在了一堆厚厚的复习资料里,自然会影响复习效率。

或许有些读者会选择详尽编写提交说明,通过检索提交说明来找出自己想要的提交。不过这带来的好处有限——你后续还是离不开使用 git show,在一长串「包罗万象」的 diff 记录中,找到真正想找的修改记录。

2)「原子化」提交

践行「原子化」提交的理念,以上的麻烦将不复存在。

C++ 从 C++11标准开始,引入了「原子操作」(atomic)特性,可以确保一个变量在同一时间只有一个操作者在读写,从而防止多线程抢占造成冲突。我借鉴了「原子操作」的理念,提出了「原子化」提交——也就是确保每个提交只完成一个任务,从而改进提交记录的组织方式,防止潜在的杂乱和冲突。

原子化提交最大的优势,在于提交记录简洁、可追溯、易于查错

3)实践「原子化」提交

下面,我就以几个在实际开发场景中的例子,来演示「原子化」提交的操作。

实例一:修复 Bug 的「原子化」提交

假设你正在开发一个电商平台的项目,最近有用户反馈在使用优惠券时,无法正确抵扣金额。经过排查,你发现是优惠券计算逻辑中的一个 Bug。你修复了这个 Bug,并且为了提高代码的可读性,对相关代码进行了重构,将一些冗长的函数拆分成了更小的函数。

如果你遵循「原子化」提交的原则,你可以将这个过程分为两个提交:

提交序号提交信息(commit message)提交内容
1:专门修复 BugFix discount calculation bug in coupon system在这个提交中,你只修改了计算优惠券金额的代码,确保 Bug 被修复。
2:进行代码重构Refactor coupon calculation code for better readability在这个提交中,你对代码进行了拆分和重构,但不涉及任何 Bug 修复的逻辑。

这样做的好处是,如果后续发现修复 Bug 的代码引入了新的问题,你可以很容易地通过 <strong>git log</strong> 找到第一个提交,然后使用 <strong>git checkout</strong> 检出到修复 Bug 之前的版本,快速定位问题。同时,代码重构的提交也不会干扰到 Bug 修复的提交,使得提交记录清晰明了。

实例二:新功能开发的「原子化」提交

假设你正在开发一个在线教育平台,需要添加一个新的功能:允许用户上传视频作业。这个功能涉及到多个方面的开发,包括前端页面的设计、后端接口的编写以及数据库表结构的调整。

如果你遵循「原子化」提交的原则,你可以将这个功能的开发过程分为多个提交:

提交序号提交信息(commit message)提交内容
1:专注于前端页面设计Design video assignment upload page在这个提交中,你创建了新的 HTML 文件和 CSS 样式,实现了页面的基本布局和样式。
2:编写后端接口Implement backend API for video assignment upload在这个提交中,你创建了新的 API,实现了视频文件的接收和存储逻辑。
3:调整数据库表结构Add database table for video assignments在这个提交中,你创建了新的数据库表,用于存储视频作业的相关信息,如用户 ID、作业 ID、视频文件路径等。

通过这样的「原子化」提交,你可以清晰地记录每个功能模块的开发进度,便于后续的复盘和维护。如果某个模块出现问题,你可以快速定位到相应的提交,进行针对性的排查和修复,而不会被其他模块的代码干扰。

实例三:性能优化的原子提交

假设你正在开发一款合成器软音源插件,最近发现插件占用处理器资源过高,影响用户体验。经过分析,你发现是由于合成器的振荡器、滤波器算法优化不当,且编译参数存在问题,拖累性能。为了优化性能,你需要调整合成器算法,并修改编译系统的编译参数。

如果你遵循「原子化」提交的原则,你可以将这个优化过程分为多个提交:

提交序号提交信息(commit message)提交内容
1:优化振荡器算法Optimize oscillator with SIMD在这个提交中,你使用 SIMD 指令来改善振荡器的性能。
2:优化滤波器算法Optimize filter with SIMD在这个提交中,你同样使用了 SIMD 指令,来提升滤波器的工作效率。
3:优化编译系统的编译参数设置Enable compiler optimization在这个提交中,你为编译系统添加了编译器优化参数,使编译器充分优化代码性能。

通过这样的原子化提交,你可以清晰地记录每个优化措施的实施过程,便于后续的评估和维护。如果某个优化措施带来了新的问题,你可以快速定位到相应的提交,进行针对性的排查和修复,而不会被其他优化措施的代码干扰。

4)原子化提交的潜在优势

值得一提的是,原子化提交除了有利于你自己的代码管理,还能起到「利他」的作用。

在 GitHub 等开源社区中,原子化提交可以方便其他开发者浏览你的提交记录和开发进程,使你的开源成果更好惠及他人。井井有条的提交记录,还可以给他人留下好印象,彰显你的专业水准。

另一方面,像 Linux 这样的大型项目,每个提交也都是原子化的,并且更加规范,这大大方便了二次开发时对新特性的反向移植工作。

我自己也在做 Android 内核的适配,曾经将新版本内核的特性反向移植到华为 P6 的 3.0.6 内核中。开发过程中,我会从新版本内核的提交日志中找到与某个特性有关的补丁,然后将其应用到当前内核中,几乎是一找一个准,节省了在海量代码中「找重点」的精力。

可见,原子化提交利己利人,具有非常显著的潜在优势,受益不只一点点。

善用 git rebase,合并细碎提交

记得大概是 2018 年,我在刷知乎的时候见到一个提问:《Git commits 历史是如何做到如此清爽的?》,提问者非常惊讶于知名前端框架 Vue.js 源码库提交记录的干净、清爽。

Vue.js core 组件提交记录的一部分。

就在这个问题下,作者尤雨溪(Evan You)现身说法

多用 rebase。

就是大佬这短短三个字的经验谈,让我对 git rebase 这个功能产生了浓厚的兴趣。那时的我恐怕也不会想到,后来的我会如此频繁地使用 rebase,从而摆脱既往使用 Git 时提交记录琐碎、杂乱的问题,离正确使用 Git 更近一步。

1)琐碎的提交从何而来?

大家都知道,作为版本管理系统,Git 要保证提交记录可靠、可追溯,因此不能像文本编辑那样随意更改提交记录,最多只提供 git commit --amend 功能来允许你更改最新的提交。

但在开发过程中,或许你不太可能时时刻刻像 Vue.js 的提交记录那样,使自己每个提交都保持清爽、规范。回头使用 git log 浏览提交记录,你可能会发现你提交了太多琐碎的内容,「细致」到写一行代码、修一个错别字、修一个注释都有单独的提交。

很多时候这些提交是很琐碎的,没有必要单独保留,好比是你在文字处理软件中每写一句话就按“保存”,而每个修改都被单独保存成了一个文件。久而久之,提交记录就变得冗长琐碎,管理的时候就很难追溯到有用的、重要的提交。

这个时候, git rebase 就派上用场了。

Git 的设计师考虑到用户整理提交记录的需求,于是就设计了 git rebase 这一功能,允许你合并、编辑、重排已有的提交,使修改后的提交井然有序,就像 Vue.js 的提交记录那样清晰。

2)实战演示如何合并琐碎提交

以下面这个提交记录为例,记录了某项目从零开始写 main() 函数的过程,仅仅是添加文本输出与修改注释的提交就有好几个。

注意:假设这些提交彼此之间没有冲突,每个提交都是在原有提交之上的微调。

$ git log --pretty=oneline    # 使用单行模式输出提交记录。最新的提交在前。
3a51f37493191451413b8dc7428d63351ce4b1e3 (HEAD -> main, origin/main) main: 修改注释中的错别字
6587eeb437c8b139965085ddf99bd72bae682f89 main: 添加注释
61b04c318c24434996587eeb437c8b13996587e6 main: 添加操作结束的文本输出
61500445ebae1eb855ab216c6bbcec6ee73bd270 main: 微调操作开始的文本
7dad6bec684949ab0188085ddf99bc724c7b7b59 main: 添加操作开始的文本输出
996587eeb437c8b13996451413b8dc7603452f32 main: 添加基本文本输出
554bb6603452f3bf5705ac200effbdfc0aa97465 main: 创建main函数

接下来,笔者就要把所有提交都合并到第一个提交「main: 创建main函数(以下简称「目标提交」)当中。

0x00:检查代码树是否有未提交的更改

为了防止 rebase 弄乱代码仓库,Git 强制要求你的工作区「干净」,也就是不存在已经修改但没有提交的文件。否则,你是没有办法 rebase 的。

根据你的开发进度,你可以先提交修改;或者是使用 git stash 将修改暂时保存起来,等到完成 rebase 后再运行 git stash pop 恢复你的修改。

0x01:进入 git rebase 模式

使用 git log 查看提交记录,记住「目标提交」的 ID。然后,运行以下命令,开始 rebase:

git rebase -i 554bb6603452f3bf5705ac200effbdfc0aa97465~

这个命令,允许你修改从「最新提交」到「目标提交」在内的所有提交。注意不要漏了提交 ID 后面的波浪线,否则会把「目标提交」给漏掉2

稍等片刻,Git 会自动打开文本编辑器(通常是 Vim 或 Nano),列出一系列提交。其中:

pick 554bb660 main: 创建main函数
pick 996587ee main: 添加基本文本输出
pick 7dad6bec main: 添加操作开始的文本输出
pick 61500445 main: 微调操作开始的文本
pick 61b04c31 main: 添加操作结束的文本输出
pick 6587eeb4 main: 添加注释
pick 3a51f374 main: 修改注释中的错别字

0x02:合并提交

在本例中,从996587ee 到3a51f374 的这几个提交,都是要并入「目标提交」的提交。

我们把这些提交对应行行首的 pick 改为 fixup (或单个字母“f”)。fixup 指令的作用是合并提交,但是只保留前一个提交(相邻一个比它早的提交)的说明。如下所示:

pick 554bb660 main: 创建main函数
f 996587ee main: 添加基本文本输出
f 7dad6bec main: 添加操作开始的文本输出
f 61500445 main: 微调操作开始的文本
f 61b04c31 main: 添加操作结束的文本输出
f 6587eeb4 main: 添加注释
f 3a51f374 main: 修改注释中的错别字

随后,保存文件并关闭编辑器,Git 就会自动开始 rebase,一个个把新的提交并入相邻的前一个提交中,直到目标提交。这样,那些琐碎的提交都被并入我们的目标提交中,如此一来提交记录就清爽了不少:

$ git log --pretty=oneline    # 使用单行模式输出提交记录
f5815166356e85a5fe244f6024c2e401f04b10fa (HEAD -> main, origin/main) main: 创建main函数

如果你希望保留相关提交的说明文本(以备参考等),那么你可以使用 <strong>squash</strong> 指令(或单个字母“<strong>s</strong>”),保存文件并关闭编辑器后, Git 会打开一个新文档,在这里你可以检查、修改提交说明。

注意:
经过 rebase 之后,原本的「目标提交」ID 会发生变化,因为 Git 实际上生成了一个新的提交。

3)如果琐碎的提交是后来才做出的

随着项目开发的推进,你达成的目标越来越多,提交数量也随之增长。但你发现一个早期编写的功能里,注释、代码缩进这些细节存在问题,于是再做了几个提交来修改。

现在,你想把这些琐碎的修改合并到该功能的提交中。此时你依然可以运用 git rebase,先调整提交顺序,然后再使用 fixup(或 squash)指令来合并提交。

0x00:准备工作

在继续操作之前,你需要确保仓库里没有未提交的更改。

另一方面,你还要保证那些琐碎的提交不与你既往的修改产生冲突。比如说,如果你的提交除了修改注释,还顺带修改了函数结构、变量定义等内容,那么很可能会与你的其他提交造成冲突,需要你手动干预,造成不必要的麻烦。

注意:
假设下文的提交彼此之间没有冲突,每个提交都是在原有提交之上的调整。

0x01:进入 rebase 模式,重排提交顺序

在运行 git rebase 之前,你需要通过 git log 检索提交日志,找到你的「目标提交」。

在下面的例子中,目标提交是554bb660 (「创建 main」函数),你的任务就是要把你的目标是把 3a51f374 与 6587eeb4 这两个琐碎的提交合并到 554bb660 这个提交中。4

$ git log --pretty=oneline    # 使用单行模式输出提交记录
...
3a51f374 main: 修改注释中的错别字
6587eeb4 main: 修正代码缩进
996587ee process: 使用libfftw3,优化合成器算法逻辑
34e87ac3 process: 创建process函数
554bb660 main: 创建main函数
...

然后,运行 git rebase 命令:

git rebase -i 554bb660~

此时, Git 依然会打开一个文本编辑器,内容如下:

pick 554bb660 main: 创建main函数
pick 34e87ac3 process: 创建process函数
pick 996587ee process: 使用libfftw3,优化合成器算法逻辑
pick 6587eeb4 main: 修正代码缩进
pick 3a51f374 main: 修改注释中的错别字

我们在 git rebase 给我们打开的文本编辑器里,把3a51f374 与 6587eeb4 这两个提交对应的行,整体复制到 <strong>554bb660</strong> 的后面,并将原有的行注释掉。就像下面这样:

pick 554bb660 main: 创建main函数

# 将提交所对应的行复制到我们的目标提交后面
pick 6587eeb4 main: 修正代码缩进
pick 3a51f374 main: 修改注释中的错别字

pick 34e87ac3 process: 创建process函数
pick 996587ee process: 使用libfftw3,优化合成器算法逻辑

# 为保险起见,将原有的行注释掉,而不是直接移动
#pick 6587eeb4 main: 修正代码缩进
#pick 3a51f374 main: 修改注释中的错别字

警告:
不要修改 pick 后面的内容,尤其是提交 ID,否则提交记录可能会发生混乱。

0x02:合并提交

确认提交顺序无误后,将待合并的提交对应行行首的 pick 指令改为 fixup (或 squash),如下所示:

pick 554bb660 main: 创建main函数

# 将提交所对应的行复制到我们的目标提交后面
fixup 6587eeb4 main: 修正代码缩进
fixup 3a51f374 main: 修改注释中的错别字

pick 34e87ac3 process: 创建process函数
pick 996587ee process: 使用libfftw3,优化合成器算法逻辑

# 为保险起见,将原有的行注释掉,而不是直接移动
#pick 6587eeb4 main: 修正代码缩进
#pick 3a51f374 main: 修改注释中的错别字

保存文件后,Git 随即开始 rebase 工作,这样我们就可以化琐碎为清爽,得到一个干净的提交记录了。

3)注意事项

在进行 rebase 前,务必要检查你要合并的提交与「目标提交」之间是否存在冲突。一旦存在冲突,那么 git rebase 就无法继续,会要求你手工修改你的仓库代码来处理冲突,这需要更多的时间和精力——因为你要保证代码正常无误。

另一方面,经过 rebase 合并所得的提交,本质上是一个全新的提交,并且也改变了原有的提交记录,因此如果别人 fork 了你的代码,在与你的仓库同步时必定会发生冲突。你或许需要告知你的团队成员,或者是通过 README 来告知代码共享平台5上的用户,告诉他们使用 git pull --rebase 来同步你的修改。

提交顺序不满意?也可以用 rebase 搞定

我自己在找到开发项目的新灵感后,会马上新建一个 Git 仓库,开始动手实践,并把我写的源代码提交到仓库里。然而当我想进一步把仓库上传到 GitHub 时,却发现:我忘了加上许可协议、README 和 .gitignore

考虑到这些文件都是在新建项目时就要添加的,如果我在完成了一部分程序功能后补上去,再回看 Git 提交记录,总是会觉得格外「别扭」。在这样的情况下,我依然可以运用 git rebase,单独调整提交顺序,还我自己一个科学有序的提交记录。

1)实践如何调整提交顺序

这里举一个高度简化的例子:假设笔者有一个项目,已经完成了程序的主体开发工作,后来才补上 README 与许可协议。提交日志如下(较新的提交在前面):

$ git log --pretty=oneline    # 使用单行模式输出提交记录
0fb4a3b0 添加 README.md
acbc6080 添加许可协议(GPLv3)
a841dbc1 UI 的 bug 修复
16f688a6 DSP 性能优化
75ca23f6 完成 UI 开发
277aad5f 完成 DSP 开发
eb323b0e 初始提交(Initial commit)

我希望把与 README、许可协议相关的提交──也就是 0fb4a3b0 、acbc6080 ──挪到时间顺序上的初始提交之后,也就是提交日志中初始提交的前一行。

0x01:打开 git rebase

在本例中,我已经确定好了「目标提交」,也就是初始提交,并且已经确保工作区没有未提交的代码。

然后,运行 git rebase,定位到目标提交:

git rebase -i eb323b0e

接下来 Git 会打开文本编辑器,显示以下内容(较新的提交在文档的后面):

pick 277aad5f 完成 DSP 开发
pick 75ca23f6 完成 UI 开发
pick 16f688a6 DSP 性能优化
pick a841dbc1 UI 的 bug 修复
pick acbc6080 添加许可协议(GPLv3)
pick 0fb4a3b0 添加 README.md

需要注意的是,git rebase 不能显示初始提交,所以我们能看到的最早的提交是紧邻初始提交之后(比初始提交新)的第一个提交。

0x02:开始挪动

先把0fb4a3b0 、acbc6080 这两个提交对应的行整体复制到 277aad5f (也就是初始提交后的第一个提交)前面,然后将原有的那两行注释掉,如下所示:

# 将提交所对应的行复制到我们的目标提交后面。
# 本例中的目标提交是初始提交,所以我们实际上是把要挪动的提交放在 git rebase 文档的最前面
pick 0fb4a3b0 添加 README.md
pick acbc6080 添加许可协议(GPLv3)

pick 277aad5f 完成 DSP 开发
pick 75ca23f6 完成 UI 开发
pick 16f688a6 DSP 性能优化
pick a841dbc1 UI 的 bug 修复

# 为保险起见,将原有的行注释掉,而不是直接移动
#pick acbc6080 添加许可协议(GPLv3)
#pick 0fb4a3b0 添加 README.md

确认无误后,直接保存,此时 Git 就会自动开始 rebase 工作,稍等片刻再查看提交记录,你会发现提交顺序变了,README 与许可协议所对应的提交就出现在了初始提交的后面,大功告成。

2)注意事项

考虑到 rebase 时可能会面临的冲突,你需要三思而后行。

通常只建议重排那些对其他提交记录几乎没什么干扰的提交,例如你的提交只创建、修改了某一个特定的文件(README、许可协议这样的文件)。若重排不慎,你就不得不花费大量的精力来处理 rebase 的冲突,还有可能把提交记录弄乱。6

同样地,你还需要告知你的团队成员或用户,使用 git pull --rebase 来合并你的更改。

对于未成型项目,git commit --amend 也许更适合你

git commit 是 Git 用于提交修改的命令。它有一个参数 --amend,允许你修改最新一次提交的内容。有时你发现刚刚提交的代码有错误,或者是提交说明有问题,但是你不想再新建一个提交来修正这些错误,那么你就可以使用 git commit --amend 这个命令。

然而,对我来说,git commit --amend 的用途远不止于此。在项目还未成型的阶段,我使用它来保持提交记录的清爽。

1)为什么我会常用 git commit --amend

我开发的项目,主要是将现有的开源音频插件移植到 DPF 这个跨平台框架7,这往往是「摸着石头过河」——无论是 DPF 还是被移植的插件,都缺乏文档,全靠我自己摸索。

在早期阶段,代码文件结构、模块和功能代码、编译系统等都还没有定稿,程序也只实现了部分功能:这就是未成型的状态。我个人习惯一边写功能一边调试,常常反复调整代码结构和算法,直到真正实现我预期的目标为止。

这,往往意味着我要持续修改源代码库。如此背景之下,如果每个修改都单独提交到仓库里,那么就意味着仓库里会有数十甚至上百条极其琐碎的提交记录,待到项目成型时还要用 git rebase 来整理提交。你可以想象一下,用 WPS 写文章,每写一句话就另存一个文件,是什么样的感觉。

2)如何妙用 git commit --amend

为了解决上述问题,git commit --amend 就成为了我最常用的操作之一。我会采用这样的思路:

我个人偏向在项目成型之后,才开始转变为以一个个单独提交的方式来持续开发。这里的「成型」,大致可理解为代码结构稳定,程序预期功能已经实现,至少有可以跑起来的 Alpha 版本推出。

如此一来,提交记录就会变得清爽,不会让琐碎的早期开发记录「挤占」你的 git log,事后也不需要再单独花时间来 rebase。

3)注意事项

除此之外,当你在为你的项目添加新功能时,你也可以在原型设计、测试的阶段使用git commit --amend ,因为这个过程常常就像写一篇新文章,你或许也不希望每写一句话就又来另存一个新文档。

写在最后

在日常与 Git 打交道的过程中,我一直致力于精进 Git 的使用。上面这几点技巧,就是我自己的实践成果,着力于让提交日志更清爽、规范,方便后续的维护。让我们再来回顾一下:

当然,以上的技巧,更多体现出我个人的使用习惯,客观上也改善了我自己 Git 仓库的质量。相信我的分享能为感兴趣的读者朋友们提供参考,一同将 Git 用得更自在。