Git
英语 ▾ 主题 ▾ 最新版本 ▾ git-bisect-lk2009 最后更新于 2.40.0

摘要

"git bisect" 使软件用户和开发人员能够轻松地找到引入回归的提交。我们展示了拥有良好的工具来对抗回归的重要性。我们描述了“git bisect”从外部的工作原理以及它在内部使用的算法。然后,我们解释如何利用“git bisect”来改进当前的实践。最后,我们讨论了“git bisect”在未来如何改进。

“git bisect”简介

Git 是由 Linus Torvalds 创建并由 Junio Hamano 维护的分布式版本控制系统 (DVCS)。

在 Git 中,就像在许多其他版本控制系统 (VCS) 中一样,系统管理的数据的不同状态称为提交。并且,由于 VCS 主要用于管理软件源代码,因此软件中的一些“有趣”的行为变化有时会在某些提交中引入。

事实上,人们特别感兴趣的是引入“不良”行为的提交,称为错误或回归。他们对这些提交感兴趣,因为一个提交(希望)包含一组非常小的源代码更改。而且,当您只需要检查一组非常小的更改时,理解并正确解决问题要容易得多,而不是当您一开始不知道从哪里查找时。

因此,为了帮助人们找到引入“不良”行为的提交,“git bisect”命令集被发明出来。当然,在“git bisect”术语中,存在“有趣行为”的提交称为“不良”提交,而其他提交称为“良好”提交。引入我们感兴趣的行为的提交称为“第一个不良提交”。请注意,在我们正在搜索的提交空间中,可能存在多个“第一个不良提交”。

因此,“git bisect”旨在帮助找到“第一个不良提交”。为了尽可能高效,它尝试执行二分查找。

对抗回归概述

回归:一个大问题

回归是软件行业的一个大问题。但很难对此说法给出一些实际的数字。

关于错误的总体有一些数字,例如 2002 年的 NIST 研究 [1]

软件错误或缺陷非常普遍,并且造成的影响非常大,据美国商务部国家标准与技术研究院 (NIST) 委托进行的一项最新研究显示,它们每年给美国经济造成约 595 亿美元的损失,约占国内生产总值的 0.6%。在国家层面,超过一半的成本由软件用户承担,其余部分由软件开发人员/供应商承担。研究还发现,虽然无法消除所有错误,但这些成本的 30% 以上,即估计 222 亿美元,可以通过改进的测试基础设施来消除,该基础设施能够更早更有效地识别和消除软件缺陷。这些是与在更接近引入错误的开发阶段发现更多百分比(但不是 100%)错误相关的节省。目前,超过一半的错误直到开发过程中的“下游”或销售后软件使用期间才被发现。

然后

软件开发人员已经将大约 80% 的开发成本用于识别和纠正缺陷,然而,除了软件之外,几乎没有其他类型的产品以如此高的错误率发布。

最终,结论以以下内容开始

提高软件质量的途径是显著改进软件测试。

其他估计表明,与软件相关的成本中 80% 左右是关于维护的 [2]

尽管如此,根据维基百科 [3]

人们普遍认为维护仅仅是修复错误。然而,多年来的研究和调查表明,大多数(超过 80%)的维护工作用于非纠正性行动 (Pigosky 1997)。这种看法是由用户提交的错误报告所延续的,而实际上这些报告是系统功能的增强。

但我们可以猜测,改进现有软件非常昂贵,因为您必须注意回归。至少,这会使上述研究相互一致。

当然,某些类型的软件是开发出来的,然后在一段时间内使用而没有进行太多改进,最终被丢弃。在这种情况下,回归可能不是一个大问题。但另一方面,有很多大型软件,在多年甚至几十年内由许多人持续开发和维护。由于许多人(有时是关键地)依赖此类软件,因此回归是一个真正的大问题。

Linux 内核就是这样一种软件。如果我们查看 Linux 内核,我们可以看到,很多时间和精力都花在了对抗回归上。发布周期从为期两周的合并窗口开始。然后标记第一个发布候选 (rc) 版本。之后,大约会有 7 或 8 个 rc 版本出现,每次版本之间间隔约一周,然后是最终发布。

第一个 rc 版本和最终发布之间的这段时间应该用于测试 rc 版本并对抗错误,特别是回归。这部分时间占发布周期时间的 80% 以上。但战斗还没有结束,因为它当然会在发布后继续。

然后,这就是 Ingo Molnar(一位著名的 Linux 内核开发人员)关于他使用 git bisect 的说法

我在合并窗口期间最积极地使用它(当许多树合并到上游时,并且错误的涌入量最高)- 是的,我一天使用过它好几次。我的平均使用次数大约是一天一次。

因此,开发人员一直在对抗回归,并且众所周知,错误应该尽快修复,以便在发现错误后尽快修复它们。这就是拥有良好的工具来完成此目的的原因。

其他对抗回归的工具

那么,用于对抗回归的工具是什么?它们几乎与用于对抗常规错误的工具相同。唯一特殊的工具是测试套件和类似“git bisect”的工具。

测试套件非常不错。但是,当它们单独使用时,应该在每次提交后运行所有测试。这意味着它们效率不高,因为许多测试都用于运行不产生有趣结果,并且它们会受到组合爆炸的影响。

