1. 程式人生 > >Git詳解之九 Git內部原理

Git詳解之九 Git內部原理

Git 內部原理

不管你是從前面的章節直接跳到了本章,還是讀完了其餘各章一直到這,你都將在本章見識 Git 的內部工作原理和實現方式。我個人發現學習這些內容對於理解 Git 的用處和強大是非常重要的,不過也有人認為這些內容對於初學者來說可能難以理解且過於複雜。正因如此我把這部分內容放在最後一章,你在學習過程中可以先閱讀這部分,也可以晚點閱讀這部分,這完全取決於你自己。

既然已經讀到這了,就讓我們開始吧。首先要弄明白一點,從根本上來講 Git 是一套內容定址 (content-addressable) 檔案系統,在此之上提供了一個 VCS 使用者介面。馬上你就會學到這意味著什麼。

早期的 Git (主要是 1.5 之前版本) 的使用者介面要比現在複雜得多,這是因為它更側重於成為檔案系統而不是一套更精緻的 VCS 。最近幾年改進了 UI 從而使它跟其他任何系統一樣清晰易用。即便如此,還是經常會有一些陳腔濫調提到早期 Git 的 UI 複雜又難學。

內容定址檔案系統這一層相當酷,在本章中我會先講解這部分。隨後你會學到傳輸機制和最終要使用的各種庫管理任務。

9.1  底層命令 (Plumbing) 和高層命令 (Porcelain)

本書講解了使用 checkoutbranchremote 等共約 30 個 Git 命令。然而由於 Git 一開始被設計成供 VCS 使用的工具集而不是一整套使用者友好的 VCS,它還包含了許多底層命令,這些命令用於以 UNIX 風格使用或由指令碼呼叫。這些命令一般被稱為 “plumbing” 命令(底層命令),其他的更友好的命令則被稱為 “porcelain” 命令(高層命令)。

本書前八章主要專門討論高層命令。本章將主要討論底層命令以理解 Git 的內部工作機制、演示 Git 如何及為何要以這種方式工作。這些命令主要不是用來從命令列手工使用的,更多的是用來為其他工具和自定義指令碼服務的。

當你在一個新目錄或已有目錄內執行 git init 時,Git 會建立一個 .git 目錄,幾乎所有 Git 儲存和操作的內容都位於該目錄下。如果你要備份或複製一個庫,基本上將這一目錄拷貝至其他地方就可以了。本章基本上都討論該目錄下的內容。該目錄結構如下:

$ ls HEAD branches/ config description hooks/ index info/ objects/ refs/

該目錄下有可能還有其他檔案,但這是一個全新的 git init 生成的庫,所以預設情況下這些就是你能看到的結構。新版本的 Git 不再使用 branches 目錄,description 檔案僅供 GitWeb 程式使用,所以不用關心這些內容。config

 檔案包含了專案特有的配置選項,info 目錄儲存了一份不希望在 .gitignore 檔案中管理的忽略模式 (ignored patterns) 的全域性可執行檔案。hooks 目錄包住了第六章詳細介紹了的客戶端或服務端鉤子指令碼。

另外還有四個重要的檔案或目錄:HEAD 及 index 檔案,objects及 refs 目錄。這些是 Git 的核心部分。objects 目錄儲存所有資料內容,refs 目錄儲存指向資料 (分支) 的提交物件的指標,HEAD檔案指向當前分支,index 檔案儲存了暫存區域資訊。馬上你將詳細瞭解 Git 是如何操縱這些內容的。

9.2  Git 物件

Git 是一套內容定址檔案系統。很不錯。不過這是什麼意思呢?這種說法的意思是,從內部來看,Git 是簡單的 key-value 資料儲存。它允許插入任意型別的內容,並會返回一個鍵值,通過該鍵值可以在任何時候再取出該內容。可以通過底層命令 hash-object來示範這點,傳一些資料給該命令,它會將資料儲存在 .git 目錄並返回表示這些資料的鍵值。首先初使化一個 Git 倉庫並確認objects 目錄是空的:

