簡體中文 ▾ 主題 ▾ 最新版本 ▾ 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 主要用於管理軟體原始碼,有時軟體中的“有趣”行為更改會在某些提交中引入。

事實上,人們特別關注引入“壞”行為(稱為 bug 或迴歸)的提交。他們關注這些提交是因為一個提交(希望)包含非常小的原始碼更改集。當你只需要檢查一小組更改時,比你根本不知道從哪裡開始查詢要容易得多,可以理解並正確地修復一個問題。

因此,為了幫助人們找到引入“壞”行為的提交,“git bisect”命令集被髮明瞭出來。當然,在“git bisect”術語中,存在“有趣行為”的提交被稱為“壞”提交,而其他提交被稱為“好”提交。引入我們感興趣的行為的提交被稱為“首個壞提交”。請注意,在我們搜尋的提交空間中可能不止一個“首個壞提交”。

因此,“git bisect”旨在幫助找到“首個壞提交”。為了儘可能高效,它會嘗試執行二分查詢。

對抗迴歸概述

迴歸:一個大問題

迴歸是軟體行業的一個大問題。但很難為此主張提供真實的數字。

有一些關於 bug 的普遍數字,例如 2002 年的 NIST 研究 [1] 表明:

根據美國商務部國家標準與技術研究院 (NIST) 委託釋出的一項新研究,軟體 bug 或錯誤普遍存在且危害極大,據估計每年給美國經濟造成 595 億美元的損失,或約佔國內生產總值的 0.6%。在國家層面,超過一半的成本由軟體使用者承擔,其餘由軟體開發商/供應商承擔。該研究還發現,雖然無法消除所有錯誤,但透過改進的測試基礎設施,可以消除其中三分之一以上的成本,或估計 222 億美元,該基礎設施可以更早、更有效地識別和消除軟體缺陷。這些節省與在開發階段早期發現(但非 100%)的錯誤相關。目前,超過一半的錯誤要到開發過程的“下游”或銷售後的軟體使用階段才能找到。

然後

軟體開發人員已經花費了大約 80% 的開發成本用於識別和糾正缺陷,但除了軟體之外,很少有產品能以如此高的錯誤率交付。

最終的結論開始於

更高軟體質量的道路是顯著改進的軟體測試。

還有其他估計表明,與軟體相關的成本的 80% 是關於維護 [2]

然而,根據維基百科 [3]

維護的一個普遍看法是,它僅僅是修復 bug。然而,多年的研究和調查表明,大部分(超過 80%)的維護工作用於非糾正性操作 (Pigosky 1997)。這種看法是由使用者提交的實際上是系統功能增強的問題報告所延續的。

但我們可以猜測,改進現有軟體的成本非常高,因為你必須注意迴歸。至少這會讓上述研究保持一致。

當然,有些軟體在開發後會使用一段時間而不作太多改進,然後最終被丟棄。在這種情況下,迴歸可能不是一個大問題。但另一方面,有很多大型軟體由許多人開發和維護多年甚至幾十年。而且由於很多人(有時是關鍵地)依賴於此類軟體,迴歸是一個真正的大問題。

Linux 核心就是這樣一個軟體。如果我們看 Linux 核心,我們可以看到花費了大量的時間和精力來對抗迴歸。釋出週期從一個為期 2 周的合併視窗開始。然後標記第一個候選釋出 (rc) 版本。在此之後,大約還有 7 到 8 個 rc 版本會出現,每個版本之間大約間隔一週,然後才是最終釋出。

第一個 rc 版本釋出到最終釋出之間的時間應該用於測試 rc 版本並處理 bug,尤其是迴歸。這段時間佔釋出週期時間的 80% 以上。但這還不是戰鬥的終點,因為當然它會在釋出後繼續。

然後,這是 Ingo Molnar(一位著名的 Linux 核心開發者)關於他使用 git bisect 的看法

