Git 🌙
简体中文 ▾ Topics ▾ Latest version ▾ git-bisect-lk2009 last updated in 2.40.0

摘要

"Git二分查找"使软件用户和开发者能够轻松找到引入回退的提交。我们展示了为什么拥有好的工具来解决回退是很重要的。我们描述了 "Git二分查找 "从外部如何工作,以及它在内部使用的算法。然后我们解释如何利用 "Git二分查找"来改进当前的体验。我们还讨论了 "Git二分查找"在未来可以如何改进。

"git bisect" 简介

Git是一个分布式版本控制系统(DVCS),由Linus Torvalds(一般指 林纳斯·本纳第克特·托瓦兹;Linux之父)创建,由Junio Hamano( Junio C Hamano;滨野 纯)维护。

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

事实上,人们对那些引入 "坏 "行为的提交特别感兴趣,这些行为被称为错误或回归。他们之所以对这些提交感兴趣,是因为一个提交(希望)只包含一组非常小的源代码改动。当你只需要检查很小的改动时,理解和正确修复一个问题要比你不知道从哪里找问题容易得多。

所以为了帮助人们找到引入 "坏 "行为的提交,发明了 "Git二分查找"命令集。当然,在 "Git二分查找"的说法中,存在 "有趣行为 "的提交被称为 "坏 "提交,而其他提交被称为 "好 "提交。而引入我们感兴趣的行为的提交被称为 "第一个坏提交"。请注意,在我们搜索的提交空间中,可能存在不止一个 "第一个坏提交"。

因此,"Git二分查找"被设计用来帮助找到 "第一个坏提交"。为了尽可能的高效,它试图进行二分查找。

解决回退概述

回退:一个大问题

回退是软件行业的一个大问题。但是很难用数字来说明这个问题。

有一些关于一般bug的数字,比如2002年NIST的一项研究[1]写道:

根据美国商务部国家标准与技术研究所(NIST)新近发布的一项研究,软件缺陷或错误是非常普遍且是灾难性的,以至于它们每年给美国经济造成约595亿美元的损失,或约占国内生产总值的0.6%。在全国范围内,超过一半的成本由软件用户承担,其余的由软件开发商/供应商承担。 该研究还发现,尽管所有的错误都无法消除,但通过改进测试基础架构,能够更早、更有效地识别和消除软件缺陷,可以消除这些成本的三分之一以上,即估计为222亿美元。这些都是在更接近错误发生的开发阶段发现更高比例(但不是100%)的错误所带来的节约。目前,超过一半的错误是在开发过程的 "下游 "或售后软件使用期间才被发现的。

然后:

软件开发人员已经将大约80%的开发成本用于识别和纠正缺陷,然而,除了软件之外,很少有其他类型的产品在出厂时出现如此高的错误。

最终结论是:

改善软件测试是通往更高的软件质量的道路最为有效的方式。

还有人估计说,与软件有关的成本中有80%是关于维护的[2]

尽管,根据维基百科[3]的说法:

人们对维护工作的普遍看法是,它仅仅是在修复错误。然而,多年来的研究和调查表明,大部分(超过80%)的维护工作都是用于非纠正性的行动(Pigosky 1997)。用户提交的问题报告实际上是对系统功能的提升,这使得这种看法得以延续。

但我们可以猜测,在现有软件上进行改进的代价是非常昂贵的,因为你必须注意功能退步的问题。至少这可以使上述研究之间保持一致。

当然,有些软件被开发出来,然后在一段时间内使用中,没有得到很大的改进,最后被废弃。当然,在这种情况下,退步可能不是一个大问题。但另一方面,有很多大型软件是由很多人在几年甚至几十几年的时间里不断开发和维护的。由于经常有许多人依赖这种软件(有时是关键性的),所以回退是一个真正的大问题。

Linux内核就是这样一个软件。如果我们看一下Linux内核,我们可以看到花了大量的时间和精力来对抗退步。发布周期从2周的合并窗口开始。然后,第一个候选版本(rc)被标记。在那之后,在最终发布之前,大约还有7到8个rc版本会出现,每个版本之间相隔一周左右。

从第一个rc版本到最终版本之间的时间应该是用来测试rc版本和解决bug,特别是回退问题。而这段时间占到了发布周期的80%以上。发布并不意味着战斗结束,因为bug在发布后还会继续出现。

然后这是Ingo Molnar(一位知名的Linux内核开发者)对他正在使用的Git二分查找的评价:

我在合并窗口期最积极地使用它(当大量的树被合并到上游时,也是bug涌入最多的时候),有一些情况下,我一天要多次使用。我的平均使用频率大约是每天一次。

因此,开发人员一直在与回退作斗争,事实上,众所周知,错误应该被尽快修复,所以一旦发现就应该尽快修复。这就是为什么有好的工具来达到这个目的是很有趣的。

解决回退的其他工具

那么,用于解决回退的工具是什么呢?它们几乎与那些用来对付常规bug的工具相同。唯一特殊的工具是测试套件和类似于 "Git二分查找"的工具。

测试套件是非常好的。但当它们被单独使用时,它们应该被用来在每次提交后检查所有的测试。这意味着它们的效率并不高,因为许多测试的运行并没有意外的结果,而且它们会受到组合的影响。

事实上,问题在于大型软件通常有许多不同的配置选项,每次提交后,每个测试用例都应该通过每个配置。因此,如果你对每个版本有N个配置,M个提交,T个测试用例,你应该执行:

N * M * T 个测试

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

因此,很快就不可能对所有的东西进行测试。

而如果有些bug在你的测试套件中溜走了,那么你可以在你的测试套件中增加一个测试。但是,如果你想用你新改进的测试套件来找到bug溜进去的地方,那么你将不得不模仿一分为二的过程,或者你也许会直截了当地从你的 "坏 "提交开始向后测试每个提交,这可能是非常浪费的。

"git bisect" 概览

开始二分

第一个要使用的 "Git二分查找"子命令是 "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 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 <torvalds@linux-foundation.org>
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 <torvalds@linux-foundation.org>
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

 # *文档*

当我们完成后,我们可以使用 "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 <torvalds@linux-foundation.org>
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之间的任何代码,除了特殊代码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

-> 走这条路 ->

其中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)

讨论二分算法

首先让我们定义“最佳二分点”。如果知道一个提交的状态(“好”或“坏”)能尽可能多地提供提交状态是“好”还是“坏”的信息,我们就称它为最佳二分点或最佳二分提交。

这意味着最好的二分提交是以下函数最大的提交:

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二分查找算法,我们得到:

            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 "的要求。

当然,"坏 "提交不可能是 "好 "提交的祖先,因为好提交的祖先应该是 "好 "的。而所有 "好 "的提交必须与坏的提交有关。 它们不可能在一个与 "坏 "提交的分支没有联系的分支上。但是,一个好的提交有可能与一个坏的提交有关系,但既不是它的祖先,也不是它的后代。

例如,可以有一个 "main"分支和一个 "dev"分支,后者是从main分支中提交名为 "D"的提交克隆出来的,如下所示:

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

提交 "D "被称为 "main "和 "dev "分支的 "合并基础",因为它是这些分支可以进行合并的最佳共同祖先。

现在我们假设提交J是坏的,提交G是好的,并且我们像之前描述的那样应用二分法。

正如一分为二算法的步骤1)b)所描述的那样,我们移除所有好提交的祖先,因为他们也应该是好的。

因此,我们将只剩下:

H-I-J

但如果第一个坏的提交是 "B",而在 "main"分支中已经被提交 "F "修复,会发生什么?

这样一分为二后,我们会发现H是第一个坏提交,但实际上第一个坏提交是B,所以这将造成错误的结果!

在实践中可能会发生这样的情况:在一个分支上工作的人不知道在另一个分支上工作的人修复了一个bug!也可能发生的情况是,F修复了不止一个bug,或者是对一些尚未准备好发布的大型开发工作进行了恢复。

事实上,开发团队经常同时维护一个开发分支和一个维护分支,如果 "git bisect "只是在他们想对开发分支上的回归进行分流而不在维护分支上的时候发挥作用,那对他们来说会很容易。他们应该可以用以下方法开始二分:

$ git bisect start dev main

为了实现这个额外的功能,当一个分界开始,一些好的提交不是坏的提交的祖先时,我们首先计算坏的提交和好的提交之间的合并基数,我们选择这些合并基数作为第一个将被检查和测试的提交。

如果有一个合并基数是坏的,那么二分过程就会停止,并发出类似的信息:

合并基础BBBBB是坏的。
这意味着BBBBB和[GGGGG,...]之间的错误已经被修复。

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

如果一些合并基点被跳过,那么二分过程将继续进行,但对于每一个被跳过的合并基点都会打印出以下信息:

警告:BBBBB和[GGGGG,...]之间的合并基数必须被跳过。
所以我们不能确定第一个坏提交是在MMMMMM和BBBBB之间。
我们会继续操作。

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

因此,如果没有坏的合并基础,在这一步之后,二分过程照常进行。

最佳的二分做法

测试套件和git bisect一起使用

如果你有一个测试套件并使用git bisect,那么在每次提交后检查所有测试是否通过就变得不那么重要。当然,为了避免破坏太多的东西,有一些检查可能是个好的做法,因为这可能会使二分其他bug更加困难。

你可以集中精力在几个点上检查(例如rc和beta版本),所有的T测试用例在所有的N个配置下都能通过。当一些测试没有通过时,你可以使用 "git bisect"(当然"git bisect run"更好)。所以你应该大致上执行:

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

其中c是测试的轮数(所以是一个小常数),b是每次提交的bug比例(希望也是一个小常数)。

所以,如果你在每次提交后都测试所有内容,O(N * T)当然比O(N * T * M)要好得多 。

这意味着测试套件对于防止一些bug被提交是很好的,它们对于告诉你有一些bug也是很好的。但它们并不能很好地告诉你一些错误是在哪里被引入的。所以需要git bisect告诉你这些信息。

测试套件的另一个好处是,当你有一个测试套件时,你已经知道如何测试不良行为。因此,当出现类似问题时,你可以使用这些知识来创建一个新的 "git bisect "测试案例。这样就能更容易地将bug二分并修复它。然后你就可以把你刚创建的测试用例添加到你的测试套件中。

因此,如果你知道如何创建测试用例和二分,你就会受到良性循环的影响:

更多的测试 ⇒ 更容易创建测试 ⇒ 更容易二分 ⇒ 更多的测试

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

对构建失败的二分

你可以非常容易地使用类似的东西来自动划分破碎的构建:

$ git bisect start BAD GOOD
$ git bisect run make

将sh -c "一些命令 "传递给 "git bisect运行"

例如:

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

另一方面,如果你经常这样做,那么就值得用脚本来避免过多的输入。

寻找性能回归

这里有一个例子,它是由Junio Hamano使用真实案例的脚本稍加修改而成的[4]

这个脚本可以传递给 "git bisect run",以找到引入性能回归的提交:

#!/bin/sh

# 我并不关心构建中的错误。
make my_app || exit 255

# 我们要检查它是否在合理的时间内停止,所以
# 让它在后台运行...

./my_app >log 2>&1 &

# ... 并抓取其进程ID。
pid=$!

# ... 然后等待足够长的时间。
sleep $NORMAL_TIME

# ... 然后看看这个进程是否还在那里。
if kill -0 $pid
then
	# 它仍然在运行 -- 有点失望。
	kill $pid; sleep 1; kill $pid;
	exit 1