事实上,问题在于,大型软件通常具有许多不同的配置选项,并且每个测试用例都应该在每次提交后通过每个配置。因此,如果您在每次发布中都有:N 个配置、M 个提交和 T 个测试用例,您应该执行

N * M * T tests

其中 N、M 和 T 都会随着您的软件大小而增长。

因此,很快将无法对所有内容进行完整测试。

如果一些错误通过了您的测试套件,那么您可以向您的测试套件添加一个测试。但是,如果您想使用新的改进的测试套件来找到错误是何时出现的,那么您要么必须模拟二分查找过程,要么您可能会从您拥有的“不良”提交开始,从后向前粗略测试每个提交,这可能非常浪费。

“git bisect”概述

开始二分查找

第一个要使用的“git bisect”子命令是“git bisect start”,以开始搜索。然后,必须设置边界以限制提交空间。这通常是通过提供一个“不良”提交和至少一个“良好”提交来完成的。它们可以在对“git bisect start”的初始调用中传递,如下所示

$ git bisect start [BAD [GOOD...]]

或者,可以使用以下命令设置它们

$ git bisect bad [COMMIT]

$ git bisect good [COMMIT...]

其中 BAD、GOOD 和 COMMIT 都是可以解析为提交的名称。

然后,“git bisect”将检出一个它选择的提交,并要求用户对其进行测试,如下所示

$ git bisect start v2.6.27 v2.6.25
Bisecting: 10928 revisions left to test after this (roughly 14 steps)
[2ec65f8b89ea003c27ff7723525a2ee335a2b393] x86: clean up using max_low_pfn on 32-bit

请注意,我们将使用的示例实际上只是一个玩具示例,我们将寻找第一个包含类似“2.6.26-something”版本的提交,即在顶层 Makefile 中包含“SUBLEVEL = 26”行的提交。这是一个玩具示例,因为 Git 中比使用“git bisect”更有效的方法来查找此提交(例如“git blame”或“git log -S<string>”)。

手动驱动二分查找

此时,基本上有两种方法可以驱动搜索。它可以由用户手动驱动,也可以由脚本或命令自动驱动。

如果由用户驱动,那么在每次搜索步骤中,用户都必须测试当前提交,并使用上面描述的“git bisect good”或“git bisect bad”命令分别说明它是“好的”还是“坏的”。例如

$ git bisect bad
Bisecting: 5480 revisions left to test after this (roughly 13 steps)
[66c0b394f08fd89236515c1c84485ea712a157be] KVM: kill file->f_count abuse in kvm

在执行了几步类似操作后,“git bisect”最终将找到第一个错误提交

$ git bisect bad
2ddcca36c8bcfa251724fe342c8327451988be0d is the first bad commit
commit 2ddcca36c8bcfa251724fe342c8327451988be0d
Author: Linus Torvalds <[email protected]>
Date:   Sat May 3 11:59:44 2008 -0700

    Linux 2.6.26-rc1

:100644 100644 5cf82581... 4492984e... M      Makefile

此时,我们可以查看提交的内容,将其检出(如果尚未检出)或对其进行修改,例如

$ git show HEAD
commit 2ddcca36c8bcfa251724fe342c8327451988be0d
Author: Linus Torvalds <[email protected]>
Date:   Sat May 3 11:59:44 2008 -0700

    Linux 2.6.26-rc1

diff --git a/Makefile b/Makefile
index 5cf8258..4492984 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
 VERSION = 2
 PATCHLEVEL = 6
-SUBLEVEL = 25
-EXTRAVERSION =
+SUBLEVEL = 26
+EXTRAVERSION = -rc1
 NAME = Funky Weasel is Jiggy wit it

 # *DOCUMENTATION*

当我们完成后,可以使用“git bisect reset”返回到我们在开始进行二分查找之前所在的的分支

$ git bisect reset
Checking out files: 100% (21549/21549), done.
Previous HEAD position was 2ddcca3... Linux 2.6.26-rc1
Switched to branch 'master'

自动驱动二分查找

驱动二分查找过程的另一种方法是告诉“git bisect”在每一步二分查找时启动一个脚本或命令,以了解当前提交是“好的”还是“坏的”。为此,我们使用“git bisect run”命令。例如

$ git bisect start v2.6.27 v2.6.25
Bisecting: 10928 revisions left to test after this (roughly 14 steps)
[2ec65f8b89ea003c27ff7723525a2ee335a2b393] x86: clean up using max_low_pfn on 32-bit
$
$ git bisect run grep '^SUBLEVEL = 25' Makefile
running grep ^SUBLEVEL = 25 Makefile
Bisecting: 5480 revisions left to test after this (roughly 13 steps)
[66c0b394f08fd89236515c1c84485ea712a157be] KVM: kill file->f_count abuse in kvm
running grep ^SUBLEVEL = 25 Makefile
SUBLEVEL = 25
Bisecting: 2740 revisions left to test after this (roughly 12 steps)
[671294719628f1671faefd4882764886f8ad08cb] V4L/DVB(7879): Adding cx18 Support for mxl5005s
...
...
running grep ^SUBLEVEL = 25 Makefile
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[2ddcca36c8bcfa251724fe342c8327451988be0d] Linux 2.6.26-rc1
running grep ^SUBLEVEL = 25 Makefile
2ddcca36c8bcfa251724fe342c8327451988be0d is the first bad commit
commit 2ddcca36c8bcfa251724fe342c8327451988be0d
Author: Linus Torvalds <[email protected]>
Date:   Sat May 3 11:59:44 2008 -0700

    Linux 2.6.26-rc1

:100644 100644 5cf82581... 4492984e... M      Makefile
bisect run success

在此示例中,我们将“grep ^SUBLEVEL = 25 Makefile”作为参数传递给了“git bisect run”。这意味着,在每一步中,我们将传递的 grep 命令都会被启动。如果它以代码 0(表示成功)退出,则 git bisect 将标记当前状态为“好”。如果它以代码 1(或介于 1 和 127 之间的任何代码,包括 127,但排除特殊代码 125)退出,则当前状态将被标记为“坏”。

代码 128 到 255 之间的退出代码对于“git bisect run”是特殊的。它们会立即停止二分查找过程。这在传递的命令需要很长时间才能完成时很有用,因为您可以使用信号将其杀死,它会停止二分查找过程。

它在传递给“git bisect run”的脚本中也很有用,如果检测到一些非常异常的情况,则可以使用“exit 255”。

避免不可测试的提交

有时会发生当前状态无法测试的情况,例如,如果它无法编译,因为当时存在阻止其编译的错误。这就是特殊退出代码 125 的用途。它告诉“git bisect run”当前提交应标记为不可测试,并且应该选择和检出另一个提交。

如果二分查找过程由用户手动驱动,可以使用“git bisect skip”来执行相同的操作。(实际上,特殊退出代码 125 使“git bisect run”在后台使用“git bisect skip”。)

或者,如果你想要更多控制,可以使用例如“git bisect visualize”来检查当前状态。它会启动 gitk(或者如果DISPLAY环境变量未设置,则启动“git log”)以帮助您找到更好的二分查找点。

无论哪种方式,如果你有一系列不可测试的提交,可能会出现你正在查找的回归是由这些不可测试的提交之一引入的。在这种情况下,无法确定是哪个提交引入了回归。

因此,如果你使用了“git bisect skip”(或者运行脚本以特殊代码 125 退出),你可能会得到这样的结果

There are only 'skip'ped commits left to test.
The first bad commit could be any of:
15722f2fa328eaba97022898a305ffc8172db6b1
78e86cf3e850bd755bb71831f42e200626fbd1e0
e15b73ad3db9b48d7d1ade32f8cd23a751fe0ace
070eab2303024706f2924822bfec8b9847e4ac1b
We cannot bisect more!

保存日志并重放

如果你想向其他人展示你的二分查找过程,可以使用以下方法获取日志

$ git bisect log > bisect_log.txt

可以使用以下方法重放它

$ git bisect replay bisect_log.txt

"git bisect" 细节

二分查找算法

由于 Git 提交形成有向无环图 (DAG),因此在每一步中找到最佳二分查找提交来测试并不容易。无论如何,Linus 发现并实现了一种“真正愚蠢”的算法,后来由 Junio Hamano 改进,这种算法运行良好。

因此,“git bisect”用来在没有跳过提交的情况下找到最佳二分查找提交的算法如下

1) 只保留满足以下条件的提交:

a) 是“坏”提交的祖先(包括“坏”提交本身),b) 不是“好”提交的祖先(不包括“好”提交)。

这意味着我们去掉了 DAG 中无趣的提交。

例如,如果我们从这样的图开始

G-Y-G-W-W-W-X-X-X-X
	   \ /
	    W-W-B
	   /
Y---G-W---W
 \ /   \
Y-Y     X-X-X-X

-> time goes this way ->

其中 B 是“坏”提交,“G”是“好”提交,W、X 和 Y 是其他提交,我们将在第一步之后得到以下图

W-W-W
     \
      W-W-B
     /
W---W

因此,只有 W 和 B 提交会被保留。因为提交 X 和 Y 分别被规则 a) 和 b) 删除,并且因为提交 G 也被规则 b) 删除。

请注意,对于 Git 用户来说,这相当于只保留由以下命令给出的提交

git rev-list BAD --not GOOD1 GOOD2...

还要注意,我们不要求保留的提交是“好”提交的后代。因此,在以下示例中,提交 W 和 Z 将被保留

G-W-W-W-B
   /
Z-Z

2) 从图的“好”端开始,将每个提交与其祖先的数量加一关联起来

例如,对于以下图,其中 H 是“坏”提交,A 和 D 是某些“好”提交的父级

A-B-C
     \
      F-G-H
     /
D---E

这将给出

1 2 3
A-B-C
     \6 7 8
      F-G-H
1   2/
D---E

3) 将每个提交与以下内容关联:min(X, N - X)

其中 X 是步骤 2) 中与提交关联的值,N 是图中的提交总数。

在上例中,我们有 N = 8,因此这将给出

1 2 3
A-B-C
     \2 1 0
      F-G-H
1   2/
D---E

4) 最佳二分查找点是与最高关联数字相对应的提交

因此,在上例中,最佳二分查找点是提交 C。

5) 请注意,实现了一些捷径来加快算法的速度

由于我们从一开始就知道了 N,因此我们知道 min(X, N - X) 不可能大于 N/2。因此,在步骤 2) 和 3) 中,如果我们将 N/2 与提交相关联,那么我们就知道这是最佳二分查找点。因此,在这种情况下,我们可以停止处理任何其他提交并返回当前提交。

二分查找算法调试

对于任何提交图,可以使用“git rev-list --bisect-all”查看与每个提交关联的数字。

