章節 ▾ 第二版

10.2 Git 內部原理 - Git 物件

Git 物件

Git 是一個內容定址檔案系統。聽起來很棒,那這是什麼意思呢?它意味著 Git 的核心是一個簡單的鍵值資料儲存。這意味著你可以將任何型別的內容插入到 Git 倉庫中,Git 會返回一個唯一的鍵,你可以在以後使用它來檢索該內容。

為了演示,讓我們看看底層命令 git hash-object,它接受一些資料,將其儲存在你的 .git/objects 目錄(物件資料庫)中,並返回一個唯一鍵,該鍵現在指向該資料物件。

首先,你需要初始化一個新的 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 目錄並在其中建立了 packinfo 子目錄,但沒有常規檔案。現在,讓我們使用 git hash-object 來建立一個新的資料物件並手動將其儲存到你的新 Git 資料庫中

$ echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4

最簡單的形式下,git hash-object 會接受你傳遞給它的內容,並僅僅返回用於在你的 Git 資料庫中儲存該內容的唯一鍵。-w 選項則告訴命令不要僅僅返回鍵,而是將該物件寫入資料庫。最後,--stdin 選項告訴 git hash-object 從標準輸入獲取要處理的內容;否則,該命令會期望在命令末尾有一個包含要使用的內容的 filename 引數。

上述命令的輸出是一個 40 個字元的校驗和雜湊值。這是 SHA-1 雜湊值——你儲存的內容加上一個頭部(稍後你會了解)的校驗和。現在你可以看到 Git 是如何儲存你的資料的了

$ find .git/objects -type f
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4

如果你再次檢查 objects 目錄,你會看到它現在包含了一個用於該新內容的檔案。Git 最初就是這樣儲存內容的——每段內容作為一個單獨的檔案,檔名是內容的 SHA-1 校驗和及其頭部的組合。子目錄以 SHA-1 的前兩個字元命名,檔名則是剩餘的 38 個字元。

一旦你的物件資料庫中有了內容,你就可以使用 git cat-file 命令來檢查該內容。這個命令就像一把瑞士軍刀,用於檢查 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

此時,你可以刪除本地的 test.txt 檔案副本,然後使用 Git 從物件資料庫中檢索你儲存的第一個版本

$ 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。你可以透過 git cat-file -t 命令,給定其 SHA-1 鍵,讓 Git 告訴你 Git 中任何物件的型別

$ git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
blob

樹物件

我們將要檢查的下一個 Git 物件型別是 tree(樹),它解決了儲存檔名的問題,也允許你將一組檔案儲存在一起。Git 儲存內容的方式類似於 UNIX 檔案系統,但有所簡化。所有內容都作為樹物件和 blob 物件儲存,其中樹對應於 UNIX 目錄條目,而 blob 或多或少對應於 inode 或檔案內容。單個樹物件包含一個或多個條目,每個條目都是一個 blob 或子樹的 SHA-1 雜湊值,以及其關聯的模式、型別和檔名。例如,假設你有一個專案,其最新樹看起來像

$ git cat-file -p master^{tree}
100644 blob a906cb2a4a904a152e80877d4088654daad0c859      README
100644 blob 8f94139338f9404f26296befa88755fc2598c289      Rakefile
040000 tree 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0      lib

master^{tree} 語法指定了你的 master 分支上最後一次提交所指向的樹物件。請注意,lib 子目錄不是一個 blob,而是指向另一個樹的指標

$ git cat-file -p 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0
100644 blob 47c6340d6459e05787f644c2447d2595f5d3a54b      simplegit.rb
注意

根據你使用的 shell,在使用 master^{tree} 語法時可能會遇到錯誤。

在 Windows 的 CMD 中,^ 字元用於轉義,因此你必須將其加倍以避免這種情況:git cat-file -p master^^{tree}。當使用 PowerShell 時,包含 {} 字元的引數必須加引號,以避免參數解析錯誤:git cat-file -p 'master^{tree}'

如果你使用的是 ZSH,^ 字元用於 globbing(檔案模式匹配),因此你必須將整個表示式用引號括起來:git cat-file -p "master^{tree}"

從概念上講,Git 儲存的資料看起來像這樣

Simple version of the Git data model
圖 173. Git 資料模型的簡單版本

你可以相當容易地建立自己的樹。Git 通常透過獲取你的暫存區或索引的狀態並從中寫入一系列樹物件來建立樹。因此,要建立一個樹物件,你首先必須透過暫存一些檔案來設定一個索引。要建立一個包含單個條目的索引——你的 test.txt 檔案的第一個版本——你可以使用底層命令 git update-index。你使用此命令將 test.txt 檔案的早期版本人工新增到新的暫存區。你必須向其傳遞 --add 選項,因為該檔案尚不存在於你的暫存區中(你甚至還沒有設定暫存區),以及 --cacheinfo,因為你正在新增的檔案不在你的目錄中,而是在你的資料庫中。然後,你指定模式、SHA-1 和檔名

$ git update-index --add --cacheinfo 100644 \
  83baae61804e65cc73a7201a7252750c76066a30 test.txt

在這種情況下,你指定了模式 100644,這意味著它是一個普通檔案。其他選項包括 100755,表示它是可執行檔案;以及 120000,指定一個符號連結。該模式取自普通的 UNIX 模式,但靈活性要差得多——這三種模式是 Git 中檔案(blob)唯一有效的模式(儘管其他模式用於目錄和子模組)。

現在,你可以使用 git write-tree 將暫存區寫入一個樹物件。不需要 -w 選項——如果該樹尚不存在,呼叫此命令會自動從索引狀態建立一個樹物件。