else
	# 它已经结束了($pid进程不再存在),
	# 皆大欢喜。
	exit 0
fi

遵循一般的最佳做法

显然,不要在提交时明知故犯是最好的,即使后来有其他的提交修复了错误。

在使用任何VCS时,最好是每次提交只有一个小的逻辑变化。

你的提交中的改动越小,"git bisect "就越有效。并且你可能一开始就不太需要 "git bisect",因为即使只有提交者审查,小改动也更容易审查。

另一个好的办法是要有一个明确的提交信息。它们对于理解为什么要做一些改动是非常有帮助的。

如果你经常进行二分操作,一般这些最佳实践是非常有帮助的。

避免容易出错的合并

即便合并不需要解决源代码冲突,但一次合并本身就会带来一些回归问题。这是因为语义变化可能发生在一个分支,而另一个分支并不知道该变化。

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

如果为了解决冲突而不得不修复许多文件,情况就会变得更糟。这就是为什么这种合并被称为 "邪恶合并"。它们会使回归变得非常难以追踪。如果恰好合并了一个这样的坏提交,就很可能会产生误会,因为人们可能会认为这个错误来自于糟糕的冲突解决,但事实上它来自于一个分支的语义变化。

无论如何,"git rebase "可以用来线性化历史。这也可以用来一开始就避免错误的合并。或者,它可以用来在线性历史上进行二分,而不是在非线性历史上进行二分,因为这在一个分支发生语义变化的情况下会提供更多信息。

通过使用较小的分支或使用许多主题分支而不是只使用一个非常长的分支,也可以使合并更简单。

而测试可以在特殊的集成分支中更频繁地进行,比如linux-next的linux内核。

调整你的工作流程

一个处理回归的特殊工作流程可以带来好的结果。

比如,以下是Andreas Ericsson使用的一个工作流程:

  • 在测试套件中,写一个测试脚本,暴露出回归的问题

  • 使用 "git bisect run" 查找引入它的提交

  • 修复由上一步骤暴露出来的错误

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

这里是Andreas对这个工作流程的评价[5]

我们曾经有142.6小时的平均报告到修复周期(由我们只是测量时钟时间的错误跟踪器统计出)。自从我们转移到Git后,我们已经把这个时间降低到了16.2小时。主要是因为我们现在可以保持在最开始进行错误修复,也因为每个人都在争先恐后地修复错误(我们对自己懒得让Git为我们找到错误感到很自豪)。每个新的版本都会减少40%的bug(出于我们现在对编写测试的感觉,这个结果非常可信)。

显然,这个工作流程使用了测试套件和 "git bisect "之间的良性循环。事实上,也正是因此它才成为为处理回归的标准程序。

在其他信息中,Andreas说他们也使用了上述的 "最佳实践":小的逻辑提交,主题分支,没有邪恶的合并,…​…​。这些做法都提高了提交图的可分性,使之更容易、更有用。

因此,一个好的工作流程应该围绕以上几点来设计。这就是让分界线变得更容易、更有用和更标准。

让QA人员参与进来,如果可能的话,让终端用户参与进来

关于 "git bisect "的一个好处是,它不仅仅是一个开发者工具。它可以有效地被QA人员甚至终端用户使用(如果他们能够访问源代码或者能够获得所有构建的权限)。

在linux内核邮件列表中曾一度讨论过总是要求终端用户进行分割是否可行,并提出了非常好的观点来支持可以这样做的观点。

例如,David Miller写道[6]

人们不明白的是,这是一个适用 "终端节点原则 "的情况。当你拥有有限的资源时(这里是指开发人员),你不会把大部分的负担推给他们。相反,你要把事情推给你拥有的大量资源,即终端节点(这里:用户),这样情况才会有实际的扩展。

这意味着,如果QA人员或终端用户能够做到这一点,往往会 "更便宜"。

