Git 的遴選和撤銷操作是如何利用三路合并的
大家好!幾天前,我嘗試向其他人解釋 Git 遴選(git cherry-pick
)的工作原理,結(jié)果發(fā)現(xiàn)自己反而更混淆了。
我原先以為 Git 遴選是簡(jiǎn)單地應(yīng)用一個(gè)補(bǔ)丁,但當(dāng)我真正這樣嘗試時(shí),卻未能成功!
因此,接下來我們將談?wù)撐以瓉硪詾榈腻噙x操作(即應(yīng)用一個(gè)補(bǔ)?。@個(gè)理解為何不準(zhǔn)確,以及實(shí)際上它是如何執(zhí)行的(進(jìn)行“三路合并”)。
盡管本文的內(nèi)容有些深入,但你并不需要全部理解才能有效地使用 Git。不過,如果你(和我一樣)對(duì) Git 的內(nèi)部運(yùn)作感到好奇,那就跟我一起深入探討一下吧!
遴選操作并不只是應(yīng)用一個(gè)補(bǔ)丁
我先前理解的 git cherry-pick COMMIT_ID
的步驟如下:
- 首先是計(jì)算
COMMIT_ID
的差異,就如同執(zhí)行git show COMMIT_ID --patch > out.patch
這個(gè)命令 - 然后是將補(bǔ)丁應(yīng)用到當(dāng)前分支,就如同執(zhí)行
git apply out.patch
這個(gè)命令
在我們?cè)敿?xì)討論之前,我想指出的是,雖然大部分情況下這個(gè)模型是正確的,如果這是你的認(rèn)知模型,那就沒有問題。但是在一些細(xì)微的地方,它可能會(huì)錯(cuò),我覺得這個(gè)疑惑挺有意思的,所以我們來看看它究竟是如何運(yùn)作的。
如果我在存在合并沖突的情況下嘗試進(jìn)行“計(jì)算差異并應(yīng)用補(bǔ)丁”的操作,下面我們就看看具體會(huì)發(fā)生什么情況:
$ 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
這一過程無法成功完成,它并未提供任何解決沖突或處理問題的方案。
而真正運(yùn)行 git cherry-pick
時(shí)的實(shí)際情況卻大為不同,我遭遇到了一處合并沖突:
$ 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 正在應(yīng)用一個(gè)補(bǔ)丁”這樣的理解方式并不十分準(zhǔn)確。但這里的錯(cuò)誤信息確實(shí)標(biāo)明了 “無法應(yīng)用 10e96e46”,這么看來,這種理解又不完全是錯(cuò)的。這到底是怎么回事呢?
那么,遴選到底是怎么執(zhí)行的呢?
我深入研究了 Git 的源代碼,主要是想了解 cherry-pick
是如何工作的,最終我找到了 這一行代碼:
res = do_recursive_merge(r, base, next, base_label, next_label, &head, &msgbuf, opts);
所以,遴選實(shí)際上就是一種……合并操作?這有些出乎意料。那具體都合并了什么內(nèi)容?如何執(zhí)行這個(gè)合并操作的呢?
我意識(shí)到我對(duì) Git 的合并操作并不是特別理解,于是我上網(wǎng)搜索了一下。結(jié)果發(fā)現(xiàn) Git 實(shí)際上采用了一種被稱為 “三路合并” 的合并方式。那這到底是什么含義呢?
Git 的合并策略:三路合并
假設(shè)我要合并下面兩個(gè)文件,我們將其分別命名為 v1.py
和 v2.py
。
def greet():
greeting = "hello"
name = "julia"
return greeting + " " + name
def say_hello():
greeting = "hello"
name = "aanya"
return greeting + " " + name
在這兩個(gè)文件間,存在兩處不同:
def greet()
和def say_hello
name = "julia"
和name = "aanya"
我們應(yīng)該選擇哪個(gè)呢?看起來好像不可能有答案!
不過,如果我告訴你,原始的函數(shù)(我們稱之為 base.py
)是這樣的:
def say_hello():
greeting = "hello"
name = "julia"
return greeting + " " + name
一切似乎變得清晰許多!在這個(gè)基礎(chǔ)上,v1
將函數(shù)的名字更改為 greet
,v2
將 name = "aanya"
。因此,合并時(shí),我們應(yīng)該同時(shí)做出這兩處改變:
def greet():
greeting = "hello"
name = "aanya"
return greeting + " " + name
我們可以命令 Git 使用 git merge-file
來完成這次合并,結(jié)果正是我們預(yù)期的:它選擇了 def greet()
和 name = "aanya"
。
$ git merge-file v1.py base.py v2.py -p
def greet():
greeting = "hello"
name = "aanya"
return greeting + " " + name?
這種將兩個(gè)文件與其原始版本進(jìn)行合并的方式,被稱為 三路合并。
如果你想在線上試一試,我在 jvns.ca/3-way-merge/ 創(chuàng)建了一個(gè)小實(shí)驗(yàn)場(chǎng)。不過我只是草草制作,所以可能對(duì)移動(dòng)端并不友好。
Git 合并的是更改,而非文件
我對(duì)三路合并的理解是 —— Git 合并的是更改,而不是文件。我們對(duì)同一個(gè)文件做出兩種不同的更改,Git 試圖以合理的方式將這兩種更改結(jié)合到一起。當(dāng)兩個(gè)更改都對(duì)同一行進(jìn)行操作時(shí),Git 可能會(huì)遇到困難,此時(shí)就會(huì)產(chǎn)生合并沖突。
Git 也可以合并超過兩處的更改:你可以對(duì)同一文件有多達(dá) 8 處不同的更改,Git 會(huì)嘗試將所有更改協(xié)調(diào)一致。這被稱為八爪魚合并,但除此之外我對(duì)其并不了解,因?yàn)槲覐奈磮?zhí)行過這樣的操作。
Git 如何使用三路合并來應(yīng)用補(bǔ)丁
接下來,讓我們進(jìn)入到一個(gè)有些出乎意料的情境!當(dāng)我們討論 Git “應(yīng)用補(bǔ)丁”(如在變基 —— rebase
、撤銷 —— revert
或遴選 —— cherry-pick
中所做的)時(shí),其實(shí)并非是生成一個(gè)補(bǔ)丁文件并應(yīng)用它。相反,實(shí)際執(zhí)行的是一次三路合并。
下面是如何將提交 X
作為補(bǔ)丁應(yīng)用到你當(dāng)前的提交,并與之前的 v1
、v2
和 base
設(shè)置相對(duì)應(yīng):
- 在你當(dāng)前提交中,文件的版本是
v1
。 - 在提交 X 之前,文件的版本是
base
。 - 在提交 X 中,文件的版本是
v2
。 - 執(zhí)行
git merge-file v1 base v2
以合并它們(實(shí)際上,Git 并不直接執(zhí)行git merge-file
,而是運(yùn)行一個(gè)實(shí)現(xiàn)這個(gè)功能的 C 函數(shù))。
總的來說,你可以將 base
和 v2
視為“補(bǔ)丁”,它們之間的差異就是你想要應(yīng)用到 v1
上的更改。
遴選如何運(yùn)作
假設(shè)我們有如下提交圖,并且我們打算在 main
分支上遴選提交 Y
:
A - B (main)
\
\
X - Y - Z
那么,如何將此情景轉(zhuǎn)化為我們前面提過的 v1
、v2
和 base
組成的三路合并呢?
B
是v1
X
是base
,而Y
是v2
所以,X
和 Y
共同構(gòu)成了這個(gè)“補(bǔ)丁”。
其實(shí),git rebase
無非就是重復(fù)多次執(zhí)行 git cherry-pick
的過程。
撤銷如何運(yùn)作
現(xiàn)在,假如我們希望在如下的提交圖上執(zhí)行 git revert Y
:
X - Y - Z - A - B
B
是v1
Y
是base
,而X
是v2
這個(gè)過程反映的實(shí)際上就是遴選的情況,不過 X
和 Y
的位置顛倒了。我們需要這樣做因?yàn)槲覀兤谕梢粋€(gè)“反向補(bǔ)丁”。在 Git 中,撤銷和遴選關(guān)系如此的緊密,它們甚至在同一個(gè)文件中實(shí)現(xiàn):revert.c。
“三路補(bǔ)丁”是一個(gè)非常棒的技巧
使用三路合并將提交作為補(bǔ)丁應(yīng)用的這個(gè)技巧非常巧妙且酷炫,我很驚訝之前從未聽說過!我并未聽過一個(gè)特定的名字來描述這種方法,但我更傾向于稱之為“三路補(bǔ)丁”。
“三路補(bǔ)丁”的理念在于,你可以通過兩個(gè)文件來定義補(bǔ)?。涸趹?yīng)用補(bǔ)丁前后的文件(在我們這篇文章中稱之為 base
和 v2
)。
因此,總體來看有三個(gè)文件被涉及到:一個(gè)是原文件,另外兩個(gè)構(gòu)成了補(bǔ)丁。
最重要的是,與普通補(bǔ)丁相比,三路補(bǔ)丁是一個(gè)更加高效的補(bǔ)丁方案,因?yàn)樵谟袃蓚€(gè)完整文件的情況下,你擁有更豐富的上下文信息來進(jìn)行合并。
以下是我們例子中的常規(guī)補(bǔ)丁的大致情況:
@@ -1,1 +1,1 @@:
- def greet():
+ def say_hello():
greeting = "hello"
而下面這就是一個(gè)三路補(bǔ)丁。不過,需要提醒的是這個(gè)“三路補(bǔ)丁”并不是一個(gè)真正的文件格式,這只是我自己提出的一種概念。
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》 中提到了這點(diǎn)
James Coglan 的書籍 《Building Git》 是我在 Git 源碼之外唯一找到的地方,他解釋了 git cherry-pick
是如何在底層運(yùn)用三路合并的(我原以為《Pro Git》可能會(huì)提及這個(gè),但我并沒能找到此話題的內(nèi)容)。
我購買完這本書后發(fā)現(xiàn),我早在 2019 年時(shí)就已經(jīng)買過了,這對(duì)我來說真的是個(gè)很好的參考。
Git 中的合并實(shí)際上比這更復(fù)雜
在 Git 中,合并不限于三路合并 —— 還有一種我不太理解的叫做“遞歸合并”,還有許多具體處理文件刪除和移動(dòng)的細(xì)節(jié),同時(shí)也有多種合并算法。
如果想要了解更多相關(guān)知識(shí),我最好的建議是閱讀《Building Git》,盡管我還未完全閱讀這本書。
Git 應(yīng)用到底做了什么?
我也參閱了 Git 的源代碼,試圖理解 git apply
的功能。它似乎(不出意外地)在 apply.c
中實(shí)現(xiàn)。這段代碼解析了一個(gè)補(bǔ)丁文件,并通入目標(biāo)文件來尋找應(yīng)該在何處應(yīng)用補(bǔ)丁。核心邏輯似乎在 這里:思路好像是從補(bǔ)丁建議的行數(shù)開始,然后向前向后找尋。
/*
* 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++) {
...
這個(gè)處理過程不禁讓人覺得非常直白、與之前的期望相符。
Git 三路應(yīng)用的工作方式
git apply
命令中也有一個(gè) --3way
參數(shù),可以實(shí)現(xiàn)三路合并。因此,我們實(shí)際上可以通過如下方式,使用 git apply
來大體實(shí)現(xiàn) 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
但要注意,參數(shù) --3way
并不只用到了補(bǔ)丁文件的內(nèi)容!補(bǔ)丁文件開始的部分是:
index d63ade04..65778fc0 100644
d63ade04
和 65778fc0
是舊/新文件版本在 Git 對(duì)象數(shù)據(jù)庫中的 ID,因此 Git 可以用這些 ID 來執(zhí)行三路補(bǔ)丁操作。但如果有人將補(bǔ)丁文件通過郵件發(fā)送給你,而你并沒有新/舊版本的文件,就無法執(zhí)行這個(gè)操作:如果你缺少 blob,將會(huì)出現(xiàn)如下錯(cuò)誤:
$ git apply out.patch
error: repository lacks the necessary blob to perform 3-way merge.
三路合并有點(diǎn)歷史了
有一部分人指出,三路合并比 Git 的歷史還要久遠(yuǎn),它起源于 70 年代末期左右。有一篇 2007 年的 論文 對(duì)此進(jìn)行了討論。
就說這么多!
我真的對(duì)于我對(duì)于 Git 內(nèi)部應(yīng)用補(bǔ)丁的核心方法其實(shí)理解得并不深入這一點(diǎn)感到非常吃驚——學(xué)習(xí)這一點(diǎn)真的很酷!
雖然我對(duì) Git 用戶界面存在 諸多不滿,但是這個(gè)特定問題并不包含在內(nèi)。三路合并似乎是統(tǒng)一解決一系列不同問題的優(yōu)雅方式,它對(duì)于人們來說也很直觀(“應(yīng)用一個(gè)補(bǔ)丁”這個(gè)想法是許多編程者都習(xí)以為常的思考模式,而它底層實(shí)現(xiàn)為三路合并的細(xì)節(jié),實(shí)際上沒有人真正需要去思考)。
我順便快速推薦一下:我正在寫一部有關(guān) Git 的 zine,如果你對(duì)它的發(fā)布感興趣,你可以注冊(cè)我非常不頻繁的 公告郵件列表。