例如,对于上面的图,类似的命令

$ git rev-list --bisect-all BAD --not GOOD1 GOOD2

将输出类似的内容

e15b73ad3db9b48d7d1ade32f8cd23a751fe0ace (dist=3)
15722f2fa328eaba97022898a305ffc8172db6b1 (dist=2)
78e86cf3e850bd755bb71831f42e200626fbd1e0 (dist=2)
a1939d9a142de972094af4dde9a544e577ddef0e (dist=2)
070eab2303024706f2924822bfec8b9847e4ac1b (dist=1)
a3864d4f32a3bf5ed177ddef598490a08760b70d (dist=1)
a41baa717dd74f1180abf55e9341bc7a0bb9d556 (dist=1)
9e622a6dad403b71c40979743bb9d5be17b16bd6 (dist=0)

讨论二分查找算法

首先,让我们定义“最佳二分查找点”。我们将说,如果知道提交 X 的状态(“好”或“坏”)可以尽可能多地提供关于该提交状态是否为“好”或“坏”的信息,则该提交 X 是最佳二分查找点或最佳二分查找提交。

这意味着最佳二分查找提交是以下函数最大的提交

f(X) = min(information_if_good(X), information_if_bad(X))

其中 information_if_good(X) 是 X 为好时获得的信息,information_if_bad(X) 是 X 为坏时获得的信息。

现在,我们将假设只有一个“第一个错误提交”。这意味着它的所有后代都是“坏的”,所有其他提交都是“好的”。我们将假设所有提交都有相同的概率为好或坏,或成为第一个错误提交,因此知道 c 个提交的状态总是提供相同的信息量,无论这些 c 个提交在图中的什么位置以及 c 是多少。(因此,我们假设这些提交例如位于分支上或靠近好或坏提交不会提供更多或更少的信息)。

让我们也假设我们有一个清理后的图,类似于上述二分查找算法中步骤 1) 之后的那一个。这意味着我们可以根据可以从图中删除的提交数量来衡量获得的信息量。

让我们在图中选择一个提交 X。

如果 X 被发现为“好”,那么我们知道它的祖先都是“好的”,因此我们想说

information_if_good(X) = number_of_ancestors(X)  (TRUE)

这是正确的,因为在步骤 1) b) 中,我们删除了“好”提交的祖先。

如果 X 被发现为“坏”,那么我们知道它的后代都是“坏的”,因此我们想说

information_if_bad(X) = number_of_descendants(X)  (WRONG)

但这是错误的,因为在步骤 1) a) 中,我们只保留了错误提交的祖先。因此,当一个提交被标记为“坏”时,我们会获得更多信息,因为我们还知道,以前“坏”提交的祖先(不是新“坏”提交的祖先)不是第一个错误提交。我们不知道它们是好还是坏,但我们知道它们不是第一个错误提交,因为它们不是新“坏”提交的祖先。

因此,当一个提交被标记为“坏”时,我们知道我们可以删除图中的所有提交,除了那些是新“坏”提交的祖先的提交。这意味着

information_if_bad(X) = N - number_of_ancestors(X)  (TRUE)

其中 N 是(清理后的)图中的提交数量。

因此,最后这意味着要找到最佳二分查找提交,我们应该最大化函数

f(X) = min(number_of_ancestors(X), N - number_of_ancestors(X))

这很好,因为在步骤 2) 中,我们计算 number_of_ancestors(X),因此在步骤 3) 中,我们计算 f(X)。

让我们以以下图为例

            G-H-I-J
           /       \
A-B-C-D-E-F         O
           \       /
            K-L-M-N

如果我们在其上计算以下非最优函数

g(X) = min(number_of_ancestors(X), number_of_descendants(X))

我们得到

            4 3 2 1
            G-H-I-J
1 2 3 4 5 6/       \0
A-B-C-D-E-F         O
           \       /
            K-L-M-N
            4 3 2 1

但使用 git bisect 使用的算法,我们得到

            7 7 6 5
            G-H-I-J
1 2 3 4 5 6/       \0
A-B-C-D-E-F         O
           \       /
            K-L-M-N
            7 7 6 5

因此,我们选择 G、H、K 或 L 作为最佳二分查找点,这比 F 更好。因为例如,如果 L 是坏的,那么我们将不仅知道 L、M 和 N 是坏的,而且还知道 G、H、I 和 J 不是第一个错误提交(因为我们假设只有一个第一个错误提交,它必须是 L 的祖先)。

因此,当前算法似乎是我们最初假设的情况下最好的可能算法。

跳过算法

当一些提交被跳过(使用“git bisect skip”)时,二分查找算法对于步骤 1) 到 3) 来说是相同的。但之后我们大致使用以下步骤

6) 按关联值的降序对提交进行排序

7) 如果第一个提交没有被跳过,我们可以返回它并在此停止

8) 否则,从排序列表中过滤掉所有被跳过的提交

9) 使用伪随机数生成器 (PRNG) 在 0 到 1 之间生成一个随机数

10) 将这个随机数乘以它的平方根,使其偏向于 0

11) 将结果乘以过滤后的列表中的提交数量,以获得该列表中的索引

12) 返回计算出的索引处的提交

讨论跳过算法

在步骤 7)(在跳过算法中)之后,我们可以检查第二个提交是否已被跳过,如果不是,则返回它。实际上,从 Git 版本 1.5.4(于 2008 年 2 月 1 日发布)中的“git bisect skip”开发出来到 Git 版本 1.6.4(于 2009 年 7 月 29 日发布)期间,我们一直使用的是这种算法。

但 Ingo Molnar 和 H. Peter Anvin(另一位著名的 Linux 内核开发者)都抱怨说,有时最佳的二分查找点恰好出现在所有提交都无法测试的区域。在这种情况下,用户被要求测试许多无法测试的提交,这可能非常低效。

实际上,无法测试的提交通常无法测试,因为断裂是在某个时间引入的,并且只有在引入许多其他提交之后才修复了该断裂。

当然,这种断裂在大多数情况下与我们试图在提交图中定位的断裂无关。但它阻止我们知道是否有“不良行为”。

因此,事实是,靠近无法测试提交的提交本身具有很高的概率无法测试。而且,最佳的二分查找提交也经常一起出现(由于二分查找算法)。

这就是为什么当第一个提交被跳过时,只选择下一个最佳未跳过的二分查找提交不是一个好主意。

我们发现,当测试图上的大多数提交时,它们可能会提供很多信息。而平均不会提供很多信息的提交是那些靠近好提交和坏提交的提交。

因此,使用 PRNG 并使其偏向于偏爱远离好提交和坏提交的提交似乎是一个不错的选择。

对该算法的一个明显改进是,在使用 PRNG 之前,查找一个与最佳二分查找提交的值相近且位于另一个分支上的提交。因为如果存在这样的提交,那么它不太可能也无法测试,因此它可能比几乎随机选择的提交提供更多信息。

检查合并基础

二分查找算法中还有一个调整,在上面的“二分查找算法”中没有描述。

我们在前面的示例中假设“好”提交是“坏”提交的祖先。但这并不是“git bisect”的要求。

当然,“坏”提交不能是“好”提交的祖先,因为“好”提交的祖先应该都是“好”的。所有“好”提交都必须与坏提交相关。它们不能位于与“坏”提交的分支没有链接的分支上。但是,一个好的提交可以与一个坏的提交相关,但既不是它的祖先,也不是它的后代。

例如,可以有一个“主”分支和一个“开发”分支,该分支从名为“D”的提交处从“主”分支分叉,如下所示

A-B-C-D-E-F-G  <--main
       \
        H-I-J  <--dev

提交“D”被称为“主”分支和“开发”分支的“合并基础”,因为它是这些分支合并的最佳共同祖先。

现在假设提交 J 是不好的,提交 G 是好的,我们应用了前面描述的二分查找算法。

如二分查找算法的步骤 1) b) 所述,我们删除了所有“好”提交的祖先,因为它们也应该都是好的。

所以我们只剩下

H-I-J

但如果第一个坏提交是“B”,并且它在“主”分支的提交“F”中得到了修复,会发生什么?

这样的二分查找结果将是我们会发现 H 是第一个坏的提交,而实际上是 B。所以这将是错误的!

是的,在实践中,在某个分支上工作的人可能不知道在另一个分支上工作的人修复了一个错误!还可能发生的是,F 修复了多个错误,或者它是对尚未发布的重大开发工作的回退。

实际上,开发团队通常维护一个开发分支和一个维护分支,如果“git bisect”在他们想要对不在维护分支上的开发分支上的回归进行二分查找时正常工作,那么这对他们来说会非常容易。他们应该能够使用以下命令开始二分查找

$ git bisect start dev main

为了启用这个额外的优秀功能,当二分查找开始并且一些好的提交不是坏提交的祖先时,我们首先计算坏提交和好提交之间的合并基础,并将这些合并基础选为将被签出和测试的第一个提交。

如果恰好有一个合并基础是坏的,那么二分查找过程将停止,并显示如下消息

The merge base BBBBBB is bad.
This means the bug has been fixed between BBBBBB and [GGGGGG,...].

其中 BBBBBB 是坏合并基础的 sha1 哈希,[GGGGGG,…​] 是好提交的 sha1 的逗号分隔列表。

如果一些合并基础被跳过,那么二分查找过程将继续,但会为每个被跳过的合并基础打印以下消息

Warning: the merge base between BBBBBB and [GGGGGG,...] must be skipped.
So we cannot be sure the first bad commit is between MMMMMM and BBBBBB.
We continue anyway.

其中 BBBBBB 是坏提交的 sha1 哈希,MMMMMM 是被跳过的合并基础的 sha1 哈希,[GGGGGG,…​] 是好提交的 sha1 的逗号分隔列表。

因此,如果没有坏的合并基础,二分查找过程将在此步骤后照常继续。

最佳二分查找实践

将测试套件和 git bisect 结合使用

如果您既有测试套件又使用 git bisect,那么检查每次提交后所有测试是否通过变得不那么重要了。当然,最好进行一些检查,以避免破坏太多东西,因为这可能会使其他错误的二分查找更加困难。

您可以集中精力检查几个点(例如 rc 和 beta 版本),以确保所有 N 种配置的所有 T 个测试用例都通过。当一些测试不通过时,您可以使用“git bisect”(或者更好地使用“git bisect run”)。所以您应该执行大约

c * N * T + b * M * log2(M) tests

其中 c 是测试轮数(所以是一个小的常数),b 是每个提交的错误率(希望也是一个小的常数)。