我在合併視窗期間(當大量樹被合併到上游,並且 bug 湧入最多時)最積極地使用它——是的,曾有幾次我一天使用它多次。我的平均使用頻率大約是每天一次。

所以開發人員一直在對抗迴歸,事實上,眾所周知 bug 應該儘早修復,一旦發現就立即修復。這就是為什麼擁有好的工具來達到這個目的很有意義。

其他對抗迴歸的工具

那麼,用來對抗迴歸的工具是什麼呢?它們幾乎與用來對抗常規 bug 的工具相同。唯一特定的工具是測試套件和類似於“git bisect”的工具。

測試套件非常好。但當它們單獨使用時,它們應該在每次提交後檢查所有測試。這意味著它們效率不高,因為許多測試的執行沒有有趣的結果,並且它們會遭受組合爆炸。

事實上,問題在於大型軟體通常有許多不同的配置選項,並且每個測試用例都應該在每次提交後透過每個配置。所以,如果你為每次釋出有:N 個配置,M 個提交和 T 個測試用例,你應該執行

N * M * T tests

其中 N、M 和 T 都會隨著你的軟體大小而增長。

所以很快將不可能完全測試所有內容。

如果某些 bug 繞過了你的測試套件,那麼你可以將一個測試新增到你的測試套件中。但是,如果你想使用你新改進的測試套件來查詢 bug slipped in 的地方,那麼你將不得不模擬一個二分過程,或者你可能會從你擁有的“壞”提交開始,逐個向後測試,這可能非常浪費。

“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 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

 # *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 <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”以檢測到非常異常的情況也可能很有用。

避免不可測試的提交

有時會發生當前狀態無法測試的情況,例如,如果它無法編譯,因為當時有一個 bug 阻止了它。這就是特殊退出程式碼 125 的用途。它告訴“git bisect run”當前提交應被標記為不可測試,並應選擇並檢出另一個提交。

如果二分過程是手動驅動的,你可以使用“git bisect skip”來做同樣的事情。(事實上,特殊退出程式碼 125 會讓“git bisect run”在後臺使用“git bisect skip”。)

或者,如果你想要更多控制,你可以使用例如“git bisect visualize”來檢查當前狀態。它將啟動 gitk(或者如果 DISPLAY 環境變數未設定,則啟動“git log”),以幫助你找到更好的二分點。

無論哪種方式,如果你有一系列不可測試的提交,可能會發生你正在尋找的迴歸是由其中一個不可測試的提交引入的。在這種情況下,無法確定是哪個提交引入了迴歸。

