當咱們git-merge的時候到底在merge什麼.

序言

我在上大學的時候並無接觸過VCS(版本控制系統)。雖然曾經在Google Code發佈過去項目,可是以壓縮包的形式發佈的;與室友合做開發計算機網絡這門課的課程設計時,也沒有用上。直到入職第一家公司後才真正開始使用,當時用的是Git,此後也始終沒用過其它的VCS——SVN僅僅耳聞不曾使用——轉眼間已經用了六年多的Git了。html

儘管平常使用問題不大,但對於Git的內部運行原理我仍然是隻知其一;不知其二——也不是我謙虛,基本就是不懂吧。例如,使用git addgit commitgit branch等命令的時候,Git在背後究竟作了什麼,我是答不上來的。好在互聯網上有許多這方面的資料可供學習,我硬着頭皮看了很多文檔和博客後,總算是習得了一些皮毛。git

如今,我試着按部就班地講解一遍吧。github

git add的時候發生了什麼?

首先建立出一個倉庫並向其中添加一個文件shell

mkdir git-test
cd git-test
git init
echo 'hello' > a
git add .
複製代碼

到此爲止,暫時不要提交改動。如今,我來看看Git到底在背後作了些什麼。Git的祕密都藏在叫作.git的目錄中,尤爲是其中的objects目錄。用tree命令查看這個目錄的結果以下bash

.git/objects
├── ce
│   └── 013625030ba8dba906f756967f9e9ca394464a
├── info
└── pack
複製代碼

與運行git add前相比,多出了一個叫ce的目錄,以及位於其中的叫013625030ba8dba906f756967f9e9ca394464a的文件。這個文件其實就是a的一個「副本」,其中存儲着文件a的內容。可是不能用cat直接查看,由於Git對這個文件作了壓縮。能夠用pigz來獲得壓縮前的原文,示例代碼以下網絡

pigz -d < .git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
複製代碼

結果爲數據結構

blob 6hello
複製代碼

Git生成這個文件的規則其實不復雜。首先Git會計算原文件的長度,即6(之因此是6,是由於用echo和重定向寫入文件a時,添加了一個換行符)。而後,Git將一個固定的前綴blob(此處有一個空格)、文件長度、一個空字符(ASCII碼爲0的字符),以及文件內容這四者鏈接成一個字符串,並計算這個字符串的SHA1摘要。具體到文件a,能夠用下面的命令試着計算ide

printf "blob 6\0hello\n" | shasum
複製代碼

或者用Git內置的hash-object子命令會更簡單post

git hash-object a
複製代碼

不論是哪個命令,算出來的摘要都是ce013625030ba8dba906f756967f9e9ca394464a。而後Git會取前兩個字符(ce)做爲目錄名,在.git/objects下建立新的目錄。以從第三個字符開始的剩餘內容(013625030ba8dba906f756967f9e9ca394464a)爲文件名,將方纔拼接好的內容壓縮後寫如文件。這種文件用Git的術語來說叫作blob對象,稍後還會遇到tree類型和commit類型的對象。學習

git commit的時候發生了什麼?

接下來提交改動

git config user.email 'foobar'
git config user.name 'foobar'
git commit -m 'test'
複製代碼

此時會發現.git/objects下新增了兩個文件

.git/objects
├── 09
│   └── 76950c1fdbcb52435a433913017bf044b3a58f # 新的
├── 14
│   └── c77e71bd06df41e1509280cfba045e1db2aa5f # 新的
├── ce
│   └── 013625030ba8dba906f756967f9e9ca394464a
├── info
└── pack
複製代碼

git cat-file -t能夠查看這兩個新文件的類型

git cat-file -t 14c77e71bd06df41e1509280cfba045e1db2aa5f # 輸出commit
git cat-file -t 0976950c1fdbcb52435a433913017bf044b3a58f # 輸出tree
複製代碼

也能夠用git cat-file -p以可讀的方式輸出新文件的內容。例如用git cat-file -p 0976950c1fdbcb52435a433913017bf044b3a58f輸出tree類型的對象的內容,結果爲

100644 blob ce013625030ba8dba906f756967f9e9ca394464a	a
複製代碼

tree類型的對象中記錄着Git所追蹤的文件的元信息,包括文件的權限、在Git中的對象類型、對象摘要,以及文件名。另外一個commit類型的對象中存儲着本次提交的信息,用git cat-file -p查看的結果以下

tree 0976950c1fdbcb52435a433913017bf044b3a58f
author foobar <foobar> 1576676836 +0800
committer foobar <foobar> 1576676836 +0800

test
複製代碼

第一行表示這個commit對象指向的是哪個tree對象,從這個tree對象出發,能夠遍歷倉庫中直到本次提交爲止、全部被Git追蹤的文件。commit指向treetree能夠指向blob也能夠指向其它的treeblob就像是樹中的葉子節點,再也不指向其它的對象,它們之間的關係以下圖所示

git branch的時候發生了什麼?

Git的branch子命令用於建立新分支——雖然我平時更多地使用git checkout -b。既然addcommit的時候,Git會建立出blobtree,以及commit類型的對象,那麼建立新分支的時候,Git是否是也會建立名爲branch的對象呢?答案是否認的。

Git的分支很是簡單——它僅僅是指向某個commit對象的引用,就像是*nix系統中的符號連接同樣。全部分支都存儲在.git/refs/heads之下。例如文件.git/refs/heads/master中便存儲着master分支上的最新提交的摘要

cat .git/refs/heads/master # 輸出14c77e71bd06df41e1509280cfba045e1db2aa5f
複製代碼

這就是在Git中建立新分支的成本很低的緣由——不過是複製一下當前分支在.git/refs/heads下的同名文件而已。我建立一個新分支develop並提交一個新文件b.git/objects下會多出三個文件

git checkout -b develop
echo 'good' > b
git add b
git commit -m 'new branch'
複製代碼

三個新文件分別存儲着文件b的內容(一個blob對象)、文件b的元信息(一個tree對象),以及本次提交(一個commit對象)。這些文件中沒有任何關於develop分支的信息,develop分支僅僅是一個存在於.git/refs/heads/目錄下的同名文件。

git merge一個子代時發生了什麼?

develop分支是從master分叉出來,將develop合併回master時,Git會進行一次fast-forward的合併。雖然名字很唬人但其實Git作的事情很是簡單,只須要將.git/refs/heads/master文件的內容修改成與develop相同的摘要便可。

也能夠要求Git不使用fast-forward。先用git reset --hard HEAD^1master分支回退到第一次提交的狀態,而後使用下列的命令再次將develop合併進來

git merge --no-ff develop
複製代碼

這一次,Git再也不簡單地修改.git/refs/heads/master文件了事,而是會建立一個新的commit對象。在個人電腦上,這個新的commit對象的摘要爲d1403bb629c7a636c724069b22875ed882b54bcc,使用git cat-file -p看看它的內容

tree e960ed43b8e6b5fe9b4e57b806f70796da820056
parent 14c77e71bd06df41e1509280cfba045e1db2aa5f
parent db891542d3e44448433ba86c7cd636d8aec3da54
author foobar <foobar> 1576679608 +0800
committer foobar <foobar> 1576679608 +0800

Merge branch 'develop'
複製代碼

有趣的是,這個commit對象有兩個「父級」的commit,而不像日常所認識的樹形數據結構那般只有一個「父節點」。顯然,這兩個父節點分別是合併前的master分支的最新一次提交,以及develop的最新提交。

雖然建立了一個新的commit對象,但其實develop分支的最新提交持有的即是整個倉庫的最新版本,因此不須要建立新的tree,合併所產生的commit直接與develop分支的最新提交共用同一個tree對象便足夠了——在上面輸出內容的第一行的摘要,就是develop分支的最新commit所指向的tree對象的摘要。

至此,終於解決了我一直以來的一個困惑。我曾天真地覺得,Git在合併兩個分支的時候,會將待合進來的分支中的全部多出來的改動,複製到要合進去的分支中去。這都是由於我沒有理解分支的本質,Git的分支並非一根水管,沒有哪個提交是隻能裝在一個特定的分支中的。Git合併的時候,就像是在一個immutable的樹上作修改,只須要建立很少的新committree對象,再引用已經存在的舊committree對象便可。不然,哪能快速地完成兩個分支的合併呢。

後記

沒想到還寫了蠻多內容的,通過這麼幾回試驗,我對Git的核心原理也算略知一二了,暫時不打算繼續深刻。各位讀者若是有興趣,能夠試着製造一次有衝突的合併,而後看看衝突解決的先後,.git/objects目錄下會有什麼變化。

最後,在摸索Git原理的過程當中,我找到了很多優質的參考資料,這裏一併奉上:

  1. nfarina.com/post/986851…
  2. maryrosecook.com/blog/post/g…
  3. www-cs-students.stanford.edu/~blynn/gitm…
  4. git-scm.com/book/en/v2/…

閱讀原文

相關文章
相關標籤/搜索