所以,当然,如果每次提交后都测试所有内容,这要好得多,因为它是 O(N * T) 与 O(N * T * M)。

这意味着测试套件有利于防止一些错误被提交,并且它们也非常适合告诉您是否有一些错误。但它们并不适合告诉您在何处引入了错误。为了有效地告诉您这一点,需要使用 git bisect。

测试套件的另一个好处是,当您有一个测试套件时,您已经知道如何测试不良行为。因此,您可以使用这种知识在出现回归时为“git bisect”创建一个新的测试用例。因此,二分查找错误并修复它会更容易。然后,您可以将刚刚创建的测试用例添加到测试套件中。

因此,如果您知道如何创建测试用例以及如何进行二分查找,那么您将成为一个良性循环的一部分

更多测试⇒更容易创建测试⇒更容易二分查找⇒更多测试

因此,测试套件和“git bisect”是互补的工具,当一起使用时非常强大且高效。

对构建失败进行二分查找

您可以非常轻松地使用类似以下内容来自动对构建失败进行二分查找

$ git bisect start BAD GOOD
$ git bisect run make

将 sh -c “一些命令”传递给“git bisect run”

例如

$ git bisect run sh -c "make || exit 125; ./my_app | grep 'good output'"

另一方面,如果您经常这样做,那么可能值得拥有脚本以避免过多地输入。

查找性能回归

这是一个示例脚本,它略微修改自 Junio Hamano [4] 使用的真实世界脚本。

此脚本可以传递给“git bisect run”以查找引入性能回归的提交

#!/bin/sh

# Build errors are not what I am interested in.
make my_app || exit 255

# We are checking if it stops in a reasonable amount of time, so
# let it run in the background...

./my_app >log 2>&1 &

# ... and grab its process ID.
pid=$!

# ... and then wait for sufficiently long.
sleep $NORMAL_TIME

# ... and then see if the process is still there.
if kill -0 $pid
then
	# It is still running -- that is bad.
	kill $pid; sleep 1; kill $pid;
	exit 1
else
	# It has already finished (the $pid process was no more),
	# and we are happy.
	exit 0
fi

遵循一般最佳实践

显然,最好不要有包含故意破坏事物更改的提交,即使一些其他提交后来修复了该破坏。

在使用任何 VCS 时,最好在每个提交中只进行一项小的逻辑更改。

提交中的更改越小,“git bisect”的效果就越好。而且您可能根本不需要“git bisect”,因为即使更改只由提交者审核,小的更改也更容易审核。

另一个好主意是写好提交信息。它们对于理解为什么进行了一些更改非常有用。

如果您经常进行二分查找,那么这些通用的最佳实践将非常有用。

避免容易出错的合并

首先,合并本身可能会引入一些回归,即使合并不需要源代码冲突解决。这是因为一个分支可能会发生语义更改,而另一个分支却不知道它。

例如,一个分支可能会更改函数的语义,而另一个分支则添加更多对同一函数的调用。

如果必须修复许多文件才能解决冲突,这种情况会变得更糟。这就是为什么这样的合并被称为“邪恶合并”。它们会使回归非常难以追踪。如果恰好是合并是第一个坏提交,那么知道第一个坏提交甚至可能具有误导性,因为人们可能会认为错误来自错误的冲突解决,而实际上它来自一个分支中的语义更改。

无论如何,“git rebase”可用于使历史线性化。这可以用来避免合并本身。或者,它可以用于在线性历史记录而不是非线性历史记录上进行二分查找,因为在某个分支中发生语义更改时,这应该提供更多信息。

通过使用更小的分支或使用多个主题分支而不是仅使用与版本相关的长分支,可以使合并更简单。

并且可以在特殊的集成分支(如 Linux 内核的 linux-next)中更频繁地进行测试。

调整您的工作流程

处理回归的特殊工作流程可以取得很好的效果。

以下是 Andreas Ericsson 使用的工作流程示例

  • 在测试套件中编写一个测试脚本,以暴露回归

  • 使用“git bisect run”找到引入它的提交

  • 修复错误,这通常在前面的步骤中变得很明显

  • 提交修复和测试脚本(如果需要,提交更多测试)

以下是 Andreas 关于此工作流程的评论 [5]

我们以前平均需要 142.6 小时才能完成一个 bug 报告到修复的循环(根据我们有点奇怪的 bug 跟踪系统,它只记录时间)。自从我们迁移到 Git 之后,我们已经将这个时间缩短到了 16.2 小时。主要是因为我们现在能够更有效地修复 bug,并且每个人都在争先恐后地修复 bug(我们很自豪地承认自己很懒,让 Git 帮我们找出 bug)。每次新版本发布后,bug 数量都会减少约 40%(几乎可以肯定是因为我们现在对编写测试的态度)。

显然,这种工作流程利用了测试套件和“git bisect”之间的良性循环。事实上,它使得“git bisect”成为处理回归的标准程序。

在其他邮件中,Andreas 说他们也使用上面描述的“最佳实践”:小的逻辑提交、主题分支、避免恶意合并……这些实践通过简化 bisecting 的操作和提高其有效性来改善提交图的可二分性。

因此,一个好的工作流程应该围绕上述要点进行设计,即让 bisecting 更轻松、更有用和更标准化。

让 QA 人员和最终用户参与进来

“git bisect”的一大优点是它不仅仅是一个开发者工具。QA 人员甚至最终用户(如果他们可以访问源代码或所有构建版本)都可以有效地使用它。