$ mkdir test $ cd test $ git init Initialized empty Git repository in /tmp/test/.git/ $ 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 指定從標準輸入裝置 (stdin) 來讀取內容,若不指定這個引數則需指定一個要儲存的檔案的路徑。該命令輸出長度為 40 個字元的校驗和。這是個 SHA-1 雜湊值──其值為要儲存的資料加上你馬上會了解到的一種頭資訊的校驗和。現在可以檢視到 Git 已經儲存了資料:

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

可以在 objects 目錄下看到一個檔案。這便是 Git 儲存資料內容的方式──為每份內容生成一個檔案,取得該內容與頭資訊的 SHA-1 校驗和,建立以該校驗和前兩個字元為名稱的子目錄,並以 (校驗和) 剩下 38 個字元為檔案命名 (儲存至子目錄下)。

通過 cat-file 命令可以將資料內容取回。該命令是檢視 Git 物件的瑞士軍刀。傳入 -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 。通過傳遞 SHA-1 值給 cat-file -t 命令可以讓 Git 返回任何物件的型別:

$ git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a blob

tree (樹) 物件

接下去來看 tree 物件,tree 物件可以儲存檔名,同時也允許儲存一組檔案。Git 以一種類似 UNIX 檔案系統但更簡單的方式來儲存內容。所有內容以 tree 或 blob 物件儲存,其中 tree 物件對應於 UNIX 中的目錄,blob 物件則大致對應於 inodes 或檔案內容。一個單獨的 tree 物件包含一條或多條 tree 記錄,每一條記錄含有一個指向 blob 或子 tree 物件的 SHA-1 指標,並附有該物件的許可權模式 (mode)、型別和檔名資訊。以 simplegit 專案為例,最新的 tree 可能是這個樣子:

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

master^{tree} 表示 branch 分支上最新提交指向的 tree 物件。請注意 lib 子目錄並非一個 blob 物件,而是一個指向別一個 tree 物件的指標:

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

從概念上來講,Git 儲存的資料如圖 9-1 所示。


圖 9-1. Git 物件模型的簡化版

你可以自己建立 tree 。通常 Git 根據你的暫存區域或 index 來建立並寫入一個 tree 。因此要建立一個 tree 物件的話首先要通過將一些檔案暫存從而建立一個 index 。可以使用 plumbing 命令update-index 為一個單獨檔案 ── test.txt 檔案的第一個版本 ── 建立一個 index 。通過該命令人為的將 test.txt 檔案的首個版本加入到了一個新的暫存區域中。由於該檔案原先並不在暫存區域中 (甚至就連暫存區域也還沒被創建出來呢) ,必須傳入 --add引數;由於要新增的檔案並不在當前目錄下而是在資料庫中,必須傳入 --cacheinfo 引數。同時指定了檔案模式,SHA-1 值和檔名:

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

在本例中,指定了檔案模式為 100644,表明這是一個普通檔案。其他可用的模式有:100755 表示可執行檔案,120000 表示符號連結。檔案模式是從常規的 UNIX 檔案模式中參考來的,但是沒有那麼靈活 ── 上述三種模式僅對 Git 中的檔案 (blobs) 有效 (雖然也有其他模式用於目錄和子模組)。

現在可以用 write-tree 命令將暫存區域的內容寫到一個 tree 物件了。無需 -w 引數 ── 如果目標 tree 不存在,呼叫 write-tree 會自動根據 index 狀態建立一個 tree 物件。

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

可以這樣驗證這確實是一個 tree 物件:

$ git cat-file -t d8329fc1cc938780ffdd9f94e0d364e0ea74f579 tree

再根據 test.txt 的第二個版本以及一個新檔案建立一個新 tree 物件:

$ echo 'new file' > new.txt $ git update-index test.txt $ git update-index --add new.txt

這時暫存區域中包含了 test.txt 的新版本及一個新檔案 new.txt 。建立 (寫) 該 tree 物件 (將暫存區域或 index 狀態寫入到一個 tree 物件),然後瞧瞧它的樣子:

$ git write-tree 0155eb4229851634a0f03eb265b69f5a2d56f341 $ git cat-file -p 0155eb4229851634a0f03eb265b69f5a2d56f341 100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt 100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt

請注意該 tree 物件包含了兩個檔案記錄,且 test.txt 的 SHA 值是早先值的 “第二版” (1f7a7a)。來點更有趣的,你將把第一個 tree 物件作為一個子目錄加進該 tree 中。可以用 read-tree 命令將 tree 物件讀到暫存區域中去。在這時,通過傳一個 --prefix 引數給 read-tree,將一個已有的 tree 物件作為一個子 tree 讀到暫存區域中:

$ 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

如果從剛寫入的新 tree 物件建立一個工作目錄,將得到位於工作目錄頂級的兩個檔案和一個名為 bak 的子目錄,該子目錄包含了 test.txt 檔案的第一個版本。可以將 Git 用來包含這些內容的資料想象成如圖 9-2 所示的樣子。


圖 9-2. 當前 Git 資料的內容結構

commit (提交) 物件

你現在有三個 tree 物件,它們指向了你要跟蹤的專案的不同快照,可是先前的問題依然存在:必須記往三個 SHA-1 值以獲得這些快照。你也沒有關於誰、何時以及為何儲存了這些快照的資訊。commit 物件為你儲存了這些基本資訊。

要建立一個 commit 物件,使用 commit-tree 命令,指定一個 tree 的 SHA-1,如果有任何前繼提交物件,也可以指定。從你寫的第一個 tree 開始:

$ echo 'first commit' | git commit-tree d8329f fdf4fc3344e67ab068f836878b6c4951e3b15f3d

通過 cat-file 檢視這個新 commit 物件:

$ git cat-file -p fdf4fc3 tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 author Scott Chacon <[email protected]> 1243040974 -0700 committer Scott Chacon <[email protected]> 1243040974 -0700 first commit

commit 物件有格式很簡單:指明瞭該時間點專案快照的頂層樹物件、作者/提交者資訊(從 Git 設理髮店的 user.name 和user.email中獲得)以及當前時間戳、一個空行,以及提交註釋資訊。

接著再寫入另外兩個 commit 物件,每一個都指定其之前的那個 commit 物件:

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

每一個 commit 物件都指向了你建立的樹物件快照。出乎意料的是,現在已經有了真實的 Git 歷史了,所以如果執行 git log 命令並指定最後那個 commit 物件的 SHA-1 便可以檢視歷史:

$ 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 files changed, 1 insertions(+), 0 deletions(-) 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 deletions(-) commit fdf4fc3344e67ab068f836878b6c4951e3b15f3d Author: Scott Chacon <[email protected]> Date: Fri May 22 18:09:34 2009 -0700 first commit test.txt | 1 + 1 files changed, 1 insertions(+), 0 deletions(-)

真棒。你剛剛通過使用低階操作而不是那些普通命令建立了一個 Git 歷史。這基本上就是執行 git add 和 git commit 命令時 Git 進行的工作 ──儲存修改了的檔案的 blob,更新索引,建立 tree 物件,最後建立 commit 物件,這些 commit 物件指向了頂層 tree 物件以及先前的 commit 物件。這三類 Git 物件 ── blob,tree 以及 tree ── 都各自以檔案的方式儲存在 .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

如果你按照以上描述進行了操作,可以得到如圖 9-3 所示的物件圖。


圖 9-3. Git 目錄下的所有物件

物件儲存

之前我提到當儲存資料內容時,同時會有一個檔案頭被儲存起來。我們花些時間來看看 Git 是如何儲存物件的。你將看來如何通過 Ruby 指令碼語言儲存一個 blob 物件 (這裡以字串 “what is up, doc?” 為例) 。使用 irb 命令進入 Ruby 互動式模式:

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

Git 以物件型別為起始內容構造一個檔案頭,本例中是一個 blob。然後新增一個空格,接著是資料內容的長度,最後是一個空位元組 (null byte):

>> header = "blob #{content.length}\0" => "blob 16\000"

Git 將檔案頭與原始資料內容拼接起來,並計算拼接後的新內容的 SHA-1 校驗和。可以在 Ruby 中使用 require 語句匯入 SHA1 digest 庫,然後呼叫 Digest::SHA1.hexdigest() 方法計算字串的 SHA-1 值:

>> store = header + content => "blob 16\000what 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\234K\312\311OR04c(\317H,Q\310,V(-\320QH\311O\266\a\000_\034\a\235"

最後將用 zlib 壓縮後的內容寫入磁碟。需要指定儲存物件的路徑 (SHA-1 值的頭兩個字元作為子目錄名稱,剩餘 38 個字元作為檔名儲存至該子目錄中)。在 Ruby 中,如果子目錄不存在可以用FileUtils.mkdir_p() 函式建立它。接著用 File.open 方法開啟檔案,並用 write() 方法將之前壓縮的內容寫入該檔案:

>> 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

這就行了 ── 你已經建立了一個正確的 blob 物件。所有的 Git 物件都以這種方式儲存,惟一的區別是型別不同 ── 除了字串 blob,檔案頭起始內容還可以是 commit 或 tree 。不過雖然 blob 幾乎可以是任意內容,commit 和 tree 的資料卻是有固定格式的。

9.3  Git References

你可以執行像 git log 1a410e 這樣的命令來檢視完整的歷史,但是這樣你就要記得 1a410e 是你最後一次提交,這樣才能在提交歷史中找到這些物件。你需要一個檔案來用一個簡單的名字來記錄這些 SHA-1 值,這樣你就可以用這些指標而不是原來的 SHA-1 值去檢索了。

在 Git 中,我們稱之為“引用”(references 或者 refs,譯者注)。你可以在 .git/refs 目錄下面找到這些包含 SHA-1 值的檔案。在這個專案裡,這個目錄還沒不包含任何檔案,但是包含這樣一個簡單的結構:

$ find .git/refs .git/refs .git/refs/heads .git/refs/tags $ find .git/refs -type f $

如果想要建立一個新的引用幫助你記住最後一次提交,技術上你可以這樣做:

$ echo "1a410efbd13591db07496601ebc7a059dd55cfe9" > .git/refs/heads/master

現在,你就可以在 Git 命令中使用你剛才建立的引用而不是 SHA-1 值:

$ git log --pretty=oneline master 1a410efbd13591db07496601ebc7a059dd55cfe9 third commit cac0cab538b970a37ea1e769cbbde608743bc96d second commit fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

當然,我們並不鼓勵你直接修改這些引用檔案。如果你確實需要更新一個引用,Git 提供了一個安全的命令 update-ref

$ git update-ref refs/heads/master 1a410efbd13591db07496601ebc7a059dd55cfe9

基本上 Git 中的一個分支其實就是一個指向某個工作版本一條 HEAD 記錄的指標或引用。你可以用這條命令建立一個指向第二次提交的分支:

$ git update-ref refs/heads/test cac0ca

這樣你的分支將會只包含那次提交以及之前的工作:

$ git log --pretty=oneline test cac0cab538b970a37ea1e769cbbde608743bc96d second commit fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

現在,你的 Git 資料庫應該看起來像圖 9-4 一樣。


圖 9-4. 包含分支引用的 Git 目錄物件

每當你執行 git branch (分支名稱) 這樣的命令,Git 基本上就是執行 update-ref 命令,把你現在所在分支中最後一次提交的 SHA-1 值,新增到你要建立的分支的引用。

HEAD 標記

現在的問題是,當你執行 git branch (分支名稱) 這條命令的時候,Git 怎麼知道最後一次提交的 SHA-1 值呢?答案就是 HEAD 檔案。HEAD 檔案是一個指向你當前所在分支的引用識別符號。這樣的引用識別符號——它看起來並不像一個普通的引用——其實並不包含 SHA-1 值,而是一個指向另外一個引用的指標。如果你看一下這個檔案,通常你將會看到這樣的內容:

$ cat .git/HEAD ref: refs/heads/master

如果你執行 git checkout test,Git 就會更新這個檔案,看起來像這樣:

$ cat .git/HEAD ref: refs/heads/test

當你再執行 git commit 命令,它就建立了一個 commit 物件,把這個 commit 物件的父級設定為 HEAD 指向的引用的 SHA-1 值。

你也可以手動編輯這個檔案,但是同樣有一個更安全的方法可以這樣做:symbolic-ref。你可以用下面這條命令讀取 HEAD 的值:

$ git symbolic-ref HEAD refs/heads/master

你也可以設定 HEAD 的值:

