Git 的遴選和撤銷操作是如何利用三路合併的
大家好!幾天前,我嘗試向其他人解釋 Git 遴選(git cherry-pick
)的工作原理,結果發現自己反而更混淆了。
我原先以為 Git 遴選是簡單地應用一個補丁,但當我真正這樣嘗試時,卻未能成功!
因此,接下來我們將談論我原來以為的遴選操作(即應用一個補丁),這個理解為何不準確,以及實際上它是如何執行的(進行「三路合併」)。
儘管本文的內容有些深入,但你並不需要全部理解才能有效地使用 Git。不過,如果你(和我一樣)對 Git 的內部運作感到好奇,那就跟我一起深入探討一下吧!
遴選操作並不只是應用一個補丁
我先前理解的 git cherry-pick COMMIT_ID
的步驟如下:
- 首先是計算
COMMIT_ID
的差異,就如同執行git show COMMIT_ID --patch > out.patch
這個命令 - 然後是將補丁應用到當前分支,就如同執行
git apply out.patch
這個命令
在我們詳細討論之前,我想指出的是,雖然大部分情況下這個模型是正確的,如果這是你的認知模型,那就沒有問題。但是在一些細微的地方,它可能會錯,我覺得這個疑惑挺有意思的,所以我們來看看它究竟是如何運作的。
如果我在存在合併衝突的情況下嘗試進行「計算差異並應用補丁」的操作,下面我們就看看具體會發生什麼情況:
$ git show 10e96e46 --patch > out.patch
$ git apply out.patch
error: patch failed: content/post/2023-07-28-why-is-dns-still-hard-to-learn-.markdown:17
error: content/post/2023-07-28-why-is-dns-still-hard-to-learn-.markdown: patch does not apply
這一過程無法成功完成,它並未提供任何解決衝突或處理問題的方案。
而真正運行 git cherry-pick
時的實際情況卻大為不同,我遭遇到了一處合併衝突:
$ git cherry-pick 10e96e46
error: could not apply 10e96e46... wip
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git cherry-pick --continue".
因此,看起來 「Git 正在應用一個補丁」這樣的理解方式並不十分準確。但這裡的錯誤信息確實標明了 「無法應用 10e96e46」,這麼看來,這種理解又不完全是錯的。這到底是怎麼回事呢?
那麼,遴選到底是怎麼執行的呢?
我深入研究了 Git 的源代碼,主要是想了解 cherry-pick
是如何工作的,最終我找到了 這一行代碼:
res = do_recursive_merge(r, base, next, base_label, next_label, &head, &msgbuf, opts);
所以,遴選實際上就是一種……合併操作?這有些出乎意料。那具體都合併了什麼內容?如何執行這個合併操作的呢?
我意識到我對 Git 的合併操作並不是特別理解,於是我上網搜索了一下。結果發現 Git 實際上採用了一種被稱為 「三路合併」 的合併方式。那這到底是什麼含義呢?
Git 的合併策略:三路合併
假設我要合併下面兩個文件,我們將其分別命名為 v1.py
和 v2.py
。
def greet():
greeting = "hello"
name = "julia"
return greeting + " " + name
def say_hello():
greeting = "hello"
name = "aanya"
return greeting + " " + name
在這兩個文件間,存在兩處不同:
def greet()
和def say_hello
name = "julia"
和name = "aanya"
我們應該選擇哪個呢?看起來好像不可能有答案!
不過,如果我告訴你,原始的函數(我們稱之為 base.py
)是這樣的:
def say_hello():
greeting = "hello"
name = "julia"
return greeting + " " + name
一切似乎變得清晰許多!在這個基礎上,v1
將函數的名字更改為 greet
,v2
將 name = "aanya"
。因此,合併時,我們應該同時做出這兩處改變:
def greet():
greeting = "hello"
name = "aanya"
return greeting + " " + name
我們可以命令 Git 使用 git merge-file
來完成這次合併,結果正是我們預期的:它選擇了 def greet()
和 name = "aanya"
。
$ git merge-file v1.py base.py v2.py -p
def greet():
greeting = "hello"
name = "aanya"
return greeting + " " + name⏎
這種將兩個文件與其原始版本進行合併的方式,被稱為 三路合併。
如果你想在線上試一試,我在 jvns.ca/3-way-merge/ 創建了一個小實驗場。不過我只是草草製作,所以可能對移動端並不友好。
Git 合併的是更改,而非文件
我對三路合併的理解是 —— Git 合併的是更改,而不是文件。我們對同一個文件做出兩種不同的更改,Git 試圖以合理的方式將這兩種更改結合到一起。當兩個更改都對同一行進行操作時,Git 可能會遇到困難,此時就會產生合併衝突。
Git 也可以合併超過兩處的更改:你可以對同一文件有多達 8 處不同的更改,Git 會嘗試將所有更改協調一致。這被稱為八爪魚合併,但除此之外我對其並不了解,因為我從未執行過這樣的操作。
Git 如何使用三路合併來應用補丁
接下來,讓我們進入到一個有些出乎意料的情境!當我們討論 Git 「應用補丁」(如在變基 —— rebase
、撤銷 —— revert
或遴選 —— cherry-pick
中所做的)時,其實並非是生成一個補丁文件並應用它。相反,實際執行的是一次三路合併。
下面是如何將提交 X
作為補丁應用到你當前的提交,並與之前的 v1
、v2
和 base
設置相對應:
- 在你當前提交中,文件的版本是
v1
。 - 在提交 X 之前,文件的版本是
base
。 - 在提交 X 中,文件的版本是
v2
。 - 執行
git merge-file v1 base v2
以合併它們(實際上,Git 並不直接執行git merge-file
,而是運行一個實現這個功能的 C 函數)。
總的來說,你可以將 base
和 v2
視為「補丁」,它們之間的差異就是你想要應用到 v1
上的更改。
遴選如何運作
假設我們有如下提交圖,並且我們打算在 main
分支上遴選提交 Y
:
A - B (main)
X - Y - Z
那麼,如何將此情景轉化為我們前面提過的 v1
、v2
和 base
組成的三路合併呢?
B
是v1
X
是base
,而Y
是v2
所以,X
和 Y
共同構成了這個「補丁」。
其實,git rebase
無非就是重複多次執行 git cherry-pick
的過程。
撤銷如何運作
現在,假如我們希望在如下的提交圖上執行 git revert Y
:
X - Y - Z - A - B
B
是v1
Y
是base
,而X
是v2
這個過程反映的實際上就是遴選的情況,不過 X
和 Y
的位置顛倒了。我們需要這樣做因為我們期望生成一個「反向補丁」。在 Git 中,撤銷和遴選關係如此的緊密,它們甚至在同一個文件中實現:revert.c。
「三路補丁」是一個非常棒的技巧
使用三路合併將提交作為補丁應用的這個技巧非常巧妙且酷炫,我很驚訝之前從未聽說過!我並未聽過一個特定的名字來描述這種方法,但我更傾向於稱之為「三路補丁」。
「三路補丁」的理念在於,你可以通過兩個文件來定義補丁:在應用補丁前後的文件(在我們這篇文章中稱之為 base
和 v2
)。
因此,總體來看有三個文件被涉及到:一個是原文件,另外兩個構成了補丁。
最重要的是,與普通補丁相比,三路補丁是一個更加高效的補丁方案,因為在有兩個完整文件的情況下,你擁有更豐富的上下文信息來進行合併。
以下是我們例子中的常規補丁的大致情況:
@@ -1,1 +1,1 @@:
- def greet():
+ def say_hello():
greeting = "hello"
而下面這就是一個三路補丁。不過,需要提醒的是這個「三路補丁」並不是一個真正的文件格式,這只是我自己提出的一種概念。
BEFORE: (the full file)
def greet():
greeting = "hello"
name = "julia"
return greeting + " " + name
AFTER: (the full file)
def say_hello():
greeting = "hello"
name = "julia"
return greeting + " " + name
《Building Git》 中提到了這點
James Coglan 的書籍 《Building Git》 是我在 Git 源碼之外唯一找到的地方,他解釋了 git cherry-pick
是如何在底層運用三路合併的(我原以為《Pro Git》可能會提及這個,但我並沒能找到此話題的內容)。
我購買完這本書後發現,我早在 2019 年時就已經買過了,這對我來說真的是個很好的參考。
Git 中的合併實際上比這更複雜
在 Git 中,合併不限於三路合併 —— 還有一種我不太理解的叫做「遞歸合併」,還有許多具體處理文件刪除和移動的細節,同時也有多種合併演算法。
如果想要了解更多相關知識,我最好的建議是閱讀《Building Git》,儘管我還未完全閱讀這本書。
Git 應用到底做了什麼?
我也參閱了 Git 的源代碼,試圖理解 git apply
的功能。它似乎(不出意外地)在 apply.c
中實現。這段代碼解析了一個補丁文件,並通入目標文件來尋找應該在何處應用補丁。核心邏輯似乎在 這裡:思路好像是從補丁建議的行數開始,然後向前向後找尋。
/*
* There's probably some smart way to do this, but I'll leave
* that to the smart and beautiful people. I'm simple and stupid.
*/
backwards = current;
backwards_lno = line;
forwards = current;
forwards_lno = line;
current_lno = line;
for (i = 0; ; i++) {
...
這個處理過程不禁讓人覺得非常直白、與之前的期望相符。
Git 三路應用的工作方式
git apply
命令中也有一個 --3way
參數,可以實現三路合併。因此,我們實際上可以通過如下方式,使用 git apply
來大體實現 git cherry-pick
的功能:
$ git show 10e96e46 --patch > out.patch
$ git apply out.patch --3way
Applied patch to 'content/post/2023-07-28-why-is-dns-still-hard-to-learn-.markdown' with conflicts.
U content/post/2023-07-28-why-is-dns-still-hard-to-learn-.markdown
但要注意,參數 --3way
並不只用到了補丁文件的內容!補丁文件開始的部分是:
index d63ade04..65778fc0 100644
d63ade04
和 65778fc0
是舊/新文件版本在 Git 對象資料庫中的 ID,因此 Git 可以用這些 ID 來執行三路補丁操作。但如果有人將補丁文件通過郵件發送給你,而你並沒有新/舊版本的文件,就無法執行這個操作:如果你缺少 blob,將會出現如下錯誤:
$ git apply out.patch
error: repository lacks the necessary blob to perform 3-way merge.
三路合併有點歷史了
有一部分人指出,三路合併比 Git 的歷史還要久遠,它起源於 70 年代末期左右。有一篇 2007 年的 論文 對此進行了討論。
就說這麼多!
我真的對於我對於 Git 內部應用補丁的核心方法其實理解得並不深入這一點感到非常吃驚——學習這一點真的很酷!
雖然我對 Git 用戶界面存在 諸多不滿,但是這個特定問題並不包含在內。三路合併似乎是統一解決一系列不同問題的優雅方式,它對於人們來說也很直觀(「應用一個補丁」這個想法是許多編程者都習以為常的思考模式,而它底層實現為三路合併的細節,實際上沒有人真正需要去思考)。
我順便快速推薦一下:我正在寫一部有關 Git 的 zine,如果你對它的發布感興趣,你可以註冊我非常不頻繁的 公告郵件列表。
(題圖:MJ/321bc2c9-4363-4661-802a-c74fb6a721b2)
via: https://jvns.ca/blog/2023/11/10/how-cherry-pick-and-revert-work/
作者:Julia Evans 選題:lujun9972 譯者:ChatGPT 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive