我在上大學的時候並無接觸過VCS(版本控制系統)。雖然曾經在Google Code發佈過去項目,可是以壓縮包的形式發佈的;與室友合做開發計算機網絡這門課的課程設計時,也沒有用上。直到入職第一家公司後才真正開始使用,當時用的是Git,此後也始終沒用過其它的VCS——SVN僅僅耳聞不曾使用——轉眼間已經用了六年多的Git了。html
儘管平常使用問題不大,但對於Git的內部運行原理我仍然是隻知其一;不知其二——也不是我謙虛,基本就是不懂吧。例如,使用git add
、git commit
、git branch
等命令的時候,Git在背後究竟作了什麼,我是答不上來的。好在互聯網上有許多這方面的資料可供學習,我硬着頭皮看了很多文檔和博客後,總算是習得了一些皮毛。git
如今,我試着按部就班地講解一遍吧。github
首先建立出一個倉庫並向其中添加一個文件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 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
指向tree
,tree
能夠指向blob
也能夠指向其它的tree
,blob
就像是樹中的葉子節點,再也不指向其它的對象,它們之間的關係以下圖所示
Git的branch
子命令用於建立新分支——雖然我平時更多地使用git checkout -b
。既然add
和commit
的時候,Git會建立出blob
、tree
,以及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/
目錄下的同名文件。
develop
分支是從master
分叉出來,將develop
合併回master
時,Git會進行一次fast-forward
的合併。雖然名字很唬人但其實Git作的事情很是簡單,只須要將.git/refs/heads/master
文件的內容修改成與develop
相同的摘要便可。
也能夠要求Git不使用fast-forward
。先用git reset --hard HEAD^1
將master
分支回退到第一次提交的狀態,而後使用下列的命令再次將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的樹上作修改,只須要建立很少的新commit
和tree
對象,再引用已經存在的舊commit
和tree
對象便可。不然,哪能快速地完成兩個分支的合併呢。
沒想到還寫了蠻多內容的,通過這麼幾回試驗,我對Git的核心原理也算略知一二了,暫時不打算繼續深刻。各位讀者若是有興趣,能夠試着製造一次有衝突的合併,而後看看衝突解決的先後,.git/objects
目錄下會有什麼變化。
最後,在摸索Git原理的過程當中,我找到了很多優質的參考資料,這裏一併奉上: