要展現 git 的協作功能,則不能不提其開分支、合併分支的機制。之前我們在 Day 9 提過,分支的本質是「參考(ref)」,那合併時,這些參考是怎麼跑的?新的 commit 物件內容是什麼?commit 又指著哪些 tree 跟 blob 物件呢?如果發生衝突,內部又是怎麼變化的呢?
這篇文章將在「無衝突」與「有衝突」兩種情境下,細細檢視合併的每個過程中,.git/
資料夾發生的變化。
首先在 main
分支上,建立第一個 commit:
touch file.txt
git add file.txt
git commit -m "Initial commit"
接著建立並切換到新分支 feature
:
git switch -c feature
在這裡建立一個新的 commit:
echo "Hello, world" > hello_world.txt
git add hello_world.txt
git commit -m "Add file on feature"
再切回 main
分支:
git switch main
在 main
分支建立 commit:
echo "Main content" > file.txt
git add file.txt
git commit -m "Revise content in file.txt"
目前 .git/
資料夾結構如下:
.git/
├── ...
│
├── objects/
│ ├── 22/ # Initial commit物件
│ │ └── 044b2153b50be5e131579d1a25008737c4b7d4
│ ├── 28/ # 指向ccc05fb...的tree物件
│ │ └── 96f069a49567bc3b9a15b2a946754d63b2c06d
│ ├── a5/ # 內容為Hello, world的blob物件
│ │ └── c19667710254f835085b99726e523457150e03
│ ├── bd/ # 指向e69de29...的tree物件
│ │ └── d68b0120ca91384c1606468b4ca81b8f67c728
│ ├── c9/ # main分支的第二個commit物件
│ │ └── 30e9edfb1c6372e197b1260544ea213b93d7e7
│ ├── cc/ # 內容為Main content的blob物件
│ │ └── c05fb42ee018ccbd119d1b9e8f47372f530632
│ ├── e6/ # 空的blob物件
│ │ └── 9de29bb2d1d6434b8b29ae775ad8c2e48c5391
│ ├── e9/ # feature分支的commit物件
│ │ └── 9a7c7249b6729a6a122ae306187cc793d43d72
│ ├── fc/ # 指向e69de29...與a5c1966...的tree物件
│ │ └── 153946417df4388babc50d03074899baf7eae7
│ ├── info/
│ └── pack/
│
├── refs/
│ ├── heads/
│ │ ├── feature # 指向e99a7c7249b6729a6a122ae306187cc793d43d72
│ │ └── main # 指向c930e9edfb1c6372e197b1260544ea213b93d7e7
│ └── tags/
│
├── ...
└── index
圖示如下:
如果這時要把 feature
分支合併進 main
分支,就在 main
分支上輸入以下指令:
git merge feature
這時會跳出一個編輯器,內容為:
Merge branch 'feature'
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
上面的 "Merge branch 'feature'"
為預設的 commit 訊息,而若觀察 .git/
資料夾,會發現變這樣:
.git/
├── ...
│
├── objects/
│ ├── 22/
│ │ └── 044b2153b50be5e131579d1a25008737c4b7d4
│ ├── 28/
│ │ └── 96f069a49567bc3b9a15b2a946754d63b2c06d
│ ├── 31/ # 新增的tree物件,指向ccc05fb...與a5c1966...
│ │ └── 47c1ebd37d3dbf87ee4d67cc95657dfffeed34
│ ├── a5/
│ │ └── c19667710254f835085b99726e523457150e03
│ ├── bd/
│ │ └── d68b0120ca91384c1606468b4ca81b8f67c728
│ ├── c9/
│ │ └── 30e9edfb1c6372e197b1260544ea213b93d7e7
│ ├── cc/
│ │ └── c05fb42ee018ccbd119d1b9e8f47372f530632
│ ├── e6/
│ │ └── 9de29bb2d1d6434b8b29ae775ad8c2e48c5391
│ ├── e9/
│ │ └── 9a7c7249b6729a6a122ae306187cc793d43d72
│ ├── fc/
│ │ └── 153946417df4388babc50d03074899baf7eae7
│ ├── info/
│ └── pack/
│
├── refs/
│ ├── heads/
│ │ ├── feature # 仍指向e99a7c7249b6729a6a122ae306187cc793d43d72
│ │ └── main # 仍指向c930e9edfb1c6372e197b1260544ea213b93d7e7
│ └── tags/
│
├── AUTO_MERGE # 新增檔案,內容為3147c1ebd37d3dbf87ee4d67cc95657dfffeed34
├── COMMIT_EDITMSG # 內容仍為Revise content in file.txt
├── config # 舊有檔案
├── description # 舊有檔案
├── HEAD # 內容仍為ref: refs/heads/main
├── index # 內容為100644 ccc05fb42ee018ccbd119d1b9e8f47372f530632 0 file.txt
│ # 以及100644 a5c19667710254f835085b99726e523457150e03 0 hello_world.txt
├── MERGE_HEAD # 內容為e99a7c7249b6729a6a122ae306187cc793d43d72
├── MERGE_MODE # 內容為空
├── MERGE_MSG # 內含上述編輯器內容
└── ORIG_HEAD # 內容為c930e9edfb1c6372e197b1260544ea213b93d7e7
這時主要有兩大變化:
3147c1e...
已經被做出來,指向兩個要被合併分支指向的 blob 物件,同時被 AUTO_MERGE
參考指著。MERGE_HEAD
參考,指向要被合併進來的 commit。圖示如下:
待編輯完合併的 commit 訊息,再關閉編輯器後,終端機畫面如下:
接著再來觀察 .git/
資料夾:
.git/
├── ...
│
├── objects/
│ ├── 22/
│ │ └── 044b2153b50be5e131579d1a25008737c4b7d4
│ ├── 28/
│ │ └── 96f069a49567bc3b9a15b2a946754d63b2c06d
│ ├── 31/ # 剛剛新增的tree物件,指向ccc05fb...與a5c1966...
│ │ └── 47c1ebd37d3dbf87ee4d67cc95657dfffeed34
│ ├── a1/ # 合併後新增的commit物件
│ │ └── 9a784cc3b6915bd33d1e0f768c8c79c69bf001
│ ├── a5/
│ │ └── c19667710254f835085b99726e523457150e03
│ ├── bd/
│ │ └── d68b0120ca91384c1606468b4ca81b8f67c728
│ ├── c9/
│ │ └── 30e9edfb1c6372e197b1260544ea213b93d7e7
│ ├── cc/
│ │ └── c05fb42ee018ccbd119d1b9e8f47372f530632
│ ├── e6/
│ │ └── 9de29bb2d1d6434b8b29ae775ad8c2e48c5391
│ ├── e9/
│ │ └── 9a7c7249b6729a6a122ae306187cc793d43d72
│ ├── fc/
│ │ └── 153946417df4388babc50d03074899baf7eae7
│ ├── info/
│ └── pack/
│
├── refs/
│ ├── heads/
│ │ ├── feature # 仍指向e99a7c7249b6729a6a122ae306187cc793d43d72
│ │ └── main # 改指向a19a784cc3b6915bd33d1e0f768c8c79c69bf001
│ └── tags/
│
├── AUTO_MERGE # 檔案消失
├── COMMIT_EDITMSG # 內容仍為Revise content in file.txt
├── config # 舊有檔案
├── description # 舊有檔案
├── HEAD # 內容仍為ref: refs/heads/main
├── index # 內容仍為100644 ccc05fb42ee018ccbd119d1b9e8f47372f530632 0 file.txt
│ # 以及100644 a5c19667710254f835085b99726e523457150e03 0 hello_world.txt
├── MERGE_HEAD # 檔案消失
├── MERGE_MODE # 檔案消失
├── MERGE_MSG # 檔案消失
└── ORIG_HEAD # 內容仍為c930e9edfb1c6372e197b1260544ea213b93d7e7
合併完後,出現兩大變化:
a19a784...
,main 分支指著它、它指向剛剛新做出來的 3147c1e...
物件。AUTO_MERGE
、MERGE_HEAD
、MERGE_MODE
、MERGE_MSG
全部消失。此時 git 物件結構如下:
在一個空的資料夾中,先在 main
分支建立第一個 commit:
touch file.txt
git add file.txt
git commit -m "Initial commit"
接著建立並切換到 feature
分支:
git switch -c feature
在 feature
分支也建立一個 commit:
echo "Feature version" > file.txt
git add file.txt
git commit -m "Add feature version"
接著切回 main
分支:
git switch main
在 main
分支上,建立一份檔名同為 file.txt
的檔案,但內容與 feature
分支不同:
echo "Main version" > file.txt
git add file.txt
git commit -m "Add main version"
這時候 .git/
資料夾長什麼模樣呢?
.git/
├── ...
│
├── objects/
│ ├── 3c/ # feature分支的commit物件
│ │ └── 42ca7eee01372a11e05c88db7198ab7be57df0
│ ├── 8f/ # 指向dbc69068...的tree物件
│ │ └── 3e35b1bef33d7af8f47a872d0c938ac2409932
│ ├── 26/ # main分支的第二個commit物件
│ │ └── 6bff847229de5ca95fd459d364f95611f96779
│ ├── 50/ # 內容為Main version的blob物件
│ │ └── 55b9198f7069db185ec0f12534e44bfd5c9623
│ ├── bd/ # 指向e69de29...的blob物件
│ │ └── d68b0120ca91384c1606468b4ca81b8f67c728
│ ├── db/ # 內容為Feature version的blob物件
│ │ └── c69068b98a226af5471ad0543d5ad3316b4cb7
│ ├── e3/ # main分支第一個commit物件
│ │ └── 27f3b729ac07053557bb5e98de113ec3fbdd3e
│ ├── e6/ # 內容為空的blob物件
│ │ └── 9de29bb2d1d6434b8b29ae775ad8c2e48c5391
│ ├── f8/ # 指向5055b91的tree物件
│ │ └── f64737b18c5aff3c7a0a488d5d1d2d8f36ad2f
│ ├── info/
│ └── pack/
│
├── refs/
│ ├── heads/
│ │ ├── feature # 指向3c42ca7eee01372a11e05c88db7198ab7be57df0
│ │ └── main # 指向266bff847229de5ca95fd459d364f95611f96779
│ └── tags/
│
├── ...
└── index
圖示如下:
如果我們一樣在 main
分支輸入以下指令:
git merge feature
從終端機可以看出有衝突:
在編輯器中,也可以看出要我們解衝突:
那此時 .git/
資料夾長怎樣呢?
.git/
├── ...
│
├── objects/
│ ├── 3b/ # 新增指向a3a09d6...的tree物件
│ │ └── 655319debc05c2433eba45fa9dfc39791759af
│ ├── 3c/
│ │ └── 42ca7eee01372a11e05c88db7198ab7be57df0
│ ├── 8f/
│ │ └── 3e35b1bef33d7af8f47a872d0c938ac2409932
│ ├── 26/
│ │ └── 6bff847229de5ca95fd459d364f95611f96779
│ ├── 50/
│ │ └── 55b9198f7069db185ec0f12534e44bfd5c9623
│ ├── a3/ # 編輯器顯示衝突內容,為blob物件
│ │ └── a09d68cc1023eccc8cd0cad04a3ef8fbd483cb
│ ├── bd/
│ │ └── d68b0120ca91384c1606468b4ca81b8f67c728
│ ├── db/
│ │ └── c69068b98a226af5471ad0543d5ad3316b4cb7
│ ├── e3/
│ │ └── 27f3b729ac07053557bb5e98de113ec3fbdd3e
│ ├── e6/
│ │ └── 9de29bb2d1d6434b8b29ae775ad8c2e48c5391
│ ├── f8/
│ │ └── f64737b18c5aff3c7a0a488d5d1d2d8f36ad2f
│ ├── info/
│ └── pack/
│
├── refs/
│ ├── heads/
│ │ ├── feature # 仍指向3c42ca7eee01372a11e05c88db7198ab7be57df0
│ │ └── main # 仍指向266bff847229de5ca95fd459d364f95611f96779
│ └── tags/
│
├── AUTO_MERGE # 內容為3b655319debc05c2433eba45fa9dfc39791759af
├── COMMIT_EDITMSG # 內容仍為Add main version
├── config # 舊有檔案
├── description # 舊有檔案
├── HEAD # 內容仍為ref: refs/heads/main
├── index # 內容為100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 1 file.txt
│ # 與100644 5055b9198f7069db185ec0f12534e44bfd5c9623 2 file.txt
│ # 以及100644 dbc69068b98a226af5471ad0543d5ad3316b4cb7 3 file.txt
├── MERGE_HEAD # 內容為3c42ca7eee01372a11e05c88db7198ab7be57df0
├── MERGE_MODE # 內容為空
├── MERGE_MSG # 內含為 Merge branch 'feature'
│ # Conflicts:
│ # file.txt
└── ORIG_HEAD # 內容為266bff847229de5ca95fd459d364f95611f96779
圖示如下,可發現跟沒有衝突時相比,右上角多了一個 blob 物件,內容即為兩種版本;預存區 index
則包含要合併的兩分支所有 blob 物件:
假設我們選 Accept Both Changes
,並把檔案加進預存區:
git add file.txt
這時 .git/
資料夾檔案變成:
.git/
├── ...
│
├── objects/
│ ├── 2b/ # 新增的blob物件,包含處理衝突完後的檔案內容
│ │ └── 557f90890804e02656db31b7f27863fb81d10a
│ ├── 3b/
│ │ └── 655319debc05c2433eba45fa9dfc39791759af
│ ├── 3c/
│ │ └── 42ca7eee01372a11e05c88db7198ab7be57df0
│ ├── 8f/
│ │ └── 3e35b1bef33d7af8f47a872d0c938ac2409932
│ ├── 26/
│ │ └── 6bff847229de5ca95fd459d364f95611f96779
│ ├── 50/
│ │ └── 55b9198f7069db185ec0f12534e44bfd5c9623
│ ├── a3/
│ │ └── a09d68cc1023eccc8cd0cad04a3ef8fbd483cb
│ ├── bd/
│ │ └── d68b0120ca91384c1606468b4ca81b8f67c728
│ ├── db/
│ │ └── c69068b98a226af5471ad0543d5ad3316b4cb7
│ ├── e3/
│ │ └── 27f3b729ac07053557bb5e98de113ec3fbdd3e
│ ├── e6/
│ │ └── 9de29bb2d1d6434b8b29ae775ad8c2e48c5391
│ ├── f8/
│ │ └── f64737b18c5aff3c7a0a488d5d1d2d8f36ad2f
│ ├── info/
│ └── pack/
│
├── refs/
│ ├── heads/
│ │ ├── feature # 仍指向3c42ca7eee01372a11e05c88db7198ab7be57df0
│ │ └── main # 仍指向266bff847229de5ca95fd459d364f95611f96779
│ └── tags/
│
├── AUTO_MERGE
├── COMMIT_EDITMSG
├── config
├── description
├── HEAD
├── index # 內容變成100644 2b557f90890804e02656db31b7f27863fb81d10a 0 file.txt
├── MERGE_HEAD
├── MERGE_MODE
├── MERGE_MSG
└── ORIG_HEAD
git add
指令多做出一個 blob 物件,為合併後的內容,此物件已被加到預存區,但還沒有 tree 物件指向它:
接著再下指令完成合併、形成 commit:
git commit -m "Merge feature into main"
合併完後,回頭觀察 .git/
資料夾:
.git/
├── ...
│
├── objects/
│ ├── 2b/
│ │ └── 557f90890804e02656db31b7f27863fb81d10a
│ ├── 3b/
│ │ └── 655319debc05c2433eba45fa9dfc39791759af
│ ├── 3c/
│ │ └── 42ca7eee01372a11e05c88db7198ab7be57df0
│ ├── 6f/ # 合併的commit物件
│ │ └── 4c29c1822ba090b23e384aa05ff25ecdf26063
│ ├── 8f/
│ │ └── 3e35b1bef33d7af8f47a872d0c938ac2409932
│ ├── 26/
│ │ └── 6bff847229de5ca95fd459d364f95611f96779
│ ├── 50/
│ │ └── 55b9198f7069db185ec0f12534e44bfd5c9623
│ ├── a3/
│ │ └── a09d68cc1023eccc8cd0cad04a3ef8fbd483cb
│ ├── bd/
│ │ └── d68b0120ca91384c1606468b4ca81b8f67c728
│ ├── db/
│ │ └── c69068b98a226af5471ad0543d5ad3316b4cb7
│ ├── e3/
│ │ └── 27f3b729ac07053557bb5e98de113ec3fbdd3e
│ ├── e6/
│ │ └── 9de29bb2d1d6434b8b29ae775ad8c2e48c5391
│ ├── ef/ # 指向2b557f9...的tree物件
│ │ └── 210594c4614c494768ea3327ec4f2bab0137a4
│ ├── f8/
│ │ └── f64737b18c5aff3c7a0a488d5d1d2d8f36ad2f
│ ├── info/
│ └── pack/
│
├── refs/
│ ├── heads/
│ │ ├── feature # 仍指向3c42ca7eee01372a11e05c88db7198ab7be57df0
│ │ └── main # 改指向6f4c29c1822ba090b23e384aa05ff25ecdf26063
│ └── tags/
│
├── COMMIT_EDITMSG # 最新的commit訊息Merge feature into main
├── config
├── description
├── HEAD # 仍為ref: refs/heads/main
├── index # 100644 2b557f90890804e02656db31b7f27863fb81d10a 0 file.txt
└── ORIG_HEAD # 266bff847229de5ca95fd459d364f95611f96779
圖示如下,可發現在隨著合併的 commit 建立,對應的 tree 與 blob 物件也都跟著出現;至於在 git add
階段出現的 blob 與 tree 物件,就沒有 commit 指向它:
此圖示也可以搭配 git log --oneline
與 git log --oneline --graph
結果觀察、理解:
git log --oneline --graph
時常常看不懂分支與 commit 間的關係,以下說明:# 每顆星星代表其右邊對應的commit
# 左邊直線(終端機是先綠再紅線)是main分支
# 右邊拉出來(終端機是全綠線)再收回去是feature分支
* 6f4c29c (HEAD -> main) Merge feature into main # HEAD指向main分支,而main分支最新的commit是這個
|\ # feature分支合併到main分支
| * 3c42ca7 (feature) Add feature version # feature分支最新的commit是這個
* | 266bff8 Add main version # 此commit對應的星星在左邊那條線,而左邊那條線是main分支,表示此commit在main分支上
|/ # 從main分支開出一條feature分支
* e327f3b Initial commit # 兩分支共同祖先commit
是不是相當於圖示中只保留 commit 物件的樣子?(不過我把紅色的 main
分支畫到右邊、綠色的 feature
分支畫到左邊)
Accept Current Change
或 Accept Incoming Change
git add
後,也會出現過渡的 tree 與 blob 物件。git commit
後,出現的新 commit 指向既有的 tree 物件。例如若選 Accept Current Change
表示選「現在所在分支 main
」的版本,合併完後的新 commit 指向原 main
分支最新 commit 指向的 tree(如下圖綠色箭頭);若選 Accept Incoming Change
表示選「合併進來的分支 feature
」版本,合併完後的新 commit 指向原 feature
分支最新 commit 指向的 tree(如下圖藍色箭頭)。
合併分支時,git 內部運作如下:
在有衝突時,若選擇 Accept Current Change
或 Accept Incoming Change
,則新 commit 會直接指向指定版本的既有 tree 物件;若選擇 Accept Both Changes
,則新 commit 會指向新的 tree、新的 tree 再指向新的 blob 物件,這個新 blob 物件內容即為兩版本合併後的檔案內容。