$ git symbolic-ref HEAD refs/heads/test $ cat .git/HEAD ref: refs/heads/test

但是你不能設定成 refs 以外的形式:

$ git symbolic-ref HEAD test fatal: Refusing to point HEAD outside of refs/

Tags

你剛剛已經重溫過了 Git 的三個主要物件型別,現在這是第四種。Tag 物件非常像一個 commit 物件——包含一個標籤,一組資料,一個訊息和一個指標。最主要的區別就是 Tag 物件指向一個 commit 而不是一個 tree。它就像是一個分支引用,但是不會變化——永遠指向同一個 commit,僅僅是提供一個更加友好的名字。

正如我們在第二章所討論的,Tag 有兩種型別:annotated 和 lightweight 。你可以類似下面這樣的命令建立一個 lightweight tag:

$ git update-ref refs/tags/v1.0 cac0cab538b970a37ea1e769cbbde608743bc96d

這就是 lightweight tag 的全部 —— 一個永遠不會發生變化的分支。 annotated tag 要更復雜一點。如果你建立一個 annotated tag,Git 會建立一個 tag 物件,然後寫入一個指向指向它而不是直接指向 commit 的 reference。你可以這樣建立一個 annotated tag(-a 引數表明這是一個 annotated tag):

$ git tag -a v1.1 1a410efbd13591db07496601ebc7a059dd55cfe9 -m 'test tag'

這是所建立物件的 SHA-1 值:

$ cat .git/refs/tags/v1.1 9585191f37f7b0fb9444f35a9bf50de191beadc2

現在你可以執行 cat-file 命令檢查這個 SHA-1 值:

$ git cat-file -p 9585191f37f7b0fb9444f35a9bf50de191beadc2 object 1a410efbd13591db07496601ebc7a059dd55cfe9 type commit tag v1.1 tagger Scott Chacon <[email protected]> Sat May 23 16:48:58 2009 -0700 test tag

值得注意的是這個物件指向你所標記的 commit 物件的 SHA-1 值。同時需要注意的是它並不是必須要指向一個 commit 物件;你可以標記任何 Git 物件。例如,在 Git 的原始碼裡,管理者添加了一個 GPG 公鑰(這是一個 blob 物件)對它做了一個標籤。你就可以執行:

$ git cat-file blob junio-gpg-pub

來檢視 Git 原始碼倉庫中的公鑰. Linux kernel 也有一個不是指向 commit 物件的 tag —— 第一個 tag 是在匯入原始碼的時候建立的,它指向初始 tree (initial tree,譯者注)。

Remotes

你將會看到的第四種 reference 是 remote reference(遠端引用,譯者注)。如果你添加了一個 remote 然後推送程式碼過去,Git 會把你最後一次推送到這個 remote 的每個分支的值都記錄在refs/remotes 目錄下。例如,你可以新增一個叫做 origin 的 remote 然後把你的 master 分支推送上去:

$ git remote add origin [email protected]:schacon/simplegit-progit.git $ git push origin master Counting objects: 11, done. Compressing objects: 100% (5/5), done. Writing objects: 100% (7/7), 716 bytes, done. Total 7 (delta 2), reused 4 (delta 1) To [email protected]:schacon/simplegit-progit.git a11bef0..ca82a6d master -> master

然後檢視 refs/remotes/origin/master 這個檔案,你就會發現origin remote 中的 master 分支就是你最後一次和伺服器的通訊。

$ cat .git/refs/remotes/origin/master ca82a6dff817ec66f44342007202690a93763949

Remote 應用和分支主要區別在於他們是不能被 check out 的。Git 把他們當作是標記這些了這些分支在伺服器上最後狀態的一種書籤。

9.4  Packfiles

我們再來看一下 test Git 倉庫。目前為止,有 11 個物件 ── 4 個 blob,3 個 tree,3 個 commit 以及一個 tag:

$ 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/95/85191f37f7b0fb9444f35a9bf50de191beadc2 # tag .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

Git 用 zlib 壓縮檔案內容,因此這些檔案並沒有佔用太多空間,所有檔案加起來總共僅用了 925 位元組。接下去你會新增一些大檔案以演示 Git 的一個很有意思的功能。將你之前用到過的 Grit 庫中的 repo.rb 檔案加進去 ── 這個原始碼檔案大小約為 12K:

$ curl http://github.com/mojombo/grit/raw/master/lib/grit/repo.rb > repo.rb $ git add repo.rb $ git commit -m 'added repo.rb' [master 484a592] added repo.rb 3 files changed, 459 insertions(+), 2 deletions(-) delete mode 100644 bak/test.txt create mode 100644 repo.rb rewrite test.txt (100%)

如果檢視一下生成的 tree,可以看到 repo.rb 檔案的 blob 物件的 SHA-1 值:

$ git cat-file -p master^{tree} 100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt 100644 blob 9bc1dc421dcd51b4ac296e3e5b6e2a99cf44391e repo.rb 100644 blob e3f094f522629ae358806b17daf78246c27c007b test.txt

然後可以用 git cat-file 命令檢視這個物件有多大:

$ git cat-file -s 9bc1dc421dcd51b4ac296e3e5b6e2a99cf44391e 12898

稍微修改一下些檔案,看會發生些什麼:

$ echo '# testing' >> repo.rb $ git commit -am 'modified repo a bit' [master ab1afef] modified repo a bit 1 files changed, 1 insertions(+), 0 deletions(-)

檢視這個 commit 生成的 tree,可以看到一些有趣的東西:

$ git cat-file -p master^{tree} 100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt 100644 blob 05408d195263d853f09dca71d55116663690c27c repo.rb 100644 blob e3f094f522629ae358806b17daf78246c27c007b test.txt

blob 物件與之前的已經不同了。這說明雖然只是往一個 400 行的檔案最後加入了一行內容,Git 卻用一個全新的物件來儲存新的檔案內容:

$ git cat-file -s 05408d195263d853f09dca71d55116663690c27c 12908

你的磁碟上有了兩個幾乎完全相同的 12K 的物件。如果 Git 只完整儲存其中一個,並儲存另一個物件的差異內容,豈不更好?

事實上 Git 可以那樣做。Git 往磁碟儲存物件時預設使用的格式叫鬆散物件 (loose object) 格式。Git 時不時地將這些物件打包至一個叫 packfile 的二進位制檔案以節省空間並提高效率。當倉庫中有太多的鬆散物件,或是手工呼叫 git gc 命令,或推送至遠端伺服器時,Git 都會這樣做。手工呼叫 git gc 命令讓 Git 將庫中物件打包並看會發生些什麼:

$ git gc Counting objects: 17, done. Delta compression using 2 threads. Compressing objects: 100% (13/13), done. Writing objects: 100% (17/17), done. Total 17 (delta 1), reused 10 (delta 0)

檢視一下 objects 目錄,會發現大部分物件都不在了,與此同時出現了兩個新檔案:

$ find .git/objects -type f .git/objects/71/08f7ecb345ee9d0084193f147cdad4d2998293 .git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 .git/objects/info/packs .git/objects/pack/pack-7a16e4488ae40c7d2bc56ea2bd43e25212a66c45.idx .git/objects/pack/pack-7a16e4488ae40c7d2bc56ea2bd43e25212a66c45.pack

仍保留著的幾個物件是未被任何 commit 引用的 blob ── 在此例中是你之前建立的 “what is up, doc?” 和 “test content” 這兩個示例 blob。你從沒將他們新增至任何 commit,所以 Git 認為它們是 “懸空” 的,不會將它們打包進 packfile 。

剩下的檔案是新建立的 packfile 以及一個索引。packfile 檔案包含了剛才從檔案系統中移除的所有物件。索引檔案包含了 packfile 的偏移資訊,這樣就可以快速定位任意一個指定物件。有意思的是執行 gc 命令前磁碟上的物件大小約為 12K ,而這個新生成的 packfile 僅為 6K 大小。通過打包物件減少了一半磁碟使用空間。

Git 是如何做到這點的?Git 打包物件時,會查詢命名及尺寸相近的檔案,並只儲存檔案不同版本之間的差異內容。可以檢視一下 packfile ,觀察它是如何節省空間的。git verify-pack 命令用於顯示已打包的內容:

$ git verify-pack -v \ .git/objects/pack/pack-7a16e4488ae40c7d2bc56ea2bd43e25212a66c45.idx 0155eb4229851634a0f03eb265b69f5a2d56f341 tree 71 76 5400 05408d195263d853f09dca71d55116663690c27c blob 12908 3478 874 09f01cea547666f58d6a8d809583841a7c6f0130 tree 106 107 5086 1a410efbd13591db07496601ebc7a059dd55cfe9 commit 225 151 322 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a blob 10 19 5381 3c4e9cd789d88d8d89c1073707c3585e41b0e614 tree 101 105 5211 484a59275031909e19aadb7c92262719cfcdf19a commit 226 153 169 83baae61804e65cc73a7201a7252750c76066a30 blob 10 19 5362 9585191f37f7b0fb9444f35a9bf50de191beadc2 tag 136 127 5476 9bc1dc421dcd51b4ac296e3e5b6e2a99cf44391e blob 7 18 5193 1 05408d195263d853f09dca71d55116663690c27c \ ab1afef80fac8e34258ff41fc1b867c702daa24b commit 232 157 12 cac0cab538b970a37ea1e769cbbde608743bc96d commit 226 154 473 d8329fc1cc938780ffdd9f94e0d364e0ea74f579 tree 36 46 5316 e3f094f522629ae358806b17daf78246c27c007b blob 1486 734 4352 f8f51d7d8a1760462eca26eebafde32087499533 tree 106 107 749 fa49b077972391ad58037050f2a75f74e3671e92 blob 9 18 856 fdf4fc3344e67ab068f836878b6c4951e3b15f3d commit 177 122 627 chain length = 1: 1 object pack-7a16e4488ae40c7d2bc56ea2bd43e25212a66c45.pack: ok

如果你還記得的話, 9bc1d 這個 blob 是 repo.rb 檔案的第一個版本,這個 blob 引用了 05408 這個 blob,即該檔案的第二個版本。命令輸出內容的第三列顯示的是物件大小,可以看到 05408 佔用了 12K 空間,而 9bc1d 僅為 7 位元組。非常有趣的是第二個版本才是完整儲存檔案內容的物件,而第一個版本是以差異方式儲存的 ── 這是因為大部分情況下需要快速訪問檔案的最新版本。

最妙的是可以隨時進行重新打包。Git 自動定期對倉庫進行重新打包以節省空間。當然也可以手工執行 git gc 命令來這麼做。

9.5  The Refspec

這本書讀到這裡,你已經使用過一些簡單的遠端分支到本地引用的對映方式了,這種對映可以更為複雜。 假設你像這樣添加了一項遠端倉庫:

$ git remote add origin [email protected]:schacon/simplegit-progit.git

它在你的 .git/config 檔案中添加了一節,指定了遠端的名稱 (origin), 遠端倉庫的URL地址,和用於獲取操作的 Refspec:

[remote "origin"] url = [email protected]:schacon/simplegit-progit.git fetch = +refs/heads/*:refs/remotes/origin/*

Refspec 的格式是一個可選的 + 號,接著是 <src>:<dst> 的格式,這裡 <src> 是遠端上的引用格式, <dst> 是將要記錄在本地的引用格式。可選的 + 號告訴 Git 在即使不能快速演進的情況下,也去強制更新它。

預設情況下 refspec 會被 git remote add 命令所自動生成, Git 會獲取遠端上 refs/heads/ 下面的所有引用,並將它寫入到本地的 refs/remotes/origin/. 所以,如果遠端上有一個 master 分支,你在本地可以通過下面這種方式來訪問它的歷史記錄:

$ git log origin/master $ git log remotes/origin/master $ git log refs/remotes/origin/master

它們全是等價的,因為 Git 把它們都擴充套件成refs/remotes/origin/master.

如果你想讓 Git 每次只拉取遠端的 master 分支,而不是遠端的所有分支,你可以把 fetch 這一行修改成這樣:

fetch = +refs/heads/master:refs/remotes/origin/master

這是 git fetch 操作對這個遠端的預設 refspec 值。而如果你只想做一次該操作,也可以在命令列上指定這個 refspec. 如可以這樣拉取遠端的 master 分支到本地的 origin/mymaster 分支:

$ git fetch origin master:refs/remotes/origin/mymaster

你也可以在命令列上指定多個 refspec. 像這樣可以一次獲取遠端的多個分支:

$ git fetch origin master:refs/remotes/origin/mymaster \ topic:refs/remotes/origin/topic From [email protected]:schacon/simplegit ! [rejected] master -> origin/mymaster (non fast forward) * [new branch] topic -> origin/topic

在這個例子中, master 分支因為不是一個可以快速演進的引用而拉取操作被拒絕。你可以在 refspec 之前使用一個 + 號來過載這種行為。

你也可以在配置檔案中指定多個 refspec. 如你想在每次獲取時都獲取 master 和 experiment 分支,就新增兩行:

[remote "origin"] url = [email protected]:schacon/simplegit-progit.git fetch = +refs/heads/master:refs/remotes/origin/master fetch = +refs/heads/experiment:refs/remotes/origin/experiment

但是這裡不能使用部分萬用字元,像這樣就是不合法的:

fetch = +refs/heads/qa*:refs/remotes/origin/qa*

但無論如何,你可以使用名稱空間來達到這個目的。如你有一個QA組,他們推送一系列分支,你想每次獲取 master 分支和QA組的所有分支,你可以使用這樣的配置段落:

[remote "origin"] url = [email protected]:schacon/simplegit-progit.git fetch = +refs/heads/master:refs/remotes/origin/master fetch = +refs/heads/qa/*:refs/remotes/origin/qa/*

如果你的工作流很複雜,有QA組推送的分支、開發人員推送的分支、和整合人員推送的分支,並且他們在遠端分支上協作,你可以採用這種方式為他們建立各自的名稱空間。

推送 Refspec

採用名稱空間的方式確實很棒,但QA組成員第1次是如何將他們的分支推送到 qa/ 空間裡面的呢?答案是你可以使用 refspec 來推送。

如果QA組成員想把他們的 master 分支推送到遠端的 qa/master 分支上,可以這樣執行:

$ git push origin master:refs/heads/qa/master

如果他們想讓 Git 每次執行 git push origin 時都這樣自動推送,他們可以在配置檔案中新增 push 值:

[remote "origin"] url = [email protected]:schacon/simplegit-progit.git fetch = +refs/heads/*:refs/remotes/origin/* push = refs/heads/master:refs/heads/qa/master

這樣,就會讓 git push origin 預設就把本地的 master 分支推送到遠端的 qa/master 分支上。

刪除引用

你也可以使用 refspec 來刪除遠端的引用,是通過執行這樣的命令:

$ git push origin :topic

因為 refspec 的格式是 <src>:<dst>, 通過把 <src> 部分留空的方式,這個意思是是把遠端的 topic 分支變成空,也就是刪除它。

9.6  傳輸協議

Git 可以以兩種主要的方式跨越兩個倉庫傳輸資料:基於HTTP協議之上,和 file://ssh://, 和 git:// 等智慧傳輸協議。這一節帶你快速瀏覽這兩種主要的協議操作過程。

啞協議

Git 基於HTTP之上傳輸通常被稱為啞協議,這是因為它在服務端不需要有針對 Git 特有的程式碼。這個獲取過程僅僅是一系列GET請求,客戶端可以假定服務端的Git倉庫中的佈局。讓我們以 simplegit 庫來看看 http-fetch 的過程:

$ git clone http://github.com/schacon/simplegit-progit.git

它做的第1件事情就是獲取 info/refs 檔案。這個檔案是在服務端運行了 update-server-info 所生成的,這也解釋了為什麼在服務端要想使用HTTP傳輸,必須要開啟 post-receive 鉤子:

=> GET info/refs ca82a6dff817ec66f44342007202690a93763949 refs/heads/master

現在你有一個遠端引用和SHA值的列表。下一步是尋找HEAD引用,這樣你就知道了在完成後,什麼應該被檢出到工作目錄:

=> GET HEAD ref: refs/heads/master

這說明在完成獲取後,需要檢出 master 分支。 這時,已經可以開始漫遊操作了。因為你的起點是在 info/refs 檔案中所提到的ca82a6 commit 物件,你的開始操作就是獲取它:

=> GET