在 Linux 内核邮件列表中曾有一次讨论,讨论的主题是是否总是要求最终用户进行 bisect,并且提出了很多很好的论点来支持这种观点。

例如,David Miller 这样写道 [6]

人们不明白的是,这是一种“最终节点原则”适用的情况。当你的资源有限时(这里指的是开发者),你不会将大部分负担压在他们身上。相反,你会将任务分配给你有大量资源的最终节点(这里指的是用户),这样才能使情况真正扩展。

这意味着,如果 QA 人员或最终用户能够进行 bisect,往往会“更便宜”。

另一个有趣的现象是,报告 bug 的最终用户(或重现 bug 的 QA 人员)可以访问发生 bug 的环境。因此,他们往往更容易重现回归。如果他们可以进行 bisect,那么就可以从发生 bug 的环境中提取更多信息,这意味着更容易理解和修复 bug。

对于开源项目来说,这是一个很好的方法,可以从最终用户那里获得更有用的贡献,并让他们参与到 QA 和开发活动中。

使用复杂的脚本

在某些情况下,例如内核开发,开发复杂的脚本可以完全自动化 bisecting,这样做很有价值。

Ingo Molnar 这样说道 [7]

我有一个完全自动化的启动挂起 bisecting 脚本。它是基于“git-bisect run”的。我运行脚本,它会完全自动地构建和启动内核,当启动失败时(脚本会通过连续监控串行日志来注意到这一点,或者通过超时,如果系统在 10 分钟内没有启动,那么它就是一个“坏”内核),脚本会通过蜂鸣声提醒我,然后我重新启动测试机。(是的,我应该使用一个可管理的电源插座来实现 100% 自动化)

将测试套件、git bisect 和其他系统结合在一起

我们已经看到,测试套件和 git bisect 在一起使用时非常强大。如果你可以将它们与其他系统结合起来,就会更加强大。

例如,一些测试套件可以在晚上自动运行,使用一些不寻常(甚至随机)的配置。如果测试套件发现了一个回归,那么可以自动启动“git bisect”,并将结果通过电子邮件发送给“git bisect”找到的第一个坏提交的作者,以及其他相关人员。还可以自动创建 bug 跟踪系统中的新条目。

bisecting 的未来

“git replace”

我们之前看到,“git bisect skip”现在使用 PRNG 来尝试避免提交图中不可测试的区域。问题是,有时第一个坏提交会出现在不可测试的区域。

为了简化讨论,我们假设不可测试的区域是一串简单的提交,它是由于一个提交引入的错误造成的(我们称之为 BBC,即 bisect 导致中断的提交),后来被另一个提交修复了(我们称之为 BFC,即 bisect 修复的提交)。

例如

...-Y-BBC-X1-X2-X3-X4-X5-X6-BFC-Z-...

我们知道 Y 是好的,BFC 是坏的,而 BBC 和 X1 到 X6 是不可测试的。

在这种情况下,如果你手动进行 bisecting,你可以做的是创建一个特殊的分支,该分支从 BBC 之前的第一个提交开始。这个分支中的第一个提交应该是 BBC,并将 BFC 合并到其中。该分支中的其他提交应该是 BBC 和 BFC 之间的提交,它们被重新基于该分支的第一个提交,然后是 BFC 之后的提交也被重新基于。

例如

      (BBC+BFC)-X1'-X2'-X3'-X4'-X5'-X6'-Z'
     /
...-Y-BBC-X1-X2-X3-X4-X5-X6-BFC-Z-...

其中用 ' 引号括起来的提交是重新基于的。

你可以使用 Git 的交互式 rebase 很容易地创建这样一个分支。

例如,使用

$ git rebase -i Y Z

然后将 BFC 移动到 BBC 之后并将其合并。

之后,你就可以像往常一样在这个新分支中开始 bisecting,你最终应该会找到第一个坏提交。

例如

$ git bisect start Z' Y

如果你使用的是“git bisect run”,你可以使用上面相同的手动修复方法,然后在这个特殊分支中启动另一个“git bisect run”。或者,正如“git bisect”手册页所说,传递给“git bisect run”的脚本可以在编译软件之前应用补丁并测试软件 [8]。补丁应该将当前不可测试的提交变成可测试的提交。这样测试就会得出“好”或“坏”的结果,“git bisect”就能找到第一个坏提交。脚本不应该忘记在测试完成后、从脚本退出之前移除补丁。

(注意,除了补丁之外,你还可以使用“git cherry-pick BFC”来应用修复,在这种情况下,你应该使用“git reset --hard HEAD^”来在测试完成后、从脚本返回之前撤销 cherry-pick)。

但是,上面规避不可测试区域的方法有点笨拙。使用特殊分支很好,因为这些分支可以像普通分支一样被开发者共享,但风险是人们会创建很多这样的分支。并且会扰乱正常的“git bisect”工作流程。因此,如果你想完全自动地使用“git bisect run”,则必须在脚本中添加特殊代码来重新启动特殊分支中的 bisecting。

无论如何,从上面的特殊分支示例可以看出,Z' 和 Z 提交应该指向相同的源代码状态(在 Git 中称为相同的“tree”)。这是因为 Z' 是通过应用与 Z 相同的更改得到的,只是顺序略有不同。

