章節 ▾ 第二版

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` 目錄,並在其中建立了 `pack` 和 `info` 子目錄,但沒有常規檔案。現在,讓我們使用 `git hash-object` 建立一個新的資料物件並手動將其儲存在你的新 Git 資料庫中

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

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

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

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

如果你再次檢查你的 `objects` 目錄,你會看到它現在包含了一個代表新內容的檔案。這就是 Git 最初儲存內容的方式——每個內容都作為一個單獨的檔案,以內容及其頭部資訊的 SHA-1 校驗和命名。子目錄用 SHA-1 的前 2 個字元命名,檔名是剩餘的 38 個字元。

一旦你的物件資料庫中有內容,你就可以使用 `git cat-file` 命令檢查該內容。這個命令就像一個瑞士軍刀,用於檢查 Git 物件。將 `-p` 傳遞給 `cat-file` 會指示命令首先確定內容型別,然後適當地顯示它

$ 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。給定任何物件的 SHA-1 金鑰,你可以讓 Git 告訴你是哪種物件型別,使用 `git cat-file -t`

$ git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
blob

樹物件

我們要檢查的下一類 Git 物件是,它解決了儲存檔名的問題,並且還允許你將一組檔案一起儲存。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 是早期“版本 2”的 SHA-1(`1f7a7a`)。僅為好玩,你將第一個樹新增為其中一個子目錄。你可以透過呼叫 `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.name` 和 `user.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 add` 和 `git 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 校驗和。你可以透過包含 SHA1 digest 庫並使用 `require` 命令,然後使用字串呼叫 `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 庫來完成。首先,你需要 `require` 該庫,然後對內容執行 `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 物件都以相同的方式儲存,只是型別不同——頭部不是以字串 blob 開始,而是以 commit 或 tree 開始。另外,雖然 blob 的內容幾乎可以是任何東西,但 commit 和 tree 的內容格式非常特定。