補丁回溯與衝突解決¶
- 作者:
Vegard Nossum <vegard.nossum@oracle.com>
引言¶
有些開發者在日常工作中可能從未真正處理過補丁回溯、分支合併或衝突解決,因此當合並衝突突然出現時,可能會令人望而生畏。幸運的是,解決衝突和其他技能一樣,並且有許多有用的技術可以使過程更順暢,並增加您對結果的信心。
本文旨在成為一份關於補丁回溯和衝突解決的全面、分步指南。
將補丁應用於樹¶
有時,您要回溯的補丁已經作為 git 提交存在,在這種情況下,您只需使用 git cherry-pick 直接將其揀選即可。但是,如果補丁來自電子郵件(Linux 核心經常如此),您將需要使用 git am 將其應用於樹。
如果您曾使用過 git am,您可能已經知道它對補丁是否能完美應用於您的原始碼樹非常挑剔。事實上,您可能曾夢到 .rej 檔案並試圖編輯補丁以使其應用。
強烈建議您改為尋找一個能幹淨應用補丁的合適基礎版本,然後將其揀選到您的目標樹,因為這將使 git 輸出衝突標記,並讓您在 git 和任何其他您可能喜歡的衝突解決工具的幫助下解決衝突。例如,如果您想將 LKML 上剛收到的補丁應用於較舊的 stable 核心,您可以將其應用於最新的主線核心,然後將其揀選到您的舊 stable 分支。
通常最好使用與生成補丁完全相同的基礎版本,但只要它能幹淨地應用並且與原始基礎版本相距不遠,這實際上並不重要。將補丁應用於“錯誤”基礎的唯一問題是,在揀選到舊分支時,它可能會在差異上下文中引入更多不相關的更改。
選擇 git cherry-pick 而非 git am 的一個重要原因是,git 知道現有提交的精確歷史,因此它會知道程式碼何時移動和更改了行號;這反過來又降低了將補丁應用到錯誤位置的可能性(這可能導致無聲的錯誤或混亂的衝突)。
如果您正在使用 b4,並且您直接從電子郵件應用補丁,您可以使用 b4 am 並帶上選項 -g/--guess-base 和 -3/--prep-3way 來自動完成其中一些操作(更多資訊請參見 b4 演示)。然而,本文的其餘部分將假定您正在進行普通的 git cherry-pick 操作。
一旦您在 git 中有了補丁,您就可以繼續將其揀選到您的原始碼樹中。如果您希望有一個書面記錄來記錄補丁的來源,別忘了使用 -x 進行揀選!
請注意,如果您要為 stable 分支提交補丁,格式略有不同;主題行後的第一行需要是:
commit <upstream commit> upstream
或者
[ Upstream commit <upstream commit> ]
解決衝突¶
噢不;揀選失敗,並出現了一個模糊的威脅性訊息:
CONFLICT (content): Merge conflict
現在該怎麼辦?
一般來說,當補丁的上下文(即被更改的行和/或圍繞更改的行)與您嘗試應用補丁的樹中的內容不匹配時,就會出現衝突。
對於回溯補丁,可能發生的情況是您回溯來源的分支包含了一些您回溯到的分支中沒有的補丁。然而,反過來也可能發生。無論如何,結果都是需要解決的衝突。
如果您嘗試的揀選因衝突而失敗,git 會自動編輯檔案以包含所謂的衝突標記,向您展示衝突的位置以及兩個分支如何發生了分歧。解決衝突通常意味著以一種考慮這些其他提交的方式來編輯最終結果。
解決衝突可以透過在常規文字編輯器中手動完成,也可以使用專用的衝突解決工具。
許多人更喜歡使用他們的常規文字編輯器直接編輯衝突,因為這樣可能更容易理解您正在做什麼並控制最終結果。兩種方法各有利弊,有時兩者結合使用也很有價值。
除了提供一些您可能使用的各種工具的指引外,我們在此不會涵蓋專用合併工具的使用:
要配置 git 以便與這些工具配合使用,請參閱 git mergetool --help 或官方的 git-mergetool 文件。
前置補丁¶
大多數衝突的發生,是因為您回溯到的分支,相較於您回溯自的分支,缺少了一些補丁。在更一般的情況下(例如合併兩個獨立分支),開發可能發生在任一分支上,或者分支只是簡單地分叉了——也許您的舊分支應用了一些其他回溯補丁,這些補丁本身也需要衝突解決,導致了分歧。
識別導致衝突的一個或多個提交始終很重要,否則您無法對解決方案的正確性充滿信心。作為額外的好處,特別是當補丁在您不熟悉的領域時,這些提交的變更日誌通常會為您提供理解程式碼以及解決衝突時潛在問題或陷阱的上下文。
git log¶
一個好的第一步是檢視衝突檔案的 git log — 當檔案沒有太多補丁時,這通常就足夠了,但如果檔案很大並且經常打補丁,可能會讓人感到困惑。您應該在當前檢出的分支(HEAD)和您正在揀選的補丁的父提交(<commit>)之間的提交範圍上執行 git log,即:
git log HEAD..<commit>^ -- <path>
更好的是,如果您想將此輸出限制到單個函式(因為衝突出現在那裡),您可以使用以下語法:
git log -L:'\<function\>':<path> HEAD..<commit>^
注意
函式名周圍的 \< 和 \> 確保匹配是錨定在單詞邊界上的。這很重要,因為這部分實際上是一個正則表示式,git 只會跟隨第一個匹配項,所以如果您使用 -L:thread_stack:kernel/fork.c,它可能只為您提供函式 try_release_thread_stack_to_cache 的結果,儘管該檔案中還有許多其他函式包含字串 thread_stack 在它們的名稱中。
另一個有用的 git log 選項是 -G,它允許您根據列表中提交的差異中出現的特定字串進行過濾:
git log -G'regex' HEAD..<commit>^ -- <path>
這也可以是一種方便快捷的方式,可以快速查詢某些內容(例如函式呼叫或變數)何時被更改、新增或刪除。搜尋字串是一個正則表示式,這意味著您可能可以搜尋更具體的內容,例如對特定結構成員的賦值:
git log -G'\->index\>.*='
git blame¶
查詢前置提交的另一種方法(儘管只適用於給定衝突的最新提交)是執行 git blame。在這種情況下,您需要針對您正在揀選的補丁的父提交以及發生衝突的檔案執行它,即:
git blame <commit>^ -- <path>
此命令也接受 -L 引數(用於將輸出限制到單個函式),但在這種情況下,您像往常一樣在命令末尾指定檔名:
git blame -L:'\<function\>' <commit>^ -- <path>
導航到衝突發生的地方。blame 輸出的第一列是新增給定程式碼行的補丁的提交 ID。
最好 git show 這些提交,看看它們是否像是衝突的來源。有時會有多個這樣的提交,這可能是因為多個提交更改了同一衝突區域的不同行,或者是因為多個後續補丁多次更改了同一行(或多行)。在後一種情況下,您可能需要再次執行 git blame 並指定要檢視的檔案的舊版本,以便更深入地追溯檔案的歷史。
前置補丁與偶然補丁¶
找到導致衝突的補丁後,您需要確定它是否是您正在回溯的補丁的前提條件,或者它只是偶然的,可以跳過。偶然補丁是指與您正在回溯的補丁接觸相同的程式碼,但不會以任何實質性方式改變程式碼語義的補丁。例如,一個空白清理補丁是完全偶然的——同樣,一個僅僅重新命名函式或變數的補丁也可能是偶然的。另一方面,如果被更改的函式在您當前的分支中甚至不存在,那麼這根本不是偶然的,您需要仔細考慮是否應該首先揀選新增該函式的補丁。
如果您發現存在必要的先決補丁,那麼您需要停止並揀選該補丁。如果您已經解決了不同檔案中的一些衝突,並且不想再做一遍,您可以建立該檔案的臨時副本。
要中止當前的揀選,請執行 git cherry-pick --abort,然後使用先決補丁的提交 ID 重新開始揀選過程。
理解衝突標記¶
合併差異(Combined diffs)¶
假設您已決定不揀選(或恢復)額外的補丁,只想解決衝突。Git 會在您的檔案中插入衝突標記。開箱即用,這看起來會像這樣:
<<<<<<< HEAD
this is what's in your current tree before cherry-picking
=======
this is what the patch wants it to be after cherry-picking
>>>>>>> <commit>... title
如果您在編輯器中開啟檔案,您會看到這樣的內容。但是,如果您不帶任何引數執行 git diff,輸出將看起來像這樣:
$ git diff
[...]
++<<<<<<<< HEAD
+this is what's in your current tree before cherry-picking
++========
+ this is what the patch wants it to be after cherry-picking
++>>>>>>>> <commit>... title
當您解決衝突時,git diff 的行為與正常行為不同。注意,這裡是兩列差異標記,而不是通常的一列;這是一種所謂的“合併差異(combined diff)”,這裡顯示的是三方差異(或差異的差異)在以下兩者之間:
當前分支(揀選前)和當前工作目錄,以及
當前分支(揀選前)和應用原始補丁後的檔案外觀。
更好的差異(Better diffs)¶
三方合併差異包括在您的當前分支和您正在揀選的分支之間對檔案發生的所有其他更改。雖然這對於發現您需要考慮的其他更改很有用,但這也使得 git diff 的輸出有些令人望而生畏且難以閱讀。您可能更喜歡執行 git diff HEAD(或 git diff --ours),它只顯示當前分支在揀選之前與當前工作目錄之間的差異。它看起來像這樣:
$ git diff HEAD
[...]
+<<<<<<<< HEAD
this is what's in your current tree before cherry-picking
+========
+this is what the patch wants it to be after cherry-picking
+>>>>>>>> <commit>... title
如您所見,這讀起來就像任何其他差異一樣,並清楚地表明瞭哪些行在當前分支中,以及哪些行是由於合併衝突或正在揀選的補丁而新增的。
合併風格與 diff3¶
上面顯示的預設衝突標記樣式稱為 merge 樣式。還有另一種可用的樣式,稱為 diff3 樣式,它看起來像這樣:
<<<<<<< HEAD
this is what is in your current tree before cherry-picking
||||||| parent of <commit> (title)
this is what the patch expected to find there
=======
this is what the patch wants it to be after being applied
>>>>>>> <commit> (title)
如您所見,這有 3 個部分而不是 2 個,幷包含了 git 預期會找到但未找到的內容。強烈建議使用這種衝突樣式,因為它能更清楚地顯示補丁實際更改了什麼;即,它允許您比較您正在揀選的提交的檔案修改前後版本。這使您能夠對如何解決衝突做出更好的決策。
要更改衝突標記樣式,您可以使用以下命令:
git config merge.conflictStyle diff3
還有第三個選項,zdiff3,在 Git 2.35 中引入,它具有與 diff3 相同的 3 個部分,但共同的行已被刪除,在某些情況下使衝突區域更小。
迭代解決衝突¶
任何衝突解決過程的第一步是理解您正在回溯的補丁。對於 Linux 核心來說,這尤為重要,因為不正確的更改可能導致整個系統崩潰——或者更糟,導致未被檢測到的安全漏洞。
理解補丁的難易程度取決於補丁本身、變更日誌以及您對被更改程式碼的熟悉程度。然而,對於每次更改(或補丁的每個程式碼塊),一個很好的問題可能是:“為什麼這個程式碼塊會出現在補丁中?”這些問題的答案將指導您的衝突解決。
解決過程¶
有時最簡單的方法是隻保留衝突的第一部分,使檔案基本保持不變,然後手動應用更改。也許補丁正在將函式呼叫引數從 0 更改為 1,而一個衝突的更改在引數列表的末尾添加了一個全新的(且不重要的)引數;在這種情況下,手動將引數從 0 更改為 1,並保留其他引數就足夠簡單了。這種手動應用更改的技術主要在衝突引入了大量您不需要關心的不相關上下文時有用。
對於帶有許多衝突標記的特別棘手的衝突,您可以使用 git add 或 git add -i 來選擇性地暫存您的解決方案,以將其移開;這還允許您使用 git diff HEAD 始終檢視還有哪些需要解決,或使用 git diff --cached 檢視您的補丁目前看起來如何。
處理檔案重新命名¶
在回溯補丁時,最令人惱火的事情之一是發現其中一個被修補的檔案已被重新命名,因為這通常意味著 git 甚至不會放置衝突標記,而只是攤手說(意譯): “未合併的路徑!你自己搞定吧……”
通常有幾種方法可以解決這個問題。如果重新命名檔案的補丁很小,比如只有一行更改,最簡單的辦法就是直接手動應用更改並完成。另一方面,如果更改很大或很複雜,您肯定不想手動操作。
作為第一步,您可以嘗試這樣做,它會將重新命名檢測閾值降低到 30%(預設情況下,git 使用 50%,這意味著兩個檔案需要至少有 50% 的共同點,它才會將新增-刪除對視為潛在的重新命名):
git cherry-pick -strategy=recursive -Xrename-threshold=30
有時,正確的做法是也回溯執行重新命名的補丁,但這肯定不是最常見的情況。相反,您可以做的是臨時重新命名您正在回溯到的分支中的檔案(使用 git mv 並提交結果),重新嘗試揀選補丁,再將檔案重新命名回來(再次使用 git mv 並再次提交),最後使用 git rebase -i(參見 rebase 教程)將結果壓縮,這樣在您完成時它顯示為一個單一提交。
陷阱¶
函式引數¶
注意改變函式引數!很容易忽略細節,認為兩行相同,但實際上它們在一些小細節上有所不同,比如傳遞的變數是哪個(特別是如果這兩個變數都是一個看起來相同的單字元,比如 i 和 j)。
錯誤處理¶
如果您揀選的補丁包含 goto 語句(通常用於錯誤處理),那麼絕對有必要仔細檢查目標標籤在您回溯到的分支中是否仍然正確。對於新增的 return、break 和 continue 語句也是如此。
錯誤處理通常位於函式的底部,因此即使它可能已被其他補丁更改,它也可能不屬於衝突。
確保您檢查錯誤路徑的一個好方法是始終在檢查您的更改時使用 git diff -W 和 git show -W(即 --function-context)。對於 C 程式碼,這會顯示補丁中被更改的整個函式。在回溯過程中經常出錯的一件事是,函式中的其他內容在您回溯的源分支或目標分支上發生了變化。透過在差異中包含整個函式,您可以獲得更多上下文,並且更容易發現否則可能被忽視的問題。
重構的程式碼¶
經常發生的情況是,透過將常見的程式碼序列或模式“提取”到輔助函式中來重構程式碼。當回溯補丁到發生此類重構的區域時,您實際上需要在回溯時進行反向操作:對單個位置的補丁可能需要應用於回溯版本中的多個位置。(這種情況的一個線索是函式被重新命名了——但這並非總是如此。)
為避免不完整的反向移植,值得嘗試弄清楚該補丁是否修復了在多個地方出現的錯誤。一種方法是使用 git grep。(這實際上通常是個好主意,不只針對反向移植。)如果您確實發現在上游樹中存在相同型別的修復會適用於其他地方,那麼也值得看看這些地方是否存在於上游——如果不存在,則補丁可能需要調整。git log 是您的朋友,可以幫助您弄清楚這些區域發生了什麼,因為 git blame 不會顯示已刪除的程式碼。
如果您在上游樹中確實發現了相同模式的其他例項,並且不確定它是否也是一個錯誤,那麼值得詢問補丁作者。在回溯過程中發現新 bug 並不少見!
驗證結果¶
colordiff¶
提交無衝突的新補丁後,您現在可以將您的補丁與原始補丁進行比較。強烈建議您使用 colordiff 等工具,它可以並排顯示兩個檔案,並根據它們之間的更改進行著色:
colordiff -yw -W 200 <(git diff -W <upstream commit>^-) <(git diff -W HEAD^-) | less -SR
這裡,-y 表示進行並排比較;-w 忽略空白,-W 200 設定輸出寬度(否則預設使用 130,這通常有點太小)。
這裡的 rev^- 語法是 rev^..rev 的一個方便的簡寫,本質上只為您提供該單個提交的差異;另請參閱官方的 git rev-parse 文件。
再次注意 git diff 中包含 -W;這確保您將看到任何已更改函式的完整函式。
colordiff 做的一件極其重要的事情是突出顯示不同的行。例如,如果原始補丁和回溯補丁之間的錯誤處理 goto 標籤發生了變化,colordiff 會將它們並排顯示,但用不同的顏色突出顯示。因此,很容易看出這兩個 goto 語句正在跳轉到不同的標籤。同樣,未被任何補丁修改但在上下文中不同的行也會被突出顯示,從而在手動檢查時顯得突出。
當然,這只是視覺檢查;真正的測試是構建並執行打過補丁的核心(或程式)。
構建測試¶
我們在此不涵蓋執行時測試,但作為快速的健全性檢查,構建只被補丁觸及的檔案是個好主意。對於 Linux 核心,如果您正確設定了 .config 和構建環境,您可以像這樣構建單個檔案:
make path/to/file.o
請注意,這不會發現連結器錯誤,因此在驗證單個檔案編譯後,您仍然應該進行完整構建。透過首先編譯單個檔案,您可以避免在更改的任何檔案存在編譯器錯誤時等待完整構建。
執行時測試¶
即使成功的構建或啟動測試也不一定足以排除某處缺少依賴。儘管機率很小,但可能存在程式碼更改,其中對同一檔案的兩個獨立更改導致沒有衝突、沒有編譯時錯誤,並且僅在特殊情況下才出現執行時錯誤。
一個具體的例子是對系統呼叫入口程式碼的一對補丁,其中第一個補丁儲存/恢復一個暫存器,而稍後的補丁在該序列的中間某個地方使用了相同的暫存器。由於更改之間沒有重疊,所以可以揀選第二個補丁,沒有衝突,並認為一切正常,而事實上程式碼現在正在覆蓋一個未儲存的暫存器。
儘管絕大多數錯誤將在編譯期間或透過表面地執行程式碼時被捕獲,但真正驗證回溯補丁的唯一方法是像對待任何其他補丁一樣,以相同程度的嚴格性審查最終補丁。擁有單元測試、迴歸測試或其他型別的自動化測試有助於增加對回溯補丁正確性的信心。
提交回溯補丁到 stable 分支¶
由於 stable 維護者會嘗試將主線修復揀選到他們的 stable 核心上,當遇到衝突時,他們可能會發送電子郵件請求回溯補丁,例如參見 <https://lore.kernel.org/stable/2023101528-jawed-shelving-071a@gregkh/>。這些電子郵件通常會包含您將補丁揀選到正確分支並提交補丁所需的精確步驟。
需要確保的一點是,您的變更日誌符合預期的格式:
<original patch title>
[ Upstream commit <mainline rev> ]
<rest of the original changelog>
[ <summary of the conflicts and their resolutions> ]
Signed-off-by: <your name and email>
“Upstream commit”一行有時會因 stable 版本而略有不同。舊版本使用這種格式:
commit <mainline rev> upstream.
最常見的是在電子郵件主題行中指明補丁適用的核心版本(例如使用 git send-email --subject-prefix='PATCH 6.1.y'),但您也可以將其放在 Signed-off-by: 區域或 --- 行下方。
stable 維護者期望為每個活躍的 stable 版本單獨提交,並且每個提交也應該單獨進行測試。
一些最後的建議¶
以謙遜的態度對待回溯過程。
理解您正在回溯的補丁;這意味著閱讀變更日誌和程式碼。
提交補丁時,對結果的信心要誠實。
向相關維護者請求明確的確認(acks)。
示例¶
上述內容大致展示了回溯補丁的理想化過程。如需更具體的示例,請參閱此影片教程,其中展示了將兩個補丁從主線回溯到 stable 分支的過程:回溯 Linux 核心補丁。