一篇文章,輕鬆掌握 git 的 merge 和 rebase

語言: CN / TW / HK

theme: condensed-night-purple highlight: a11y-dark


前言

Git是一個版本控制系統,它在我們的工作中發揮著重要的作用。 git mergegit rebase 兩個指令是我們整合 Git 工作,合併不同分支內容的兩大利器,但是它們兩者的工作方式和對歷史記錄的影響卻是截然不同的。

Git 原理

在理解 git mergegit rebase 之前,我們先來簡單的介紹下 Git 的內部原理,這將有助於對後續內容的理解。

Git 的儲存

Git 實際上是一個內容定址檔案系統,它的核心部分是一個簡單的鍵值對資料庫(key-value data store)。Git 在實現內容儲存時,都會返回一個基於 SHA-1 演算法計算出來的 key,我們可以通過這個 key 在任意時刻將內容讀取出來。

SHA-1 hash 是一個由十六進位制字元(0-9和a – f)組成的40個字元字串,如:b0850823c8e5797e01e071eb93b6194e4543a4b4

Git 將內容儲存在 Git 物件中: 1. blob 物件:檔案由 blob 物件儲存,儲存檔案的全部內容(即檔案快照) 2. tree 物件: 檔案的目錄結構由 tree 物件儲存,物件中的每條記錄含有一個指向 blob 物件或者子樹物件的 SHA-1 指標,以及相應的模式、型別、檔名資訊。 3. commit 物件:Git 的每一個提交由 commit 物件儲存,物件中的內容包括提交者、提交的時間,以及指向頂層 tree 物件的指標(儲存整個專案目錄結構的 tree 物件,即專案快照),如果存在前一個 commit, 還會包含指向前一個 commit 的指標。

mermaid classDiagram TreeA <|-- Commit TreeB <|-- TreeA FileA <|-- TreeA FileB <|-- TreeA Commit : author Commit : time Commit: tree TreeA Commit: commit Parent class TreeA{ tree TreeB blob FileA blob FileB } class FileA{ file content } class FileB{ file content } class TreeB{ blob FileC }

我們可以在專案中的 .git 資料夾中看到每一個由 Git 建立的 物件。

.git 儲存專案所有的 metadata 和 object database,執行 git clone 克隆一個專案時,其中最重要的就是將 .git 資料夾拷貝。

Git 將每一個物件對應的 SHA-1 值的前兩個字元作為目錄名,餘下的 38 個字元則用作檔名,所以每一個Git 物件對應的儲存位置類似以下結構:.git/objects/4a/c34d0644fc69cab26a829f0da5497eda562940

commit 與 branch

每一個 commit 物件對應著我們在 Git 的每一次提交(即版本),每一個 commit 物件儲存時返回的 SHA-1 值就是我們常說的 commit id

commit_id

每一個 commit 物件(除了第一個),都會包含一個指向上一個 commit 的指標,所以 Git 中的 commit 可以抽象成以下形式:

commit

commit 實際上是一個物件,那麼 branch 是什麼呢?

Git 中除了物件外,還有一種儲存結構,稱為引用(references),該引用型別的檔案儲存的是某一個commit 物件的 SHA-1 值,這樣的檔案通常有一些簡單的名字,如 masterdev 等。

Gitbranch 實際上,就是引用,是一個指向 commit 物件的可變指標。

一般 Git 專案中會有一個預設的分支 master,當我們從 master 切出一個名為 dev 的分支時,實際上就是對當前的 commit 物件建立了一個新的引用,並且它的內容會隨著新的 commit 的建立而改變。

那麼 Git 在眾多分支中,如何知道我們工作在哪個分支呢?

這歸功於一個名為 HEAD 的特殊的引用,這個引用特殊在它的內容不是指向 commit 物件的 SHA-1 值,而是其他引用檔案。它當前的內容是哪個引用檔案,便意味著 Git 當前工作在該引用檔案所代表的 branch 上,並且它的內容會隨著分支的切換而改變。

HEAD_master

HEAD_dev

merge

我們通常用 git merge 來合併兩個不同 branch 的內容,通過前面的內容我們瞭解到 branch 實際上是指向 commit 的指標,合併不同的 branch 就是合併不同的 commit

merge 可以分成兩種情況: - 快速合併 - 三方合併

滿足快速合併的條件是其中一個 commit 是另一個 commit 的祖先。

當我們在 dev 上繼續提交多個 commit 後,執行git merge devmasterdev 分支合併。此時 Git 會尋找 masterdev 共同的祖先 commit,於是發現 master 指向的 commit 62940 就是 commit 142e3 的祖先,這時兩者就能直接進行一個快速合併,並且不會存在任何衝突。

快速合併

merge 完成後,master 指標會指向最新的 commit 142e3

如果我們在 dev 分支提交 commit 的同時,也在 master 分支提交了 commit。當兩者進行 merge 時,因為 commit 33888commit 142e3 存在共同的祖先為 commit 62940,無法進行快速合併,此時便需要採用三方合併的方式進行處理。

image.png

三方合併是指將commit 33888commit 142e3 以及它們的共同祖先 commit 62940,這三個 commit 的內容進行合併,同時會自動生成一個全新的 commit 物件記錄合併之後的結果。

三方合併

這個新的 commit 6d5d1 會同時存在兩個父 commit。既包含了指向 commit 33888 的指標,又包含了指向 commit 142e3 的指標。 當 merge 完成後, master 指標會指向新的 commit 6d5d1

但是,三方合併並非總是一帆風順的。

三方合併需要將三個 commit 的內容合併,如果存在兩個 commit 對同一檔案同一部分做了不同的修改,此時合併就會出現衝突,因為Git不知道如何處理這種問題,所以需要我們手動的解決衝突。當出現衝突時,Git 會完成合並但是不會自動建立新的 commit,需要我們手動解決衝突後,自己通過 git addgit commit 建立新的 commit

rebase

rebase 在任何情況下,合併不同分支的 commit ,都是採用相同的處理方式。

以同時在 masterdev 建立 commit 為例,在 dev 分支上執行 git rebase master 合併master 分支的 commit

image.png

首先 Git 會尋找到兩個分支的共同祖先 commit 62940 ,然後將 dev 分支基於該 commit 之後的每一次提交對應的修改都提取並儲存為臨時檔案。之後就將 dev 的指標指向與 master 相同的 commit 33888image.png

然後再將臨時檔案的內容重新應用到該 dev 分支上,依次建立新的 commit,因為 dev 已經指向commit 33888,所以新的 commit 就依次建立在 commit 33888 之後。如果在這個過程中存在衝突,則 rebase 會中止,需要等待解決衝突後,讓 rebase 繼續進行。

注意:此時雖然重新應用到 dev 上的修改是一樣的,但是因為依次重新建立了 commit,因此 commit 對應的 SHA-1 值是不同的,即 commit id 不同了。

image.png

因為 rebase 的過程存在重新應用修改,重新建立commit 的過程,因此使用rebase時可能會遇到需要不斷地重新解決衝突的問題。

總結

瞭解了這麼多有關 mergerebase 的內容,有些同學可能會想:在 Git 合併分支時,使用哪一種方式更好呢?其實這個問題沒有標準答案,只能是在不同的情境下選擇合適的方式。

我們不難發現,使用 rebase 的方式能夠使我們保持整潔的 commit 記錄,這是一種剔除枝葉維護主幹的工作方式。而 merge 的方式,會留下支幹,同時還會增加一些由 merge 建立的 commit,它並不整潔,卻能夠完整的記錄下所有的工作痕跡。