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%。在國家層面,超過一半的成本由軟體使用者承擔,其餘則由軟體開發人員/供應商承擔。該研究還發現,雖然並非所有錯誤都可以消除,但透過改進的測試基礎設施,可以在早期更有效率地識別和消除軟體缺陷,從而消除超過三分之一的成本,或估計為 222 億美元。這些是在它們被引入的開發階段更接近的找到較高百分比(但不是 100%)錯誤所帶來的節省。目前,超過一半的錯誤直到「下游」開發流程或銷售後軟體使用期間才被發現。

然後

軟體開發人員已經將大約 80% 的開發成本花在識別和糾正缺陷上,但除了軟體以外,很少有任何類型的產品在出廠時存在如此高水準的錯誤。

最終的結論始於

提高軟體品質的方法是大幅改進軟體測試。

還有其他估計指出,軟體相關成本的 80% 與維護有關 [2]

不過,根據維基百科 [3]

人們對維護的普遍認知是它只是修正錯誤。然而,多年來的研究和調查顯示,大部分(超過 80%)的維護工作用於非修正措施 (Pigosky 1997)。這種認知是由於使用者提交的問題報告實際上是系統的功能增強而持續存在的。

但我們可以猜測,改進現有軟體的成本非常高昂,因為您必須注意回歸錯誤。至少這會使上述研究彼此之間保持一致。

當然,某些軟體會先開發,然後使用一段時間而沒有進行太多改進,然後最終被丟棄。在這種情況下,回歸錯誤當然可能不是一個大問題。但另一方面,有很多大型軟體是由很多人持續開發和維護多年甚至數十年。而且,由於通常有許多人(有時是關鍵地)依賴此類軟體,因此回歸錯誤是一個非常大的問題。

其中一個這樣的軟體是 Linux 核心。如果我們查看 Linux 核心,我們可以看到為了對抗回歸錯誤,花費了大量的時間和精力。發布週期從為期 2 週的合併窗口開始。然後標記第一個候選發布 (rc) 版本。在那之後,大約還會出現 7 或 8 個以上的 rc 版本,每個版本之間約間隔一週,然後才是最終發布。

第一個 rc 發布和最終發布之間的時間應該用於測試 rc 版本,並對抗錯誤,尤其是回歸錯誤。而且這個時間超過了發布週期時間的 80%。但這還不是戰鬥的結束,因為當然它會在發布後繼續進行。

然後,這是有名的 Linux 核心開發人員 Ingo Molnar 對於他如何使用 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 都是可以解析為 commit 的名稱。

接著,「git bisect」會取出它所選擇的 commit,並要求使用者進行測試,如下所示:

$ 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」版本號的 commit,也就是在頂層 Makefile 中具有「SUBLEVEL = 26」行的 commit。這是一個玩具範例,因為使用 Git 找到此 commit 的方法比使用「git bisect」更好(例如,「git blame」或「git log -S<字串>」)。

手動驅動二分搜尋

目前基本上有兩種方式可以驅動搜尋。它可以由使用者手動驅動,也可以由腳本或命令自動驅動。

如果由使用者驅動,則在搜尋的每個步驟中,使用者都必須測試當前 commit,並使用上面描述的「git bisect good」或「git bisect bad」命令,分別說明它是「good」還是「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」最終會找到第一個壞的 commit。

$ 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

此時,我們可以查看 commit 的內容、將其取出(如果尚未取出)或對其進行修改,例如:

$ 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」回到我們開始二分搜尋之前所在的 branch。

$ 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」在每個二分搜尋步驟中啟動腳本或命令,以了解當前的 commit 是「good」還是「bad」。為此,我們使用「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 會將當前狀態標記為「good」。如果它以程式碼 1 退出(或任何介於 1 到 127 之間的程式碼,包括 1 和 127,除了特殊程式碼 125),則當前狀態將標記為「bad」。

介於 128 和 255 之間的退出程式碼對「git bisect run」來說是特殊的。它們會立即停止二分搜尋過程。如果傳遞的命令執行時間太長,這會很有用,因為您可以使用信號終止它,並且它會停止二分搜尋過程。

如果在傳遞給「git bisect run」的腳本中偵測到一些非常不正常的情況,也可以使用「exit 255」。

避免無法測試的 commit

有時會發生無法測試目前狀態的情況,例如,如果它由於當時存在阻止它的錯誤而無法編譯。這就是特殊退出程式碼 125 的用途。它告訴「git bisect run」應該將目前的 commit 標記為無法測試,並應該選擇和取出另一個 commit。

如果二分搜尋過程是手動驅動的,您可以使用「git bisect skip」來執行相同的操作。(事實上,特殊退出程式碼 125 會讓「git bisect run」在後台使用「git bisect skip」。)

或者,如果您想要更多控制權,可以使用例如「git bisect visualize」來檢查目前狀態。它將啟動 gitk(如果未設定 DISPLAY 環境變數,則啟動「git log」),以協助您找到更好的二分搜尋點。

無論如何,如果您有一連串無法測試的 commit,可能會發生您正在尋找的迴歸是由這些無法測試的 commit 之一引入的情況。在這種情況下,無法確定哪個 commit 引入了迴歸。

因此,如果您使用「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 commit 形成有向非循環圖 (DAG),因此在每個步驟中找到要測試的最佳二分搜尋 commit 並不簡單。無論如何,Linus 發現並實作了一種「非常愚蠢」的演算法,後來由 Junio Hamano 進行了改進,效果很好。

因此,「git bisect」用來在沒有跳過的 commit 時找到最佳二分搜尋 commit 的演算法如下:

1) 只保留以下 commit:

a) 是「bad」commit 的祖先(包括「bad」commit 本身),b) 不是「good」commit 的祖先(不包括「good」commit)。

這表示我們擺脫了 DAG 中不感興趣的 commit。

例如,如果我們從如下圖形開始:

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 是「bad」commit,「G」是「good」commit,W、X 和 Y 是其他 commit,則在第一個步驟之後,我們將得到如下圖形:

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

因此,只會保留 W 和 B commit。因為 commit X 和 Y 將分別被規則 a) 和 b) 移除,並且因為 commit G 也會被規則 b) 移除。

請注意,對於 Git 使用者來說,它等效於僅保留由以下提供的 commit:

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

另請注意,我們不要求保留的 commit 是「good」commit 的後代。因此,在以下範例中,將會保留 commit W 和 Z:

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

2) 從圖形的「good」端開始,將每個 commit 的祖先數量加一。

例如,使用以下圖形,其中 H 是「bad」commit,A 和 D 是某些「good」commit 的某些父節點:

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) 將以下值與每個 commit 相關聯:min(X, N - X)

其中 X 是在步驟 2) 中與 commit 關聯的值,N 是圖形中的 commit 總數。

在上述範例中,我們有 N = 8,因此這將產生:

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

4) 最佳二分搜尋點是具有最高關聯數字的 commit。

因此,在上述範例中,最佳二分搜尋點是 commit C。

5) 請注意,已實作一些捷徑來加速演算法。

由於我們從一開始就知道 N,因此我們知道 min(X, N - X) 不可能大於 N/2。因此,在步驟 2) 和 3) 期間,如果我們將 N/2 與 commit 關聯,則我們知道這是最佳二分搜尋點。因此,在這種情況下,我們只需停止處理任何其他 commit 並傳回目前的 commit 即可。

二分搜尋演算法除錯

對於任何 commit 圖形,您可以使用「git rev-list --bisect-all」查看與每個 commit 相關聯的數字。

例如,對於上述圖形,類似的命令:

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

二分搜尋演算法討論

首先,讓我們定義「最佳二分搜尋點」。我們將說,如果知道 commit X 的狀態(「good」或「bad」),則如果 commit 的狀態恰好是「good」或「bad」,則 commit X 是最佳二分搜尋點或最佳二分搜尋 commit,並儘可能多地提供資訊。

這表示最佳二分搜尋 commit 是以下函數為最大的 commit:

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

其中 information_if_good(X) 是如果 X 是 good,我們獲得的資訊,而 information_if_bad(X) 是如果 X 是 bad,我們獲得的資訊。

現在我們假設只有一個「第一個壞 commit」。這表示它的所有後代都是「bad」,而所有其他 commit 都是「good」。我們將假設所有 commit 具有相同的機率是 good 或 bad,或是第一個壞 commit,因此無論這些 c commit 在圖形上的何處以及 c 是什麼,知道 c 個 commit 的狀態始終會提供相同數量的資訊。(因此,我們假設這些 commit 例如在分支上或靠近 good 或 bad commit 並不會提供更多或更少的資訊)。

我們也假設我們有一個清理過的圖形,例如上面二分搜尋演算法中步驟 1) 之後的圖形。這表示我們可以根據可以從圖形中移除的 commit 數量來測量我們獲得的資訊。

讓我們取圖形中的 commit X。

如果發現 X 是「good」,則我們知道它的祖先都是「good」,因此我們想說:

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

這是正確的,因為在步驟 1) b) 中,我們移除了「good」commit 的祖先。

如果發現 X 是「bad」,則我們知道它的後代都是「bad」,因此我們想說:

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

但是這是錯誤的,因為在步驟 1) a) 中,我們只保留了 bad commit 的祖先。因此,當 commit 標記為「bad」時,我們會獲得更多資訊,因為我們也知道先前的「bad」commit 中不是新「bad」commit 祖先的那些 commit 不是第一個壞 commit。我們不知道它們是 good 還是 bad,但是我們知道它們不是第一個壞 commit,因為它們不是新「bad」commit 的祖先。