所以,如果你使用了“git bisect skip”(或 run 指令碼退出了特殊程式碼 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 是一個最佳二分點或最佳二分提交,如果知道它的狀態(“好”或“壞”)提供了儘可能多的資訊,無論它的狀態碰巧是“好”還是“壞”。

這意味著最佳二分提交是以下函式最大化的提交:

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

其中 information_if_good(X) 是我們如果 X 為好所獲得的資訊,information_if_bad(X) 是我們如果 X 為壞所獲得的資訊。

現在,我們將假設只有一個“首個壞提交”。這意味著它所有的後代都是“壞”的,而其他所有提交都是“好”的。我們還將假設所有提交是好或壞,或者它們是首個壞提交的機率都相等,因此知道 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”的要求。

當然,“壞”提交不可能是“好”提交的祖先,因為“好”提交的祖先被認為是“好”的。並且所有“好”提交都必須與壞提交相關。它們不能位於與壞提交分支沒有聯絡的分支上。但是,一個好提交可能與壞提交相關,同時既不是它的祖先也不是它的後代。

例如,可以有一個“main”分支,以及一個在名為“D”的提交處分叉的“dev”分支,如下所示:

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

為了實現這個額外的便利功能,當啟動二分查詢並且某些好提交不是壞提交的祖先時,我們首先計算壞提交和好提交之間的合併基,並將這些合併基作為要檢出和測試的第一個提交。

如果碰巧某個合併基是壞的,那麼二分查詢過程將以如下訊息停止:

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,那麼在每次提交後檢查所有測試是否透過的重要性就會降低。儘管當然,為了避免破壞太多東西而進行一些檢查可能是一個好主意,因為這可能會使二分查詢其他 bug 變得更加困難。

你可以將精力集中在少數幾個點(例如 rc 和 beta 釋出)檢查所有 T 個測試用例是否都透過所有 N 個配置。當某些測試不透過時,你可以使用“git bisect”(或者更好地使用“git bisect run”)。因此,你應該大約執行:

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

其中 c 是測試輪數(一個小的常數),b 是每個提交的 bug 率(希望也是一個小的常數)。

所以,當然這比 O(N * T * M) 的時間複雜度要好得多,如果你在每次提交後都會測試所有內容的話。

這意味著測試套件對於防止某些 bug 被提交以及告知你存在一些 bug 都非常有用。但它們在告訴你在哪裡引入了 bug 方面不是那麼好。為了有效地告訴你這一點,需要 git bisect。

測試套件的另一個優點是,當你擁有一個時,你已經知道如何測試壞行為。所以,當出現迴歸時,你可以利用這些知識來為“git bisect”建立一個新的測試用例。這樣,更容易二分查詢 bug 並修復它。然後,你可以將你剛剛建立的測試用例新增到你的測試套件中。

所以,如果你知道如何建立測試用例以及如何進行二分查詢,你將受益於一個良性迴圈:

更多測試 ⇒ 更易於建立測試 ⇒ 更易於二分查詢 ⇒ 更多測試

因此,測試套件和“git bisect”是互補的工具,當一起使用時,它們非常強大且高效。

二分查詢構建失敗

你可以非常輕鬆地使用類似以下命令自動二分查詢構建失敗:

$ git bisect start BAD GOOD
$ git bisect run make

將 sh -c "some commands" 傳遞給 "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”的次數會更少,因為小的更改更容易審查,即使它們只由提交者審查。

另一個好主意是編寫良好的提交訊息。它們可以非常有幫助,以便理解為什麼會進行某些更改。

這些一般最佳實踐對經常進行二分查詢很有幫助。

避免易出錯的合併

首先,即使合併不需要原始碼衝突的解決,合併本身也可能引入一些迴歸。這是因為一個分支中的語義更改可能發生在另一個分支沒有意識到的時候。

例如,一個分支可以更改函式的語義,而另一個分支會向同一個函式新增更多的呼叫。

如果需要修復許多檔案來解決衝突,情況會更糟。這就是為什麼這類合併被稱為“邪惡合併”。它們可能使迴歸非常難以追蹤。如果首個壞提交恰好是這樣的合併,甚至可能會誤導,因為人們可能會認為 bug 來自糟糕的衝突解決,而實際上它來自一個分支中的語義更改。

無論如何,“git rebase”可用於線性化歷史。這可以用於避免合併。或者,它可用於線上性歷史而不是非線性歷史進行二分查詢,因為在語義更改的情況下,這應該提供更多資訊。

使用更小的分支或使用多個主題分支而不是僅使用長的版本相關分支,也可以使合併變得更簡單。

並且可以在特殊的整合分支(如 Linux 核心的 linux-next)中更頻繁地進行測試。

調整你的工作流程

一個特殊的工作流程來處理迴歸可以產生很好的結果。

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

  • 在測試套件中編寫一個暴露迴歸的測試指令碼

  • 使用“git bisect run”查詢引入它的提交

  • 修復 bug(通常透過上一步驟變得明顯)

  • 提交修復和測試指令碼(如果需要,還可以提交更多測試)

這是 Andreas 關於這個工作流程所說的 [5]

給出一些具體數字,我們過去平均的報告到修復週期為 142.6 小時(根據我們有些奇怪的 bug 跟蹤器,它只測量實際時間)。自從我們遷移到 Git 後,我們將其降低到 16.2 小時。主要是因為我們現在可以及時修復 bug,而且每個人都在爭先恐後地修復 bug(我們很自豪我們能懶惰到讓 Git 為我們找到 bug)。每次新版本釋出都會導致 bug 減少約 40%(幾乎可以肯定是由於我們現在願意編寫測試)。

顯然,這個工作流程利用了測試套件和“git bisect”之間的良性迴圈。事實上,它使之成為處理迴歸的標準程式。

在其他訊息中,Andreas 說他們也使用了上述“最佳實踐”:小的邏輯提交、主題分支、沒有惡意的合併……​ 這些實踐都透過使提交圖更容易和更有用地進行二分查詢來改進其可二分性。

因此,良好的工作流程應該圍繞上述幾點進行設計。即使二分查詢更容易、更有用和標準化。

讓 QA 人員和可能的終端使用者參與進來

“git bisect”的一個優點是它不僅僅是一個開發人員工具。QA 人員甚至終端使用者(如果他們可以訪問原始碼或所有構建)也可以有效地使用它。

曾經在 Linux 核心郵件列表中有過一次討論,關於是否可以始終要求終端使用者進行二分查詢,並提出了非常好的論據來支援這種觀點的可行性。

例如 David Miller 寫道 [6]

人們不明白的是,這是一種“終端節點原則”適用的情況。當你的資源有限(在這裡是開發人員)時,你不會把大部分負擔壓在他們身上。相反,你會把事情推給那些你擁有大量資源的資源,即終端節點(在這裡是使用者),這樣情況實際上才具有可擴充套件性。

這意味著,如果 QA 人員或終端使用者能夠做到,通常會“更便宜”。

有趣的是,報告 bug 的終端使用者(或重現 bug 的 QA 人員)可以訪問 bug 發生的壞境。因此,他們通常可以更容易地重現迴歸。如果他們能夠進行二分查詢,那麼就可以從 bug 發生的壞境中提取更多資訊,這意味著更容易理解和修復 bug。

對於開源專案來說,這是一種從終端使用者那裡獲得更多有用貢獻,並將他們引入 QA 和開發活動的好方法。

使用複雜指令碼

在某些情況下,例如核心開發,開發複雜的指令碼來完全自動化二分查詢是值得的。

這是 Ingo Molnar 關於此的說法 [7]

我有一個完全自動化的啟動掛起二分查詢指令碼。它基於“git-bisect run”。我執行指令碼,它會自動構建和啟動核心,當啟動失敗時(指令碼透過連續監視的序列日誌或超時來檢測,如果系統在 10 分鐘內沒有啟動,則被認為是“壞”核心),指令碼會透過蜂鳴聲引起我的注意,然後我重啟測試框。(是的,我應該 100% 自動化使用受管理的電源插座)

結合測試套件、git bisect 和其他系統

我們已經看到,測試套件和 git bisect 一起使用時非常強大。如果能將它們與其他系統結合起來,其威力將更大。

例如,一些測試套件可以在夜間以一些不尋常的(甚至隨機的)配置自動執行。如果測試套件發現了一個迴歸,那麼可以自動啟動“git bisect”,並且其結果可以被電子郵件傳送給“git bisect”找到的第一個壞提交的作者,以及可能其他的相關人員。也可以自動建立一個新的 bug 跟蹤系統條目。

二分查詢的未來

“git replace”

我們之前看到,“git bisect skip”現在使用 PRNG 來嘗試避擴音交圖中的不可測試提交區域。問題是,有時第一個壞提交會出現在一個不可測試的區域。

為了簡化討論,我們將假設不可測試區域是一連串簡單的提交,並且它是由一個提交(我們稱之為 BBC,即 bisect breaking commit)引入的損壞,後來被另一個提交(我們稱之為 BFC,即 bisect fixing commit)修復。

例如

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

在這裡,我們知道 Y 是好的,BFC 是壞的,而 BBC 和 X1 到 X6 是不可測試的。

在這種情況下,如果你手動進行二分查詢,你可以建立一個特殊的、從 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 之後並將其合併。

之後,你可以在新分支中像往常一樣開始二分查詢,最終應該能找到第一個壞提交。

例如

$ 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 術語中稱為“tree”)。這是因為 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”儲存在“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/”中,但也許將僅用於二分查詢的替換 refs 儲存在“refs/replace/bisect/”中會更好。這樣,替換 refs 就可以僅用於二分查詢,而“refs/replace/”中的其他 refs 將幾乎一直被使用。

