我們揭開Git神秘面紗,往里瞧瞧它是如何創造奇跡的。我會跳過細節。更深入的描述參 見?[用戶手 冊](http://www.kernel.org/pub/software/scm/git/docs/user-manual.html)。
[TOC]
## 大象無形
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內核郵件列表帖子](http://lkml.org/lkml/2005/4/6/121)?描述了導致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](http://www.zlib.net/zpipe.c)?管道, 或者鍵入:
~~~
$ 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`?,但它們由一套不同的命令更新。