因此,當 commit 標記為「bad」時,我們知道我們可以移除圖形中的所有 commit,除了那些是新「bad」commit 祖先的 commit。這表示:

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

其中 N 是(清理過的)圖形中的 commit 數量。

因此,最後,這表示為了找到最佳二分搜尋 commit,我們應該最大化函數:

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 是 bad,那麼我們不僅知道 L、M 和 N 是 bad,而且 G、H、I 和 J 也不是第一個壞 commit(因為我們假設只有一個第一個壞 commit,並且它必須是 L 的祖先)。

因此,目前的演算法似乎是在我們最初假設的情況下最佳的演算法。

跳過演算法

當某些 commit 已被跳過(使用「git bisect skip」)時,二分搜尋演算法對於步驟 1) 到 3) 是相同的。但是接下來,我們大致使用以下步驟:

6) 按關聯值的遞減順序對 commit 進行排序

7) 如果第一個 commit 未被跳過,我們可以傳回它並在此處停止

8) 否則,篩選掉排序列表中所有跳過的 commit

9) 使用偽亂數產生器 (PRNG) 來產生介於 0 和 1 之間的亂數

10) 將此亂數與其平方根相乘,使其偏向 0

11) 將結果乘以篩選列表中的 commit 數量,以取得此列表中的索引

12) 傳回計算索引處的 commit

跳過演算法討論

在步驟 7)(在跳過演算法中)之後,我們可以檢查第二個 commit 是否已跳過,如果不是,則傳回它。事實上,這是在 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。所以那將是錯誤的!

是的,在實務中確實會發生,在一個分支上工作的人不知道在另一個分支上工作的人修復了一個錯誤!也可能發生 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 小時(根據我們有點奇怪的錯誤追蹤器,它只測量實際經過的時間)。自從我們改用 Git 以來,我們已將其降低到 16.2 小時。主要是因為我們現在可以掌握錯誤修復的情況,而且因為每個人都爭先恐後地修復錯誤(我們很自豪我們很懶惰,讓 Git 為我們找到錯誤)。每個新版本都會減少約 40% 的錯誤(幾乎可以肯定是歸因於我們現在對編寫測試的看法)。

顯然,這個工作流程使用了測試套件和「git bisect」之間的良性循環。事實上,它使處理迴歸成為標準程序。

在其他訊息中,Andreas 說他們也使用了上面描述的「最佳實務」:小的邏輯提交、主題分支、沒有邪惡合併…​ 這些實務都提高了提交圖的可二分性,方法是使其更容易且更有用進行二分。

因此,一個好的工作流程應該圍繞以上幾點進行設計。也就是說,使二分更容易、更有用且成為標準。

讓品管人員參與,如果可能的話,也讓最終使用者參與

關於「git bisect」的一個好處是,它不僅僅是一個開發人員工具。品管人員甚至最終使用者也可以有效地使用它(如果他們可以存取原始碼,或者如果他們可以存取所有建置)。

曾經在 Linux 核心郵件論壇上討論過,是否總是要求終端使用者進行二分搜尋 (bisect) 是合理的。當時提出許多很好的觀點來支持這個觀點,認為這樣做是合理的。

例如,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 之前開始。此分支中的第一個提交應該是 BBC,並將 BFC 壓縮到其中。並且分支中的其他提交應該是 BBC 和 BFC 之間的提交,重新基於分支的第一個提交,然後 BFC 之後的提交也重新基於第一個提交。

例如

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

其中用 ' 引用的提交已經被重新基底 (rebased)。

您可以使用 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/heads/" 中)或標籤(儲存在 "refs/tags/" 中),這表示它們可以像分支或標籤一樣在開發人員之間自動共享。

"git replace" 是一種非常強大的機制。它可以用於修復已經發佈的歷史中的提交,例如變更提交訊息或作者。它也可以用來代替 git "grafts" 來將儲存庫與另一個舊儲存庫連結。

事實上,正是最後一個功能「說服」了 Git 社群,因此它現在位於 Git 的 Git 儲存庫的「master」分支中,並且應於 2009 年 10 月或 11 月在 Git 1.6.5 中發布。

"git replace" 的一個問題是,目前它將所有替換引用儲存在 "refs/replace/" 中,但如果僅對二分搜尋有用的替換引用位於 "refs/replace/bisect/" 中可能會更好。這樣,替換引用只能用於二分搜尋,而 "refs/replace/" 中的其他引用幾乎會一直使用。

二分搜尋零星錯誤

"git bisect" 的另一個可能的改進是選擇性地為執行的測試添加一些冗餘,以便在追蹤零星錯誤時更可靠。

一些核心開發人員已要求這樣做,因為一些稱為零星錯誤的錯誤並非出現在所有核心版本中,因為它們非常依賴編譯器輸出。

這個想法是,例如每 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" 在他使用時為他節省了多少時間時,讓我們將最後的發言權交給他

很多

大約十年前,我第一次對 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。

非常感謝在我使用 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