因此,如果我们在 bisecting 时将 Z 替换为 Z',那么我们就无需在脚本中添加任何内容。它可以适用于共享这些特殊分支和替换的项目中的任何成员。

以上面的示例为例,将得到

      (BBC+BFC)-X1'-X2'-X3'-X4'-X5'-X6'-Z'-...
     /
...-Y-BBC-X1-X2-X3-X4-X5-X6-BFC-Z

这就是“git replace”命令被创建的原因。从技术上讲,它将替换“refs”存储在“refs/replace/”层次结构中。这些“refs”类似于分支(存储在“refs/heads/”中)或标签(存储在“refs/tags/”中),这意味着它们可以像分支或标签一样自动在开发者之间共享。

“git replace”是一个非常强大的机制。它可以用于修复已发布历史中的提交,例如更改提交消息或作者。它还可以用于代替 git “grafts”来将一个仓库与另一个旧的仓库链接起来。

实际上,正是最后一个功能说服了 Git 社区,因此它现在已经包含在 Git 的 Git 仓库的“master”分支中,并将在 2009 年 10 月或 11 月的 Git 1.6.5 版本中发布。

“git replace”的一个问题是,它目前将所有替换 refs 存储在“refs/replace/”中,但可能将仅用于 bisecting 的替换 refs 存储在“refs/replace/bisect/”中会更好。这样,替换 refs 就可以只用于 bisecting,而“refs/replace/”中的其他 refs 可以几乎一直被使用。

对偶发 bug 进行 bisecting

“git bisect”另一个可能的改进是,可以选择在执行的测试中添加一些冗余,这样在跟踪偶发 bug 时会更加可靠。

一些内核开发者已经提出了这个请求,因为一些被称为偶发 bug 的 bug 不会出现在所有内核构建中,因为它们对编译器输出非常依赖。

想法是,例如每 3 次测试,“git bisect”都会要求用户测试一个已经被判定为“好”或“坏”的提交(因为它的某个后代或某个祖先已经被判定为“好”或“坏”。如果发生之前错误分类的提交,则可以提前中止 bisecting,希望在没有太多错误的情况下中止。然后,用户需要查看发生了什么情况,然后使用一个固定的 bisect 日志重新启动 bisecting。

Ealdwulf Wuffinga 在 Github 上创建了一个名为 BBChop 的项目,它使用贝叶斯搜索理论来实现类似的功能 [9]

BBChop 类似于git bisect(或等效工具),但适用于 bug 间歇出现的情况。也就是说,它适用于存在假阴性情况(即使版本中包含 bug,但这次碰巧正常工作)。它假设不存在假阳性(原则上,相同的方法也能奏效,但添加它可能不简单)。

但 BBChop 独立于任何 VCS,而 Git 用户更希望在 Git 中集成类似的功能。

结论

我们已经看到,回归是一个重要的问题,“git bisect”具有很多很棒的功能,可以很好地补充用来对抗回归的实践和其他工具,尤其是测试套件。但是,可能需要改变一些工作流程和(不好的)习惯才能充分利用它。

“git bisect”内部的算法可以进行一些改进,一些新功能可以帮助解决某些问题,但总的来说,“git bisect”已经运行良好,被广泛使用,并且非常有用。为了支持最后一点,让我们听听 Ingo Molnar 的最终评价,当作者问他使用“git bisect”节省了他多少时间时,他这样说

很多。

大约十年前,我第一次对 Linux 修补程序队列进行了二分查找。那是在 Git(甚至 BitKeeper)出现之前。我花了几天时间对补丁进行排序,创建本质上是独立的提交,这些提交据我估计与该错误相关。

这是一种绝对的最后手段。我宁愿花几天时间查看 printk 输出,也不愿进行手动补丁二分查找

有了 Git bisect,这变得轻而易举:在最佳情况下,我可以在 20-30 分钟内完成一个大约 15 步的内核二分查找,以自动化方式完成。即使在手动帮助或对多个重叠的错误进行二分查找时,也很少超过一个小时。

事实上,它非常宝贵,因为如果没有 git bisect,我甚至不会尝试调试一些错误。过去,有些错误模式对我来说是无法调试的,我只能将崩溃/错误签名发送到 lkml,并希望其他人能想出解决办法。

即使二分查找今天失败了,它也能告诉我们关于错误的宝贵信息:它是非确定性的 - 与时间或内核映像布局相关。

所以 git bisect 是绝对的好东西 - 随意引用这句话 ;-)

致谢

非常感谢 Junio Hamano 帮助审查本文,审查我发送到 Git 邮件列表的补丁,讨论一些想法并帮助我改进它们,极大地改进“git bisect”以及他在维护和开发 Git 方面的出色工作。

非常感谢 Ingo Molnar 提供了本文中出现的非常有用的信息,评论了本文,为改进“git bisect”提供了建议,并在 Linux 内核邮件列表中宣传“git bisect”。

非常感谢 Linus Torvalds 发明、开发和宣传“git bisect”、“Git”和“Linux”。

非常感谢其他许多帮助过我的人,特别感谢 Andreas Ericsson、Johannes Schindelin、H. Peter Anvin、Daniel Barkalow、Bill Lear、John Hawley、Shawn O. Pierce、Jeff King、Sam Vilain、Jon Seymour。

非常感谢 Linux-Kongress 项目委员会选择作者发表演讲并发表本文。

scroll-to-top