$ git write-tree
d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579
100644 blob 83baae61804e65cc73a7201a7252750c76066a30      test.txt

你還可以使用之前看到的 git cat-file 命令來驗證這是一個樹物件

$ 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)的“版本 2”SHA-1。僅僅為了好玩,你將把第一個樹作為一個子目錄新增到這個樹中。你可以透過呼叫 git 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 為這些結構包含的資料視為如下所示

The content structure of your current Git data
圖 174. 你當前 Git 資料的內容結構

提交物件

如果你已經完成了以上所有操作,你現在有三個樹,它們代表了你想要跟蹤的專案的不同快照,但之前的問題仍然存在:你必須記住所有三個 SHA-1 值才能召回這些快照。你也沒有關於誰儲存了快照、何時儲存以及為何儲存的任何資訊。這就是提交物件為你儲存的基本資訊。

要建立一個提交物件,你呼叫 commit-tree 並指定一個單獨的樹 SHA-1 以及直接位於其之前的提交物件(如果有)。從你寫入的第一個樹開始

$ echo 'First commit' | git commit-tree d8329f
fdf4fc3344e67ab068f836878b6c4951e3b15f3d
注意

由於建立時間和作者資料的不同,你將獲得不同的雜湊值。此外,儘管原則上任何提交物件都可以根據給定的資料精確複製,但本書構建的歷史細節意味著列印的提交雜湊值可能與給定的提交不符。在本章後面,請用你自己的校驗和替換提交和標籤雜湊值。

現在你可以用 git cat-file 檢視你的新提交物件

$ git cat-file -p fdf4fc3
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
author Scott Chacon <schacon@gmail.com> 1243040974 -0700
committer Scott Chacon <schacon@gmail.com> 1243040974 -0700

First commit

提交物件的格式很簡單:它指定了該點專案快照的頂級樹;父提交(如果有)(上面描述的提交物件沒有任何父級);作者/提交者資訊(使用你的 user.nameuser.email 配置設定以及一個時間戳);一個空行,然後是提交訊息。

接下來,你將寫入另外兩個提交物件,每個都引用直接在其之前的提交。

$ echo 'Second commit' | git commit-tree 0155eb -p fdf4fc3
cac0cab538b970a37ea1e769cbbde608743bc96d
$ echo 'Third commit'  | git commit-tree 3c4e9c -p cac0cab
1a410efbd13591db07496601ebc7a059dd55cfe9

這三個提交物件中的每一個都指向你建立的三個快照樹中的一個。奇怪的是,你現在擁有一個真實的 Git 歷史,如果你在最後一個提交 SHA-1 上執行 git log 命令,就可以檢視它。

$ git log --stat 1a410e
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Author: Scott Chacon <schacon@gmail.com>
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 <schacon@gmail.com>
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 <schacon@gmail.com>
Date:   Fri May 22 18:09:34 2009 -0700

    First commit

 test.txt | 1 +
 1 file changed, 1 insertion(+)

太棒了。你剛剛完成了構建 Git 歷史的底層操作,而沒有使用任何前端命令。這本質上就是你執行 git addgit commit 命令時 Git 所做的事情——它為更改的檔案儲存 blob,更新索引,寫出樹,並寫出引用頂級樹以及緊接在它們之前的提交的提交物件。這三個主要的 Git 物件——blob、tree 和 commit——最初作為單獨的檔案儲存在你的 .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

如果你跟隨所有內部指標,你會得到一個像這樣的物件圖

All the reachable objects in your Git directory
圖 175. Git 目錄中所有可達的物件

物件儲存

我們前面提到,每個提交到 Git 物件資料庫的物件都儲存了一個頭部。讓我們花一點時間看看 Git 是如何儲存其物件的。你將看到如何以互動方式在 Ruby 指令碼語言中儲存一個 blob 物件——在本例中是字串“what is up, doc?”。

你可以使用 irb 命令啟動互動式 Ruby 模式

$ irb
>> content = "what is up, doc?"
=> "what is up, doc?"

Git 首先構建一個頭部,該頭部首先標識物件的型別——在本例中是一個 blob。Git 在頭部的第一部分後新增一個空格,然後是內容的位元組大小,並新增一個最後的空位元組。

>> header = "blob #{content.bytesize}\0"
=> "blob 16\u0000"

Git 連線頭部和原始內容,然後計算新內容的 SHA-1 校驗和。你可以透過使用 require 命令包含 SHA1 digest 庫,然後用字串呼叫 Digest::SHA1.hexdigest() 來計算 Ruby 中字串的 SHA-1 值。

>> store = header + content
=> "blob 16\u0000what is up, doc?"
>> require 'digest/sha1'
=> true
>> sha1 = Digest::SHA1.hexdigest(store)
=> "bd9dbf5aae1a3862dd1526723246b20206e5fc37"

讓我們將其與 git hash-object 的輸出進行比較。這裡我們使用 echo -n 來防止在輸入中新增換行符。

$ echo -n "what is up, doc?" | git hash-object --stdin
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 cat-file 檢查物件的內容

---
$ git cat-file -p bd9dbf5aae1a3862dd1526723246b20206e5fc37
what is up, doc?
---

就是這樣——你已經建立了一個有效的 Git blob 物件。

所有 Git 物件的儲存方式都相同,只是型別不同——頭部將以 commit 或 tree 開頭,而不是字串 blob。此外,儘管 blob 內容幾乎可以是任何東西,但 commit 和 tree 內容都有非常特定的格式。

scroll-to-top