> 作者:[Susan Potter](http://www.aosabook.org/en/intro2.html#potter-susan),翻譯:張吉
> 原文:http://www.aosabook.org/en/git.html
## 6.1 Git概述
Git能夠讓不同的協作者通過一個點對點的倉庫網絡對數據內容(通常是代碼,當然不僅限于代碼)進行維護。它支持分布式的工作流程,能夠讓數據內容臨時分離,并最終合并到一起。
本章將闡述Git的內部實現是如何提供以上功能的,以及它和其他版本控制系統(VCS)的區別。
## 6.2 Git起源
為了更好地理解Git的設計思想,我們有必要先了解一下Git項目的發源地——Linux內核開發社區——所面臨的問題。
Linux內核開發與其他商業軟件項目有很大不同,因為它的開發者眾多,且每個開發者的參與程度和對Linux內核代碼的理解有很大差異。多年以來,內核代碼一直都是以Tar壓縮文件以及補丁的形式維護的,而當時的核心開發團隊一直在尋找一個能夠滿足他們各方面需求的版本控制系統。
Git就是在這樣的背景下于2005年作為一款開源軟件誕生的。當時,Linux內核代碼通過兩種版本控制系統進行維護,BitKeeper和CVS,分別由兩組核心開發團隊使用。BitKeeper相較于當時頗為流行的CVS,提供了一種不同的歷史展示方式。
當BitKeeper的所有者BitMover決定收回Linux內核開發人員的使用許可時,Linux Torvalds緊急開啟了一個項目,也就是后來的Git。一開始,他通過編寫一組Shell腳本來幫助他將郵件中的補丁按順序應用到代碼中。這組原始腳本能夠在代碼合并過程中迅速中斷,讓維護者能夠進行人工干預,修改代碼,然后繼續合并。
從項目開始之初,Torvalds就為Git制定了一個目標——要和CVS的做法完全相反——同時還包含了以下三條設計目標:
* 支持分布式的協作流程,類似BitKeeper
* 預防代碼錯亂
* 高性能
這些設計目標都被實現了,我會在下文中通過解析Git的各種做法來闡述,包括在內容管理中使用有向無環圖([DAG](http://en.wikipedia.org/wiki/Directed_acyclic_graph)),頭指針引用,對象模型,遠程協議,以及Git如何追蹤合并樹。
雖然Git設計之初受到了很多BitKeeper的影響,但是兩者還是有根本上的區別的,如Git提供了更多分布式和本地開發流程,這點是BitKeeper做不到的。[Monotone](http://www.monotone.ca/),2003年啟動的一個開源分布式版本控制系統,也對Git的早期開發產生了影響。
分布式版本控制系統在提供更靈活的工作流程的同時,往往會增加它的復雜程度。分布式模型的獨特優點有:
* 能夠線下進行增量提交
* 開發者可以決定自己的代碼何時能夠開放出來
* 能夠線下瀏覽歷史
* 可以將工作成果發布到不同的倉庫,以不同的分支、不同的提交粒度展現出來
在Git項目的開發期間,誕生了其他三個開源分布式版本控制系統(其中Mercurial可以參見《開源軟件架構》的第一卷)。這些分布式版本控制系統(dVCS)都提供了非常靈活的工作流程,這是先前的集中式版本控制系統做不到的。注意:Subversion有一款插件名為SVK,由不同的開發者維護,提供了服務器之間的同步功能。
目前流行的dVCS包括Bazaar, Darcs, Fossil, Git, Mercurial, 以及Veracity。
## 6.3 版本控制系統的設計
現在讓我們回過頭來看看Git之外的其他版本控制系統是如何設計的。通過比較他們和Git之間的區別,可以幫助我們去理解Git在架構設計中的選擇。
版本控制系統通常有三項核心功能(需求):
* 保存內容
* 記錄變更歷史(包括具體的合并信息)
* 向協作者分發內容和變更歷史
注意:第三項并不是所有版本控制系統的核心功能。
### 保存內容
在VCS中保存內容,最普遍的做法是保存增量的修改,或使用有向無環圖(DAG)。
增量修改可以反映出兩個版本之間的內容差異,以及一些額外的信息。使用有向無環圖保存內容則是將特定對象構造成一種樹狀結構,作為某一次提交的快照保存下來(樹狀結構中未發生變化的對象是可以重用的)。Git使用有向無環圖來保存內容,它所使用的不同對象類型會在本文的“對象數據庫”一節中有所描述。
### 提交和合并的歷史
在保存歷史、記錄變化方面,大部分VCS使用以下方式之一:
* 線性歷史
* 有向無環圖
Git使用的還是有向無環圖,這次則是用來保存歷史。每次提交包含了它父節點的元信息——Git中的一次提交可以擁有0個或多個父節點(理論上沒有個數限制)。例如,Git倉庫的第一次提交就沒有父節點,而一次三頭合并則有三個父節點。
Git和SVN線性提交的另一個重要區別是Git可以直接進行分支的創建,并記錄下大部分合并歷史。

圖6.1:Git中有向無環圖示例
通過采用有向無環圖保存內容,Git能夠提供完整的分支功能。一個文件的歷史會通過它所處的目錄結構位置和根節點關聯起來,并最終和一個提交節點關聯。這個提交節點又會有一個或多個父節點。這種組織方式提供了以下兩個特性,讓我們能夠更好地在Git瀏覽文件歷史和內容:
* 當內容節點(文件或目錄)在有向無環圖中有相同的標識(Git中以SHA碼表示),即使它們處于不同的提交節點,也能保證它們的內容是一致的,從而使得Git在差異比對時更為高效。
* 在對兩個分支進行合并時,實質上是在對兩個有向無環圖節點進行合并。有向無環圖能夠讓Git更為高效地判斷出他們共同的父節點。
### 內容分發
版本控制系統在向協作者分發內容時通常有以下三種做法:
* 僅限本地:某些版本控制系統沒有上文提到的第三項需求。
* 中央服務器:版本庫的所有改動都必須在一個中央版本庫中進行,也只有這個版本庫會記錄歷史。
* 分布式模型:雖然分布式模型中也會有一個中央倉庫供協作者“推送”自己的改動,但協作者可以在本地進行提交,并稍后再推送到遠程。
為了展示以上設計模式的優點和不足,我們設想這樣一個應用場景:一個SVN倉庫和一個Git倉庫,有著相同的內容(即Git默認分支的頭指針指向的內容和SVN倉庫最新的trunk分支內容一致)。一個名叫Alex的開發者在本地檢出了一份SVN代碼,以及克隆了一個Git版本庫。
假設Alex在本地對一個1M大小的文件進行了修改,并進行了提交。提交后本地更新了元信息,遠程服務器則是將文件的差異記錄了下來。
Git下則有所不同。Alex對文件的變動首先會在本地進行記錄,然后再“推送”到遠程的公共倉庫,這樣文件的改動就能被其他開發者看到了。文件內容的變動記錄在不同的版本庫之中的表示方式是完全一致的。除了本地提交之外,Git會為變動后的文件創建一個對象來保存它(包括其完整的內容),然后逐層為該文件的父目錄創建對象,直至倉庫根目錄。接下來Git會創建一個有向無環圖,從剛才新創建的根目錄節點開始,指向各個二進制單元(期間會重用那些內容沒有改變的二進制單元),并使用新創建的二進制單元去替代那些變動的部分(一個二進制單元通常用來表示一個文件)。
到此為止,本次提交還是只保存在Alex克隆下來的本地倉庫中。當Alex將這個提交推送到遠程倉庫后,遠程倉庫會驗證這次提交是否能應用到當前分支中,然后這些對象將會按照原樣保存下來,如同在本地倉庫中創建的一樣。
在Git中會有很多可變動的部分,有些對用戶是透明的,有些則需要用戶顯示地指定這些內容是否需要分享出來,或是只在本地保存。雖然增加了復雜性,但也提供給團隊開發者更大的自由度,得以更好地控制工作流程和發布內容,這在“Git起源”一節中已經有所闡述。
在SVN中,開發者不會忘記將變動內容提交至遠程倉庫。從效率上講,SVN僅保存變動內容的方式會比Git保存文件每個版本的完整內容要來得高效,但是之后我們會講述Git其實已經通過某種方式對此進行了優化。
## 6.4 工具包
如今,Git已然形成一個生態系統,在各種操作系統上(包括Windows)都開發出了大量命令行和圖界面工具,而他們大部分都是構建在Git核心工具包之上的。
由于Git是Linus發起和開發的,它又立足于Linux社區,因此Git工具包的設計理念和傳統的Unix命令行工具相仿。
Git工具包分為兩個部分:底層命令和上層命令。底層命令提供了基本的內容追蹤手段,以及直接操縱有向無環圖。上層命令則是用戶主要接觸的命令,用以維護倉庫,以及在多個倉庫間進行協作。
雖然Git工具包提供了足夠多的命令來操縱倉庫,但是開發者們還是抱怨Git沒有提供類庫以供調用。Git命令最終會執行die()方法,使得GUI和Web界面在使用它時必須啟動一個新的進程,效率較低。
不過這一問題已經得到處理,我會在本文的“當前進展和未來規劃”一節加以闡述。
## 6.5 版本庫、暫存區、工作區
讓我們開始深入研究一下Git吧,了解其中幾個關鍵概念。
首先讓我們在本地創建一個Git版本庫。在類Unix系統下,我們可以執行以下命令:
~~~
$ mkdir testgit
$ cd testgit
$ git init
~~~
這樣我們就在testgit目錄中初始化了一個新的版本庫。我們可以建立分支、提交、創建里程碑、和遠程Git倉庫進行交互。我們甚至可以和其他類型的版本控制系統進行交互,只需要借助若干`git`命令即可。
`git init`命令會在testgit目錄下創建一個名為.git的子目錄。我們來看一下這個目錄的結構:
~~~
tree .git/
.git/
|-- HEAD
|-- config
|-- description
|-- hooks
| |-- applypatch-msg.sample
| |-- commit-msg.sample
| |-- post-commit.sample
| |-- post-receive.sample
| |-- post-update.sample
| |-- pre-applypatch.sample
| |-- pre-commit.sample
| |-- pre-rebase.sample
| |-- prepare-commit-msg.sample
| |-- update.sample
|-- info
| |-- exclude
|-- objects
| |-- info
| |-- pack
|-- refs
|-- heads
|-- tags
~~~
`.git`目錄默認創建在工作區的根目錄下,也就是`testgit`。它包含了以下幾種類型的文件和目錄:
* *配置文件*:?`.git/config`、`.git/description`、`.git/info/exclude`,這些文件會用來配置本地倉庫。
* *鉤子*:?`.git/hooks`目錄下的腳本可以在Git運作的各個環節中得到執行。
* *暫存區*:?`.git/index`文件(它并沒有在上述目錄結構中顯示出來)會用來保存工作區準備提交的內容。
* *對象數據庫*:?`.git/objects`是默認的Git對象數據庫存放目錄,囊括了本地倉庫的所有文件內容和指針。對象一經創建則不能修改。
* *引用*:`.git/refs`目錄用來存放本地和遠程倉庫的分支、里程碑、頭指針等信息。“引用”表示指向某個對象指針,通常是`tag`和`commit`類型。引用之所以放置在對象數據庫之外,是為了讓他們能夠隨版本庫的演進而變化。特殊的引用可以指向其他引用,如`HEAD`。
`.git`目錄是真正意義上的版本庫。工作區指的是包含所有工作文件的目錄,它通常是`.git`目錄的父目錄。如果你需要創建一個沒有工作區的遠程倉庫,可以使用`git init --bare`命令。它會直接在根目錄下生成Git倉庫的各類文件,而不是放置在一個子目錄中。
另一個較為重要的文件是Git暫存區:`.git/index`。它在工作區和本地版本庫之間增加了一個緩沖區,可以將需要提交的內容暫存在這里,最后一起提交。即使你對很多文件進行了修改,通過暫存區可以將它們作為一次完整的提交,并加注合理的注釋。如果想將工作區某些文件的部分修改保存至暫存區,可以使用`git add -p`命令。
Git暫存區里的內容默認保存在單個文件中。版本庫、暫存區、工作區的存放位置都是可以通過環境變量來進行配置的。
我們有必要了解一下以上三個區域的文件是如何進行交互的,以幾個核心的Git命令舉例:
* `git checkout [branch]`
這條命令會將HEAD引用指向指定分支的引用(如`refs/heads/master`),并用該引用指向的內容替換掉暫存區和工作區中的內容。
* `git add [files]`
這條命令會檢驗工作區中指定的文件和暫存區是否一致,若不一致則更新暫存區。版本庫不會發生變化。
為了深入挖掘其中的原理,讓我們看看`.git`目錄下的文件都發生了哪些變化:
~~~
$ GIT_DIR=$PWD/.git
$ cat $GIT_DIR/HEAD
ref: refs/heads/master
$ MY_CURRENT_BRANCH=$(cat .git/HEAD | sed 's/ref: //g')
$ cat $GIT_DIR/$MY_CURRENT_BRANCH
cat: .git/refs/heads/master: No such file or directory
~~~
這里會返回一個錯誤信息,因為我們還沒有在Git倉庫中進行過任何提交,因此不會存在任何分支,包括默認分支`master`。
讓我們進行一次提交,這時master分支會自動創建:
~~~
$ git commit -m "Initial empty commit" --allow-empty
$ git branch
* master
$ cat $GIT_DIR/$MY_CURRENT_BRANCH
3bce5b130b17b7ce2f98d17b2998e32b1bc29d68
$ git cat-file -p $(cat $GIT_DIR/$MY_CURRENT_BRANCH)
~~~
輸出的內容就是Git對象數據庫中保存的信息了。
## 6.6 對象數據庫

圖6.2:Git對象
Git有四種基本對象類型,版本庫中的所有內容都是由這些基本對象類型構成的。每種對象類型包含以下屬性:*類型*、*大小*、*內容*。這四種基本對象類型是:
* *樹*:用來表示目錄結構,樹中的元素可以是另一棵樹或是一個二進制單元。
* *二進制單元*:表示一個文件。
* *提交*:提交會指向一個根節點樹對象,并保存父提交的信息和其他基本信息。
* *里程碑*:里程碑有一個名稱,并指向版本庫中的一個提交對象。
所有的基本對象類型使用SHA碼來標識,它是一種40位的十六進制字符串,含有以下特性:
* 如果兩個對象是一致的,則SHA碼一致。
* 如果兩個對象不一致,則SHA碼也不一致。
* 如果只是拷貝了對象的一部分,或者對象的數據發生了其他更改,只需重新計算其SHA碼就能區別開來。
前兩種屬性使得Git能夠實施它的分布式模型(Git的第二個目標),第三種屬性則是杜絕了數據混亂(第三個目標)。
## 6.7 存儲和壓縮技術
Git是通過壓縮數據內容來解決存儲大小的問題的,它使用一個索引文件來標識對象內容在壓縮文件中的實際位置。

圖6.3:壓縮文件和它對應的索引文件
我們可以使用`git count-objects`來查看版本庫中未經壓縮的對象數量,然后讓Git對未壓縮的對象進行壓縮,刪除冗余的對象等。
Git對象的壓縮方式進行過升級。過去,壓縮文件和索引文件的CRC校驗碼會全部保存在索引文件中,這就無法檢測出壓縮對象中存在的數據混亂,因為后續壓縮過程中不會再進行校驗。新版本(Version 2)的壓縮格式中將每個壓縮對象的CRC校驗碼都保存了下來,從而解決了這一問題。同時,新版格式允許壓縮文件大于4GB,這在以前是不支持的。為了更快地檢測壓縮文件是否損壞,文件末尾會保存一個20個字節的SHA1碼,對壓縮文件中所有對象的SHA碼進行排序和校驗。新版壓縮格式的主要目的是為了滿足Git設計目標中的杜絕數據混亂。
對于遠程傳輸,Git會計算同步版本庫(或分支)需要傳輸的提交和文件內容,生成相應的壓縮文件,通過指定協議進行傳輸。
## 6.8 記錄合并歷史
上文我有提到過,Git和其它類RCS的版本控制系統的最大區別在于對合并歷史的記錄。如SVN將文件和目錄結構的改動用線性提交來表示,版本號高的內容一定會覆蓋版本號低的內容。因此,SVN不能直接提供分支功能,而是使用一種人為規定的目錄結構來實現:

圖6.4:合并歷史的圖形表示
首先讓我們用一個示例來說明要維護多個分支會多么麻煩,而且在某些場景下是有局限性的。
SVN中的“分支”通常放置在`branches/branch-name`,它和主干分支`trunk`(相當于?*master*?)目錄同級。我們假設這個分支和主干分支是并行開發的。
舉例來說,我們可能需要修改某個軟件的數據庫連接類型。在此過程中,我們想要將其他分支(非trunk)的內容合并到當前分支中。合并完成后(可能需要手工合并),我們繼續修改。全部完成后,我們需要將`branches/branch-name`分支合并到`trunk`中。在類似SVN的線性歷史版本控制系統中,我們無法得知其他分支的內容是否已經包含在trunk中了。
而對于以有向無環圖為基礎的版本控制系統(如Git)來說,就能很好地處理這種應用場景。如果某個分支不含沒有合并至當前分支(如`db-migration`)的“提交”,我們就可以通過“提交”對象的繼承關系來確定`db-migration`分支包含了那個分支的`HEAD`引用。由于“提交”對象可以包含零個或多個父提交,因此就能通過`db-migration`中的那次合并提交的信息來確定當前HEAD包含了兩個分支中的內容。同理,當將`db-migration`合并至`master`分支時也能確認這些關系。
然而有一個問題無論是使用有向無環圖還是線性提交都無法解決的,就是判斷某個提交是否存在于每個分支中。例如上述例子中,我們假設已經將每個分支的提交都合并到各個分支去了。并不是所有情況下都是如此。
對于較為簡單的情況,Git可以將其它分支的“提交”揀選(`cherry-pick`)到當前分支中,當然前提是這次提交必須是能夠直接應用進來的。
## 6.9 下一步做什么?
上文提到,Git采用來自Unix世界的工具包設計理念,因此非常適合用來編寫腳本。但是,當需要在長時間運行的應用程序或服務中內嵌Git工具庫的話就不太容易了。雖然目前流行的IDE都提供了Git圖形化界面,但開發這些工具所需花費的精力還是比其他版本控制系統要多,因為它們提供了便于使用的鏈接庫。
為了解決這個問題,Shawn Pearce(來自谷歌開源程序辦公室)率先實現了一個可供鏈接的Git類庫,且發布協議較為寬松,因此沒有阻礙該類庫的推廣。這個類庫的名字是[libgit2](https://github.com/libgit2/libgit2)。一開始它并不流行,直到一個名叫Vincent Marti的學生在谷歌編程夏令營中使用了它。從那以后,Vincent和Github持續對libgit2類庫貢獻代碼,并為其他語言編寫了相應類庫,包括Ruby,Python,PHP,.NET,Lua,Object-C等。
Shawn Pearce還開啟了一個名為[JGit](https://github.com/eclipse/jgit)的BSD項目,使用純Java語言實現,能夠對Git版本庫進行基本的操作。該類庫現在由Eclipse基金會維護,用于Eclipse IDE的Git插件中。
還有其他一些有趣的周邊項目,帶有實驗性質,使用各類數據源來保存Git對象,如:
* [jgit_cassandra](https://github.com/spearce/jgit_cassandra)?使用Apache Cassandra作為Git對象數據庫。它是一種混合型的數據源,提供了動態的BigTable式的數據模型。
* [jgit_hbase](https://github.com/spearce/jgit_hbase)?能夠將Git對象保存在HBase中,一種KV型分布式數據庫。
* [libgit2-backends](https://github.com/libgit2/libgit2-backends)?由libgit2項目衍生而來,致力于提供其他種類的數據源,如Memcached,Redis,SQLite,MySQL。
以上這些都是獨立于Git核心工具包之外的項目。
如你所見,我們可以用各種方式來使用Git,它的表現形式不再只有命令行這一種了,而是成為一種版本控制系統的協議。
在本文撰寫之時,這些項目都還沒有發布穩定版本,所以還是有很多工作要做,但整體看來未來是光明的。
## 6.10 經驗教訓
在軟件設計中,任何一個決定都有正反兩面。作為一個在日常工作中大量使用Git,并且還為Git對象數據庫開發了周邊軟件的程序員,我覺得Git目前的組織方式非常棒。因此,下文提到的“經驗教訓”更多的是來自其他開發者對于Git目前設計方式的不滿,主要歸咎于Git核心開發者當初做出的決定。
最常見的問題在于Git相較于其他CVS不能很好地和IDE進行整合,因為Git是基于工具包設計的,整合起來會比較具有挑戰性。
早期Git的實現是采用shell腳本的方式,不能很好地跨平臺,特別是對于Windows操作系統。雖然我相信Git開發者不會因為這個問題而寢食難安,但這的確阻礙了Git在大型公司內的推廣。現在,有一個名為Git for Windows的項目由志愿者發起,及時地將最新的Git開發成果移植到Windows平臺上。
Git工具包的設計方式所帶來的另一個間接影響是,他的底層命令繁多,會讓初學者陷入困境,難以理解Git出錯時拋出的異常信息,最后無可適從。這就使得Git在某些開發團隊中的推廣受到阻礙。
即便如此,我仍然對Git核心項目以及其周邊項目的開發充滿信心。
- 前言(卷一)
- 卷1:第1章 Asterisk
- 卷1:第3章 The Bourne-Again Shell
- 卷1:第5章 CMake
- 卷1:第6章 Eclipse之一
- 卷1:第6章 Eclipse之二
- 卷1:第6章 Eclipse之三
- 卷1:第8章 HDFS——Hadoop分布式文件系統之一
- 卷1:第8章 HDFS——Hadoop分布式文件系統之二
- 卷1:第8章 HDFS——Hadoop分布式文件系統
- 卷1:第12章 Mercurial
- 卷1:第13章 NoSQL生態系統
- 卷1:第14章 Python打包工具
- 卷1:第15章 Riak與Erlang/OTP
- 卷1:第16章 Selenium WebDriver
- 卷1:第18章 SnowFlock
- 卷1:第22章 Violet
- 卷1:第24章 VTK
- 卷1:第25章 韋諾之戰
- 卷2:第1章 可擴展Web架構與分布式系統之一
- 卷2:第1章 可擴展Web架構與分布式系統之二
- 卷2:第2章 Firefox發布工程
- 卷2:第3章 FreeRTOS
- 卷2:第4章 GDB
- 卷2:第5章 Glasgow Haskell編譯器
- 卷2:第6章 Git
- 卷2:第7章 GPSD
- 卷2:第9章 ITK
- 卷2:第11章 matplotlib
- 卷2:第12章 MediaWiki之一
- 卷2:第12章 MediaWiki之二
- 卷2:第13章 Moodle
- 卷2:第14章 NginX
- 卷2:第15章 Open MPI
- 卷2:第18章 Puppet part 1
- 卷2:第18章 Puppet part 2
- 卷2:第19章 PyPy
- 卷2:第20章 SQLAlchemy
- 卷2:第21章 Twisted
- 卷2:第22章 Yesod
- 卷2:第24章 ZeroMQ