Git 內部原理
Git 物件
Git 是一個內容定址檔案系統。 看起來很酷, 但這是什麼意思呢? 這意味著,Git 的核心部分是一個簡單的鍵值對資料庫(key-value data store)。 你可以向該資料庫插入任意型別的內容,它會返回一個鍵值,通過該鍵值可以在任意時刻再次檢索(retrieve)該內容。 可以通過底層命令 hash-object
來演示上述效果——該命令可將任意資料保存於 .git
目錄,並返回相應的鍵值。 首先,我們需要初始化一個新的 Git 版本庫,並確認 objects
目錄為空:
$ git init test Initialized empty Git repository in /tmp/test/.git/ $ cd test $ find .git/objects .git/objects .git/objects/info .git/objects/pack $ find .git/objects -type f
可以看到 Git 對 objects
目錄進行了初始化,並建立了 pack
和 info
子目錄,但均為空。 接著,往 Git 資料庫存入一些文字:
$ echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4
-w
選項指示 hash-object
命令儲存資料物件;若不指定此選項,則該命令僅返回對應的鍵值。 --stdin
選項則指示該命令從標準輸入讀取內容;若不指定此選項,則須在命令尾部給出待儲存檔案的路徑。 該命令輸出一個長度為 40 個字元的校驗和。 這是一個 SHA-1 雜湊值——一個將待儲存的資料外加一個頭部資訊(header)一起做 SHA-1 校驗運算而得的校驗和。後文會簡要討論該頭部資訊。 現在我們可以檢視 Git 是如何儲存資料的:
$ find .git/objects -type f
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
可以在 objects
目錄下看到一個檔案。 這就是開始時 Git 儲存內容的方式——一個檔案對應一條內容,以該內容加上特定頭部資訊一起的 SHA-1 校驗和為檔案命名。 校驗和的前兩個字元用於命名子目錄,餘下的 38 個字元則用作檔名。
可以通過 cat-file
命令從 Git 那裡取回資料。 這個命令簡直就是一把剖析 Git 物件的瑞士軍刀。 為 cat-file
指定 -p
選項可指示該命令自動判斷內容的型別,併為我們顯示格式友好的內容:
$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content
至此,你已經掌握瞭如何向 Git 中存入內容,以及如何將它們取出。 我們同樣可以將這些操作應用於檔案中的內容。 例如,可以對一個檔案進行簡單的版本控制。 首先,建立一個新檔案並將其內容存入資料庫:
$ echo 'version 1' > test.txt
$ git hash-object -w test.txt
83baae61804e65cc73a7201a7252750c76066a30
接著,向檔案裡寫入新內容,並再次將其存入資料庫:
$ echo 'version 2' > test.txt
$ git hash-object -w test.txt
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
資料庫記錄下了該檔案的兩個不同版本,當然之前我們存入的第一條內容也還在:
$ find .git/objects -type f
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
現在可以把檔案內容恢復到第一個版本:
$ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test.txt
$ cat test.txt
version 1
或者第二個版本:
$ git cat-file -p 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a > test.txt
$ cat test.txt
version 2
然而,記住檔案的每一個版本所對應的 SHA-1 值並不現實;另一個問題是,在這個(簡單的版本控制)系統中,檔名並沒有被儲存——我們僅儲存了檔案的內容。 上述型別的物件我們稱之為資料物件(blob object)。 利用 cat-file -t
命令,可以讓 Git 告訴我們其內部儲存的任何物件型別,只要給定該物件的 SHA-1 值:
$ git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
blob
樹物件
接下來要探討的物件型別是樹物件(tree object),它能解決檔名儲存的問題,也允許我們將多個檔案組織到一起。 Git 以一種類似於 UNIX 檔案系統的方式儲存內容,但作了些許簡化。 所有內容均以樹物件和資料物件的形式儲存,其中樹物件對應了 UNIX 中的目錄項,資料物件則大致上對應了 inodes 或檔案內容。 一個樹物件包含了一條或多條樹物件記錄(tree entry),每條記錄含有一個指向資料物件或者子樹物件的 SHA-1 指標,以及相應的模式、型別、檔名資訊。 例如,某專案當前對應的最新樹物件可能是這樣的:
$ git cat-file -p master^{tree}
100644 blob a906cb2a4a904a152e80877d4088654daad0c859 README
100644 blob 8f94139338f9404f26296befa88755fc2598c289 Rakefile
040000 tree 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0 lib
master^{tree}
語法表示 master
分支上最新的提交所指向的樹物件。 請注意,lib
子目錄(所對應的那條樹物件記錄)並不是一個數據物件,而是一個指標,其指向的是另一個樹物件:
$ git cat-file -p 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0
100644 blob 47c6340d6459e05787f644c2447d2595f5d3a54b simplegit.rb
從概念上講,Git 內部儲存的資料有點像這樣:
Figure 149. 簡化版的 Git 資料模型。
你可以輕鬆建立自己的樹物件。 通常,Git 根據某一時刻暫存區(即 index 區域,下同)所表示的狀態建立並記錄一個對應的樹物件,如此重複便可依次記錄(某個時間段內)一系列的樹物件。 因此,為建立一個樹物件,首先需要通過暫存一些檔案來建立一個暫存區。 可以通過底層命令 update-index
為一個單獨檔案——我們的 test.txt 檔案的首個版本——建立一個暫存區。 利用該命令,可以把 test.txt 檔案的首個版本人為地加入一個新的暫存區。 必須為上述命令指定 --add
選項,因為此前該檔案並不在暫存區中(我們甚至都還沒來得及建立一個暫存區呢);同樣必需的還有 --cacheinfo
選項,因為將要新增的檔案位於 Git 資料庫中,而不是位於當前目錄下。 同時,需要指定檔案模式、SHA-1 與檔名:
$ git update-index --add --cacheinfo 100644 \
83baae61804e65cc73a7201a7252750c76066a30 test.txt
本例中,我們指定的檔案模式為 100644
,表明這是一個普通檔案。 其他選擇包括:100755
,表示一個可執行檔案;120000
,表示一個符號連結。 這裡的檔案模式參考了常見的 UNIX 檔案模式,但遠沒那麼靈活——上述三種模式即是 Git 檔案(即資料物件)的所有合法模式(當然,還有其他一些模式,但用於目錄項和子模組)。
現在,可以通過 write-tree
命令將暫存區內容寫入一個樹物件。 此處無需指定 -w
選項——如果某個樹物件此前並不存在的話,當呼叫 write-tree
命令時,它會根據當前暫存區狀態自動建立一個新的樹物件:
$ git write-tree
d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579
100644 blob 83baae61804e65cc73a7201a7252750c76066a30 test.txt
不妨驗證一下它確實是一個樹物件:
$ git cat-file -t d8329fc1cc938780ffdd9f94e0d364e0ea74f579
tree
接著我們來建立一個新的樹物件,它包括 test.txt 檔案的第二個版本,以及一個新的檔案:
$ echo 'new file' > new.txt
$ git update-index --cacheinfo 100644 \
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt
$ git update-index --add new.txt
暫存區現在包含了 test.txt 檔案的新版本,和一個新檔案:new.txt。 記錄下這個目錄樹(將當前暫存區的狀態記錄為一個樹物件),然後觀察它的結構:
$ git write-tree
0155eb4229851634a0f03eb265b69f5a2d56f341
$ git cat-file -p 0155eb4229851634a0f03eb265b69f5a2d56f341
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt
我們注意到,新的樹物件包含兩條檔案記錄,同時 test.txt 的 SHA-1 值(1f7a7a
)是先前值的“第二版”。 只是為了好玩:你可以將第一個樹物件加入第二個樹物件,使其成為新的樹物件的一個子目錄。 通過呼叫 read-tree
命令,可以把樹物件讀入暫存區。 本例中,可以通過對 read-tree
指定 --prefix
選項,將一個已有的樹物件作為子樹讀入暫存區:
$ git read-tree --prefix=bak d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git write-tree
3c4e9cd789d88d8d89c1073707c3585e41b0e614
$ git cat-file -p 3c4e9cd789d88d8d89c1073707c3585e41b0e614
040000 tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 bak
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt
如果基於這個新的樹物件建立一個工作目錄,你會發現工作目錄的根目錄包含兩個檔案以及一個名為 bak
的子目錄,該子目錄包含 test.txt 檔案的第一個版本。 可以認為 Git 內部儲存著的用於表示上述結構的資料是這樣的:
Figure 150. 當前 Git 的資料內容結構。
提交物件
現在有三個樹物件,分別代表了我們想要跟蹤的不同專案快照。然而問題依舊:若想重用這些快照,你必須記住所有三個 SHA-1 雜湊值。 並且,你也完全不知道是誰儲存了這些快照,在什麼時刻儲存的,以及為什麼儲存這些快照。 而以上這些,正是提交物件(commit object)能為你儲存的基本資訊。
可以通過呼叫 commit-tree
命令建立一個提交物件,為此需要指定一個樹物件的 SHA-1 值,以及該提交的父提交物件(如果有的話)。 我們從之前建立的第一個樹物件開始:
$ echo 'first commit' | git commit-tree d8329f
fdf4fc3344e67ab068f836878b6c4951e3b15f3d
現在可以通過 cat-file
命令檢視這個新提交物件:
$ git cat-file -p fdf4fc3
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
author Scott Chacon <[email protected]> 1243040974 -0700
committer Scott Chacon <[email protected]> 1243040974 -0700
first commit
提交物件的格式很簡單:它先指定一個頂層樹物件,代表當前專案快照;然後是作者/提交者資訊(依據你的 user.name
和 user.email
配置來設定,外加一個時間戳);留空一行,最後是提交註釋。
接著,我們將建立另兩個提交物件,它們分別引用各自的上一個提交(作為其父提交物件):
$ echo 'second commit' | git commit-tree 0155eb -p fdf4fc3
cac0cab538b970a37ea1e769cbbde608743bc96d
$ echo 'third commit' | git commit-tree 3c4e9c -p cac0cab
1a410efbd13591db07496601ebc7a059dd55cfe9
這三個提交物件分別指向之前建立的三個樹物件快照中的一個。 現在,如果對最後一個提交的 SHA-1 值執行 git log
命令,會出乎意料的發現,你已有一個貨真價實的、可由 git log
檢視的 Git 提交歷史了:
$ git log --stat 1a410e
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Author: Scott Chacon <[email protected]>
Date: Fri May 22 18:15:24 2009 -0700
third commit
bak/test.txt | 1 +
1 file changed, 1 insertion(+)
commit cac0cab538b970a37ea1e769cbbde608743bc96d
Author: Scott Chacon <[email protected]>
Date: Fri May 22 18:14:29 2009 -0700
second commit
new.txt | 1 +
test.txt | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
commit fdf4fc3344e67ab068f836878b6c4951e3b15f3d
Author: Scott Chacon <[email protected]>
Date: Fri May 22 18:09:34 2009 -0700
first commit
test.txt | 1 +
1 file changed, 1 insertion(+)
太神奇了: 就在剛才,你沒有藉助任何上層命令,僅憑几個底層操作便完成了一個 Git 提交歷史的建立。 這就是每次我們執行 git add
和 git commit
命令時, Git 所做的實質工作——將被改寫的檔案儲存為資料物件,更新暫存區,記錄樹物件,最後建立一個指明瞭頂層樹物件和父提交的提交物件。 這三種主要的 Git 物件——資料物件、樹物件、提交物件——最初均以單獨檔案的形式儲存在 .git/objects
目錄下。 下面列出了目前示例目錄內的所有物件,輔以各自所儲存內容的註釋:
$ find .git/objects -type f
.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341 # tree 2
.git/objects/1a/410efbd13591db07496601ebc7a059dd55cfe9 # commit 3
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a # test.txt v2
.git/objects/3c/4e9cd789d88d8d89c1073707c3585e41b0e614 # tree 3
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 # test.txt v1
.git/objects/ca/c0cab538b970a37ea1e769cbbde608743bc96d # commit 2
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 'test content'
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 # tree 1
.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92 # new.txt
.git/objects/fd/f4fc3344e67ab068f836878b6c4951e3b15f3d # commit 1
如果跟蹤所有的內部指標,將得到一個類似下面的物件關係圖:
Figure 151. 你的 Git 目錄下的所有物件。
物件儲存
前文曾提及,在儲存內容時,會有個頭部資訊一併被儲存。 讓我們略花些時間來看看 Git 是如何儲存其物件的。 通過在 Ruby 指令碼語言中互動式地演示,你將看到一個數據物件——本例中是字串“what is up, doc?”——是如何被儲存的。
可以通過 irb
命令啟動 Ruby 的互動模式:
$ irb
>> content = "what is up, doc?"
=> "what is up, doc?"
Git 以物件型別作為開頭來構造一個頭部資訊,本例中是一個“blob”字串。 接著 Git 會新增一個空格,隨後是資料內容的長度,最後是一個空位元組(null byte):
>> header = "blob #{content.length}\0"
=> "blob 16\u0000"
Git 會將上述頭部資訊和原始資料拼接起來,並計算出這條新內容的 SHA-1 校驗和。 在 Ruby 中可以這樣計算 SHA-1 值——先通過 require
命令匯入 SHA-1 digest 庫,然後對目標字串呼叫 Digest::SHA1.hexdigest()
:
>> store = header + content
=> "blob 16\u0000what is up, doc?"
>> require 'digest/sha1'
=> true
>> sha1 = Digest::SHA1.hexdigest(store)
=> "bd9dbf5aae1a3862dd1526723246b20206e5fc37"
Git 會通過 zlib 壓縮這條新內容。在 Ruby 中可以藉助 zlib 庫做到這一點。 先匯入相應的庫,然後對目標內容呼叫 Zlib::Deflate.deflate()
:
>> require 'zlib'
=> true
>> zlib_content = Zlib::Deflate.deflate(store)
=> "x\x9CK\xCA\xC9OR04c(\xCFH,Q\xC8,V(-\xD0QH\xC9O\xB6\a\x00_\x1C\a\x9D"
最後,需要將這條經由 zlib 壓縮的內容寫入磁碟上的某個物件。 要先確定待寫入物件的路徑(SHA-1 值的前兩個字元作為子目錄名稱,後 38 個字元則作為子目錄內檔案的名稱)。 如果該子目錄不存在,可以通過 Ruby 中的 FileUtils.mkdir_p()
函式來建立它。 接著,通過 File.open()
開啟這個檔案。最後,對上一步中得到的檔案控制代碼呼叫 write()
函式,以向目標檔案寫入之前那條 zlib 壓縮過的內容:
>> path = '.git/objects/' + sha1[0,2] + '/' + sha1[2,38]
=> ".git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37"
>> require 'fileutils'
=> true
>> FileUtils.mkdir_p(File.dirname(path))
=> ".git/objects/bd"
>> File.open(path, 'w') { |f| f.write zlib_content }
=> 32
就是這樣——你已建立了一個有效的 Git 資料物件。 所有的 Git 物件均以這種方式儲存,區別僅在於型別標識——另兩種物件型別的頭部資訊以字串“commit”或“tree”開頭,而不是“blob”。 另外,雖然資料物件的內容幾乎可以是任何東西,但提交物件和樹物件的內容卻有各自固定的格式。