章 8. 揭開面紗

我們揭開Git神秘面紗,往裡瞧瞧它是如何創造奇蹟的。我會跳過細節。更深入的描述參 見 用戶手 冊

大象無形

Git怎麼這麼謙遜寡言呢?除了偶爾提交和合併外,你可以如常工作,就像不知道版本控 制系統存在一樣。那就是,直到你需要它的時候,而且那是你歡欣的時候,Git一直默默 注視着你。

其他版本控制系統強迫你與繁文縟節和官僚主義不斷鬥爭。檔案的權限可能是隻讀的, 除非你顯式地告訴中心伺服器哪些檔案你打算編輯。即使最基本的命令,隨着用戶數目 的增多,也會慢的像爬一樣。中心伺服器可能正跟蹤什麼人,什麼時候check out了什麼 代碼。當網絡連接斷了的時候,你就遭殃了。開發人員不斷地與這些版本控制系統的種 種限製作鬥爭。一旦網絡或中心伺服器癱瘓,工作就嘎然而止。

與之相反,Git簡單地在你工作目錄下的.git`目錄保存你項目的歷史。這是你自己的歷 史拷貝,因此你可以保持離線,直到你想和他人溝通為止。你擁有你的檔案命運完全的 控制權,因為Git可以輕易在任何時候從.git`重建一個保存狀態。

數據完整性

很多人把加密和保持信息機密關聯起來,但一個同等重要的目標是保證信息安全。合理 使用哈希加密功能可以防止無意或有意的數據損壞行為。

一個SHA1哈希值可被認為是一個唯一的160位ID數,用它可以唯一標識你一生中遇到的每 個位元組串。 實際上不止如此:每個位元組串可供任何人用好多輩子。

對一個檔案而言,其整體內容的哈希值可以被看作這個檔案的唯一標識ID數。

因為一個SHA1哈希值本身也是一個位元組串,我們可以哈希包括其他哈希值的位元組串。這 個簡單的觀察出奇地有用:查看“哈希鏈”。我們之後會看Git如何利用這一點來高效地 保證數據完整性。

簡言之,Git把你數據保存在`.git/objects`子目錄,那裡看不到正常檔案名,相反你只 看到ID。通過用ID作為檔案名,加上一些檔案鎖和時間戳技巧,Git把任意一個原始的文 件系統轉化為一個高效而穩定的資料庫。

智能

Git是如何知道你重命名了一個檔案,即使你從來沒有明確提及這個事實?當然,你或許 是運行了 git mv ,但這個命令和 git add 緊接 git rm 是完全一樣的。

Git啟發式地找出相連版本之間的重命名和拷貝。實際上,它能檢測檔案之間代碼塊的移 動或拷貝!儘管它不能覆蓋所有的情況,但它已經做的很好了,並且這個功能也總在改 進中。如果它在你那兒不工作的話,可以嘗試打開開銷更高的拷貝檢測選項,並考慮升 級。

索引

為每個加入管理的檔案,Git在一個名為“index”的檔案裡記錄統計信息,諸如大小, 創建時間和最後修改時間。為了確定檔案是否更改,Git比較其當前統計信息與那些在索 引裡的統計信息。如果一致,那Git就跳過重新讀檔案。

因為統計信息的調用比讀檔案內容快的很多,如果你僅僅編輯了少數幾個檔案,Git几乎 不需要什麼時間就能更新他們的統計信息。

我們前面講過索引是一個中轉區。為什麼一堆檔案的統計數據是一個中轉區?因為添加 命令將檔案放到Git的資料庫並更新它們的統計信息,而無參數的提交命令創建一個提交, 只基于這些統計信息和已經在資料庫裡的檔案。

Git的源起

這個 Linux內核郵件列表帖子 描述了導致Git 的一系列事件。整個討論線索是一個令人着迷的歷史探究過程,對Git史學家而言。

對象資料庫

你數據的每個版本都保存在“對象資料庫”裡,其位於子目錄.git/objects`;其他位 于.git/`的較少數據:索引,分支名,標籤,配置選項,日誌,頭提交的當前位置等。 對象資料庫樸素而優雅,是Git的力量之源。

`.git/objects`裡的每個檔案是一個對象。有3中對象跟我們有關:“blob”對象, “tree”對象,和“commit”對象。

Blob對象

首先來一個小把戲。去一個檔案名,任意檔案名。在一個空目錄:

$ echo sweet > YOUR_FILENAME
$ git init
$ git add .
$ find .git/objects -type f

你將看到 .git/objects/aa/823728ea7d592acc69b36875a482cdf3fd5c8d

我如何在不知道檔案名的情況下知道這個?這是因為以下內容的SHA1哈希值:

"blob" SP "6" NUL "sweet" LF

是 aa823728ea7d592acc69b36875a482cdf3fd5c8d,這裡SP是一個空格,NUL是一個0位元組, LF是一個換行符。你可以驗證這一點,鍵入:

$ printf "blob 6\000sweet\n" | sha1sum

Git基于“內容定址”:檔案並不按它們的檔案名存儲,而是按它們包含內容的哈希值, 在一個叫“blob對象”的檔案裡。我們可以把檔案內容的哈希值看作一個唯一ID,這樣 在某種意義上我們通過他們內容放置檔案。開始的“blob 6”只是一個包含對象類型與 其長度的頭;它簡化了內部存儲。

這樣我可以輕易語言你所看到的。檔案名是無關的:只有裡面的內容被用作構建blob對象。

你可能想知道對相同的檔案什麼會發生。試圖加一個你檔案的拷貝,什麼檔案名都行。 在 .git/objects 的內容保持不變,不管你加了多少。Git只存儲一次數據。

順便說一句,在 .git/objects 裡的檔案用zlib壓縮,因此你不應該直接查看他們。 可以通過zpipe -d 管道, 或者鍵入:

$ git cat-file -p aa823728ea7d592acc69b36875a482cdf3fd5c8d

這漂亮地打印出給定的對象。

Tree對象

但檔案名在哪?它們必定在某個階段保存在某個地方。Git在提交時得到檔案名:

$ git commit  # 輸入一些信息。
$ find .git/objects -type f