有趣的是,报告错误的终端用户(或重现错误的QA人员)可以进入发生错误的环境。所以他们通常可以更容易地重现一个回归。如果他们能进行二分法,那么就可以从发生错误的环境中提取更多的信息,这意味着将更容易理解,然后修复错误。

对于开源项目来说,这可能是一个很好的方法,可以从终端用户那里获得更多有用的贡献,并将他们引入QA和开发活动。

使用复杂脚本

在某些情况下,如内核开发,值得开发复杂的脚本,以便能够完全自动化地进行二分。

以下是Ingo Molnar对此的评价[7]

我有一个完全自动化启动并挂起的二分脚本。它是基于 "git-bisect run "的。我运行这个脚本,它完全自动地构建和启动内核,当启动失败时(脚本通过连续观察的串行日志注意到这一点或者是通过超时判断,如果系统在10分钟内没有启动,就是一个 "坏 "内核),脚本通过嘟嘟声引起我的注意,我就给测试机断电。(的确,我应该使用一个可管理的电源插座来实现100%的自动化)

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

我们已经看到,测试套件和git bisect一起使用时已经非常强大了。如果你能把它们和其他系统结合起来,那将更加强大。

例如,一些测试套件可以在晚上以一些不寻常(甚至是随机)的配置自动运行。如果测试套件发现了一个回归问题,那么 "git bisect "可以自动启动,其结果可以通过电子邮件发给 "git bisect"发现的第一个坏提交的作者,也许还有其他人。并且在错误跟踪系统中自动创建一个新条目。

未来的二分算法

"git replace"

之前,我们发现,"git bisect skip"目前使用PRNG来试图避免提交图中不可测试的区域。但问题是,有时第一个坏的提交恰恰在一个无法测试的区域。

为了简化这个话题,我们将假设不可测试区是一串简单的提交,它是由一个提交(我们称它为BBC,意为双断裂提交)引起的断裂,后来被另一个提交修复(我们称它为BFC,意为双断裂修复提交)。

例如:

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

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

在这种情况下,如果你是手动二分,你可以在BBC开始之前创建一个特殊的分支。这个分支的第一次提交应该是将 BFC 与BBC压缩到一起的。分支中的其他提交应该是在BBC和BFC之间的提交,以该分支的第一次提交为基础,然后再以BFC之后的提交为基础。

例如:

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

其中以引号 ' 括起来的的提交已被变基。

你能够很轻松地用Git的交互式变基来创建这样的分支。

例如:

$ git rebase -i Y Z

然后BFC移动到BBC之后并压缩。

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

例如:

$ 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",你必须在你的脚本中添加特殊的代码,以重新启动特殊分支的二分流程。

总之,我们可以注意到,在上述特殊分支的例子中,Z’和Z的提交应该指向相同的源代码状态(用git术语说是相同的 "树")。这是因为Z’的结果是应用了与Z相同的修改,只是顺序略有不同。

因此,如果我们可以在二分时用Z’来 "替换"Z,那么我们就不需要在脚本中添加任何东西。它将对项目中任何共享特殊和替换分支的人起作用。

通过上面的例子,可以看出:

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

这就是"git replace"命令出现的原因。技术层面,它在 "refs/replace/"目录中存储替换的 "refs"。这些 "ref "就像分支(存储在 "refs/heads/"中)或标签(存储在 "refs/tags "中),所以它们可以像分支或标签一样自动地在开发者之间共享。

"git replace"拥有非常强大的机制。它可以用来修复发布历史中的提交,例如改变提交的信息或作者。它也可以用来代替git "grafts",将一个仓库与另一个旧仓库连接起来。

事实上,正是这最后一个功能把它"卖"给了Git社区,所以它目前在Git仓库的"master"分支中,应该是在2009年10月或11月的Git 1.6.5版本中发布的。