二分查詢偶發性 bug

對“git bisect”的另一個可能的改進是,可以選擇性地為執行的測試新增一些冗餘,以便在跟蹤偶發性 bug 時更加可靠。

一些核心開發人員提出了這個要求,因為一些被稱為偶發性 bug 的 bug 並不會出現在所有的核心構建中,因為它們非常依賴於編譯器輸出。

想法是,例如每 3 次測試,“git bisect”可以要求使用者測試一個已經確定為“好”或“壞”的提交(因為它的後代或祖先已經分別被確定為“好”或“壞”)。如果一個提交之前被錯誤地分類,那麼二分查詢可以提前中止,希望在犯下太多錯誤之前。然後使用者需要檢視發生了什麼,並使用修復的二分日誌重新啟動二分查詢。

GitHub 上已經有一個名為 BBChop 的專案,由 Ealdwulf Wuffinga 建立,它使用貝葉斯搜尋理論 [9] 來做類似的事情。

BBChop 類似於 *git bisect*(或同等工具),但適用於你的 bug 是間歇性時。也就是說,它在存在誤報(當一個版本碰巧工作,儘管它包含 bug)的情況下也能工作。它假定不存在漏報(原則上,相同的方法也有效,但新增它可能並不簡單)。

但是 BBChop 與任何 VCS 都無關,對於 Git 使用者來說,擁有一個整合到 Git 中的東西會更容易。

