Linux中國

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.pyv2.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 將函數的名字更改為 greetv2name = "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 作為補丁應用到你當前的提交,並與之前的 v1v2base 設置相對應:

  1. 在你當前提交中,文件的版本是 v1
  2. 在提交 X 之前,文件的版本是 base
  3. 在提交 X 中,文件的版本是 v2
  4. 執行 git merge-file v1 base v2 以合併它們(實際上,Git 並不直接執行 git merge-file,而是運行一個實現這個功能的 C 函數)。

總的來說,你可以將 basev2 視為「補丁」,它們之間的差異就是你想要應用到 v1 上的更改。

遴選如何運作

假設我們有如下提交圖,並且我們打算在 main 分支上遴選提交 Y

A - B (main)

    X - Y - Z

那麼,如何將此情景轉化為我們前面提過的 v1v2base 組成的三路合併呢?

  • Bv1
  • Xbase,而 Yv2

所以,XY 共同構成了這個「補丁」。

其實,git rebase 無非就是重複多次執行 git cherry-pick 的過程。

撤銷如何運作

現在,假如我們希望在如下的提交圖上執行 git revert Y

X - Y - Z - A - B
  • Bv1
  • Ybase,而 Xv2

這個過程反映的實際上就是遴選的情況,不過 XY 的位置顛倒了。我們需要這樣做因為我們期望生成一個「反向補丁」。在 Git 中,撤銷和遴選關係如此的緊密,它們甚至在同一個文件中實現:revert.c

「三路補丁」是一個非常棒的技巧

使用三路合併將提交作為補丁應用的這個技巧非常巧妙且酷炫,我很驚訝之前從未聽說過!我並未聽過一個特定的名字來描述這種方法,但我更傾向於稱之為「三路補丁」。

「三路補丁」的理念在於,你可以通過兩個文件來定義補丁:在應用補丁前後的文件(在我們這篇文章中稱之為 basev2)。

因此,總體來看有三個文件被涉及到:一個是原文件,另外兩個構成了補丁。

最重要的是,與普通補丁相比,三路補丁是一個更加高效的補丁方案,因為在有兩個完整文件的情況下,你擁有更豐富的上下文信息來進行合併。

以下是我們例子中的常規補丁的大致情況:

@@ -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&apos;s probably some smart way to do this, but I&apos;ll leave
     * that to the smart and beautiful people. I&apos;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 &apos;content/post/2023-07-28-why-is-dns-still-hard-to-learn-.markdown&apos; with conflicts.
U content/post/2023-07-28-why-is-dns-still-hard-to-learn-.markdown

但要注意,參數 --3way 並不只用到了補丁文件的內容!補丁文件開始的部分是:

index d63ade04..65778fc0 100644

d63ade0465778fc0 是舊/新文件版本在 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

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國