"git replace"存在的一个问题是,目前它把所有替换的引用都存放在 "refs/replace/"中,但如果把只对二分有用的替换引用放在 "refs/replace/bisect/"中,也许会更好。这样一来,替换的引用就可以只用于二分,而直接存储在 "refs/replace/"中的其他引用则几乎一直在使用。

查找零星的漏洞

对 "git bisect"的另一个可能的改进是,在所进行的测试中选择性地增加一些冗余,这样在跟踪零星的bug时就会更可靠。

这是由一些内核开发者提出的要求,因为一些被称为零星漏洞的漏洞不会出现在所有的内核构建过程中,因为发现它们非常依赖于编译器输出。

我们的想法是,每3次测试,"git bisect"可以要求用户测试一个已经被标记为 "好 "或 "坏 "的提交(因为它的一个后代或一个祖先已经被视为 "好 "或 "坏"提交)。如果错误地分类了一个提交,那么可以在酿成大祸之前中止这个过程。接着,用户将不得不查看发生了什么,最后使用固定的日志重新启动二分操作。

目前,Github上已经有一个由Ealdwulf Wuffinga创建的名为BBChop的项目,其使用贝叶斯搜索理论做了类似的事情[9]

BBChop就像’git bisect'(或就是),但当存在间歇性漏洞的时候,它就会起作用。也就是说,它在存在假阴性的情况下也能工作(尽管这个版本包含了这个漏洞,但也可以运行)。它假定不存在假阳性(原则上,同样的方法也可以,但这可能不容易)。

但BBChop是独立于任何VCS的,并且对于Git用户来说,在Git中集成一些东西非常容易。

结论

我们已经了解到,回归是一个重要的问题,"git bisect"有强大的功能,同时也可以很好地补充和应用到其他工具中,尤其是通常被用来解决回归问题测试套件。但也需要改变一些工作流程和改正坏习惯,以获得它的最大效益。

"git bisect"内部的算法可以进行改进,并且新的功能在特殊情况下也会有帮助,但总的来说,"git bisect "已经被大量使用并且非常优秀。Ingo Molnar的发言是最后一个说法的有力支撑,作者问他认为使用 "git bisect"能够节省他多少时间:

a lot.

大约十年前,我第一次对Linux补丁队列进行了 "二分算法"。那是在Git(甚至在BitKeeper)出现之前。我真的花了好几天时间来整理补丁,创建了一些我猜测与该错误有关的独立提交。

这是一个万不得已的情况下使用的工具。我宁愿花几天时间看printk输出,也不愿意进行手动 的"补丁分割"。

使用Git bisect很容易:在最好的情况下,我可以在20-30分钟内以自动化的方式完成约15个步骤的内核分割。即使需要人工帮助,或者在对多个重叠的bug进行分割时,也很少超过一个小时。

事实上,它是非常有价值的,因为如果没有git bisect,有些漏洞我甚至不会尝试去调试。在过去,有些漏洞对我来说基本就没有希望进行调试——我最多只能把崩溃/漏洞签名发给lkml,希望别人能想到一些东西。

即使是在运行失败的情况下,它也会告诉我们关于这个漏洞一些有价值的东西:它是非决定性的——与时间或内核图像布局有关。

因此,git bisect的善良不求回报——请随意引用这句话;-)

致谢

非常感谢Junio Hamano在审阅本文时提供的帮助,感谢他审阅我发给Git邮件列表的补丁,感谢他讨论一些想法并帮助我改进,感谢他对 "git bisect"的大量改进,感谢他在维护和开发Git方面的出色工作。

非常感谢Ingo Molnar给本文提供非常有用的信息,感谢他对本文的评论,感谢他对 "git bisect"的改进建议,感谢他在linux内核邮件列表中对"git bisect"的宣传。

非常感谢Linus Torvalds对 "git bisect"、Git和Linux的发明、开发和宣传。

非常感谢其他许多在我从事Git工作时以这种或那种方式提供帮助的伟大人物,特别是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