你應看到3個對象。這次我不能告訴你這兩個新檔案是什麼,因為它部分依賴你選擇的文 件名。我繼續進行,假設你選了‘`rose’'。如果你沒有,你可以重寫歷史以讓它看起來 像似你做了:

$ git filter-branch --tree-filter 'mv YOUR_FILENAME rose'
$ find .git/objects -type f

現在你硬看到檔案 .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9 ,因為這是以下內容的SHA1哈希值:

"tree" SP "32" NUL "100644 rose" NUL 0xaa823728ea7d592acc69b36875a482cdf3fd5c8d

檢查這個檔案真的包含上面內容通過鍵入:

$ echo 05b217bb859794d08bb9e4f7f04cbda4b207fbe9 | git cat-file --batch

使用zpipe,驗證哈希值是容易的:

$ zpipe -d < .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9 | sha1sum

與查看檔案相比,哈希值驗證更技巧一些,因為其輸出不止包含原始未壓縮檔案。

這個檔案是一個“tree”對象:一組數據包含檔案類型,檔案名和哈希值。在我們的例 子裡,檔案類型是100644,這意味着“rose”是一個一般檔案,並且哈希值指blob對象, 包含“rose”的內容。其他可能檔案類型有可執行,連結或者目錄。在最後一個例子裡, 哈希值指向一個tree對象。

在一些過渡性的分支,你會有一些你不在需要的老的對象,儘管有寬限過期之後,它們 會被自動清除,現在我們還是將其刪除,以使我們比較容易跟上這個玩具例子。

$ rm -r .git/refs/original
$ git reflog expire --expire=now --all
$ git prune

在真實項目裡你通常應該避免像這樣的命令,因為你在破換備份。如果你期望一個乾淨 的倉庫,通常最好做一個新的克隆。還有,直接操作 .git 時一定要小心:如果 Git命令同時也在運行會怎樣,或者突然停電?一般,引用應由 git update-ref -d 刪除,儘管通常手工刪除 refs/original 也是安全的。

Commit對象

我們已經解釋了三個對象中的兩個。第三個是“commit”對象。其內容依賴于提交信息 以及其創建的日期和時間。為滿足這裡我們所有的,我們不得不調整一下:

$ git commit --amend -m Shakespeare  # 改提交信息
$ git filter-branch --env-filter 'export
    GIT_AUTHOR_DATE="Fri 13 Feb 2009 15:31:30 -0800"
    GIT_AUTHOR_NAME="Alice"
    GIT_AUTHOR_EMAIL="alice@example.com"
    GIT_COMMITTER_DATE="Fri, 13 Feb 2009 15:31:30 -0800"
    GIT_COMMITTER_NAME="Bob"
    GIT_COMMITTER_EMAIL="bob@example.com"'  # Rig timestamps and authors.
$ find .git/objects -type f

你現在應看到 .git/objects/49/993fe130c4b3bf24857a15d7969c396b7bc187 是下列 內容的SHA1哈希值:

"commit 158" NUL
"tree 05b217bb859794d08bb9e4f7f04cbda4b207fbe9" LF
"author Alice <alice@example.com> 1234567890 -0800" LF
"committer Bob <bob@example.com> 1234567890 -0800" LF
LF
"Shakespeare" LF

和前面一樣,你可以運行zpipe或者cat-file來自己看。

這是第一個提交,因此沒有父提交,但之後的提交將總有至少一行,指定一個父提交。

沒那麼神

Git的秘密似乎太簡單。看起來似乎你可以整合幾個shell腳本,加幾行C代碼來弄起來, 也就幾個小時的事:一個基本檔案操作和SHA1哈希化的混雜,用鎖檔案裝飾一下,檔案 同步保證健壯性。實際上,這準確描述了Git的最早期版本。儘管如此,除了巧妙地打包 以節省空間,巧妙地索引以省時間,我們現在知道Git如何靈巧地改造檔案系統成為一個 對版本控制完美的資料庫。

例如,如果對象資料庫裡的任何一個檔案由於硬碟錯誤損毀,那麼其哈希值將不再匹配, 這個錯誤會報告給我們。通過哈希化其他對象的哈希值,我們在所有層面維護數據完整 性。Commit對象是原子的,也就是說,一個提交永遠不會部分地記錄變更:在我們已經 存儲所有相關tree對象,blob對象和父commit對象之後,我們才可以計算提交的的哈希 值並將其存儲在資料庫,對象資料庫不受諸如停電之類的意外中斷影響。

我們打敗即使是最狡猾的對手。假設有誰試圖悄悄修改一個項目裡一個遠古版本檔案的 內容。為使對象據庫看起來健康,他們也必須修改相應blob對象的哈希值,既然它現在 是一個不同的位元組串。這意味着他們講不得不引用這個檔案的tree對象的哈希值,並反 過來改變所有與這個tree相關的commit對象的哈希值,還要加上這些提交所有後裔的哈 希值。這暗示官方head的哈希值與這個壞倉庫不同。通過跟蹤不匹配哈希值線索,我 們可以查明殘缺檔案,以及第一個被破壞的提交。

總之,只要20個位元組代表最後一次提交的是安全的,不可能篡改一個Git倉庫。

那麼Git的著名功能怎樣呢?分支?合併?標籤?單純的細節。當前head保存在檔案 .git /HEAD ,其中包含了一個commit對象的哈希值。該哈希值在運行提交以及其他命 令是更新。分支几乎一樣:它們是保存在 .git/refs/heads 的檔案。標籤也是:它們 住在住在 .git/refs/tags ,但它們由一套不同的命令更新。