結論

我們已經看到迴歸是一個重要的問題,並且“git bisect”擁有很好的功能,能夠很好地補充實踐和其他工具(尤其是測試套件),這些工具通常用於對抗迴歸。但是,可能需要改變一些工作流程和(糟糕的)習慣才能充分發揮其作用。

對“git bisect”內部演算法的一些改進是可能的,一些新功能在某些情況下會有幫助,但總的來說,“git bisect”已經工作得很好,被廣泛使用,並且已經非常有用了。為了支援最後這一說法,讓我們聽聽 Ingo Molnar 的看法,當作者問他,他認為使用“git bisect”能為他節省多少時間時

很多

大約十年前,我第一次對 Linux 補丁佇列進行了二分查詢。那時還沒有 Git(甚至沒有 BitKeeper)。我花了好幾天時間整理補丁,建立本質上是獨立的提交,我猜測它們與那個 bug 相關。

那是一個最後的手段。我寧願花幾天時間檢視 printk 輸出,也不願手動進行補丁二分查詢

有了 Git bisect,這就像一陣風:在最好的情況下,我可以在 20-30 分鐘內完成一個大約 15 步的核心二分查詢,而且是自動化的。即使需要手動幫助或二分查詢多個重疊的 bug,也通常不超過一小時。

事實上,它是無價的,因為有些 bug 如果不是因為 git bisect,我根本不會嘗試去除錯。過去,有些 bug 的模式對我來說是立即無望的——充其量我只能將崩潰/bug 簽名傳送到 lkml,並希望別人能想到些什麼。

而且,即使今天二分查詢失敗,它也告訴我們關於 bug 的有價值資訊:它是非確定性的——依賴於時序或核心映象佈局。

所以 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 組委會選擇作者發表演講並出版本文。