前言
在本章中,我們將介紹一個分布式版本控制系統(tǒng)的設(shè)計思路,以及它與集中式版本控制系統(tǒng)的不同之處。除此之外,我們還將帶你了解分布式版本庫的具體工作方式,以及為什么我們會說,在Git中創(chuàng)建分支和合并分支不是個大不了的問題。

先說集中式版本控制系統(tǒng),版本庫是集中存放在中央服務(wù)器的,而干活的時候,用的都是自己的電腦,所以要先從中央服務(wù)器取得最新的版本,然后開始干活,干完活了,再把自己的活推送給中央服務(wù)器。中央服務(wù)器就好比是一個圖書館,你要改一本書,必須先從圖書館借出來,然后回到家自己改,改完了,再放回圖書館。

在分布式版本控制系統(tǒng)中,開發(fā)者環(huán)境與服務(wù)器環(huán)境之間是沒有分隔的。每一個開發(fā)者都同時擁有一個用于當(dāng)前文件操作的工作區(qū)與一個用于存儲該項目所有版本、分支以及標(biāo)簽的本地版本庫(我們稱其為一份克隆)。每個開發(fā)者的修改都會被載入成一次次的新版本提交(commit), 首先提交到其本地版本庫中。然后,其他開發(fā)者就會立即看到新的版本。通過推送(push)和拉回(pull)命令,我們可以將這些修改從一個版本庫傳送到另一個版本庫中。這樣一來,從技術(shù)上來看,這里所有的版本庫在分布式架構(gòu)上的地位是同等的。因此從理論上來講,我們不再需要借助服務(wù)器,就可以將某一臺開發(fā)工作機上所做的所有修改直接傳送給另一開發(fā)工作機。相較于SVN 的每一次 commit 都需要聯(lián)網(wǎng),這就需要網(wǎng)絡(luò)的等待。 Git則可以在無網(wǎng)絡(luò)情況下本地提交,他把提交版本與推送服務(wù)器這兩個概念分開了。Git只有在Push、Pull 的時候需要聯(lián)網(wǎng),而我們平時更多的操作應(yīng)是commit。
Git是去中心,它的服務(wù)器并非必須的,我們可以把Git服務(wù)器(Github)看成一個始終在線的參與者,它不寫代碼,只接受大家的推送與合并,方便大家交流使用。當(dāng)然在具體實踐中,Git中的服務(wù)器版本庫也扮演了重要的角色。
1.Git文件快照
Git 和其他版本控制系統(tǒng)的主要差別在于,Git 只關(guān)心文件數(shù)據(jù)的整體是否發(fā)生變化,而大多數(shù)其他系統(tǒng)則只關(guān)心文件內(nèi)容的具體差異。這類系統(tǒng)(CVS,Subversion,Perforce,Bazaar 等等)每次記錄有哪些文件作了更新,以及都更新了哪些行的什么內(nèi)容,請看圖。

Git 并不保存這些前后變化的差異數(shù)據(jù)。實際上,Git 更像是把變化的文件作快照后,記錄在一個微型的文件系統(tǒng)中。每次提交更新時,它會縱覽一遍所有文件的指紋信息并對文件作一快照,然后保存一個指向這次快照的索引。為提高性能,若文件沒有變化,Git 不會再次保存,而只對上次保存的快照作一鏈接。Git 的工作方式就像圖 1-5 所示。

這是 Git 同其他系統(tǒng)的重要區(qū)別。它完全顛覆了傳統(tǒng)版本控制的套路,并對各個環(huán)節(jié)的實現(xiàn)方式作了新的設(shè)計。Git 更像是個小型的文件系統(tǒng),但它同時還提供了許多以此為基礎(chǔ)的超強工具,而不只是一個簡單的 VCS。稍后在第三章討論 Git 分支管理的時候,我們會再看看這樣的設(shè)計究竟會帶來哪些好處。

這是項目的三個版本,版本1中有兩個文件A和B,然后修改了A,變成了A1,形成了版本2,接著又修改了B變?yōu)锽1,形成了版本3。
如果我們把項目的每個版本都保存到本地倉庫,需要保存至少6個文件,而實際上,只有4個不同的文件,A、A1、B、B1。為了節(jié)省存儲的空間,我們要像一個方法將同樣的文件只需要保存一份。這就引入了Sha-1算法。
可以使用git命令計算文件的 sha-1 值。
echo 'test content' | git hash-object --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4
SHA-1將文件中的內(nèi)容通過通過計算生成一個 40 位長度的hash值。
Sha-1的非常有特點:
- 由文件內(nèi)容計算出的hash值
- hash值相同,文件內(nèi)容相同
對于上圖中的內(nèi)容,無論我們執(zhí)行多少次,都會得到相同的結(jié)果。因此,文件的sha-1值是可以作為文件的唯一 id 。同時,它還有一個額外的功能,校驗文件完整性。
有了 sha-1 的幫助,我們可以對項目版本的存儲方式做一下調(diào)整。

2.Git 文件的三種狀態(tài)
對于任何一個文件,在 Git 內(nèi)都只有三種狀態(tài):已提交(committed),已修改(modified)和已暫存(staged)。已提交表示該文件已經(jīng)被安全地保存在本地數(shù)據(jù)庫中了;已修改表示修改了某個文件,但還沒有提交保存;已暫存表示把已修改的文件放在下次提交時要保存的清單中。
由此我們看到 Git 管理項目時,文件流轉(zhuǎn)的三個工作區(qū)域:Git 的工作目錄,暫存區(qū)域,以及本地倉庫。

每個項目都有一個 Git 目錄(譯注:如果 git clone 出來的話,就是其中 .git 的目錄;如果 git clone --bare 的話,新建的目錄本身就是 Git 目錄。),它是 Git 用來保存元數(shù)據(jù)和對象數(shù)據(jù)庫的地方。該目錄非常重要,每次克隆鏡像倉庫的時候,實際拷貝的就是這個目錄里面的數(shù)據(jù)。
從項目中取出某個版本的所有文件和目錄,用以開始后續(xù)工作的叫做工作目錄。這些文件實際上都是從 Git 目錄中的壓縮對象數(shù)據(jù)庫中提取出來的,接下來就可以在工作目錄中對這些文件進(jìn)行編輯。
所謂的暫存區(qū)域只不過是個簡單的文件,一般都放在 Git 目錄中。有時候人們會把這個文件叫做索引文件,不過標(biāo)準(zhǔn)說法還是叫暫存區(qū)域。
加入暫存區(qū)的原因有以下幾點:
- 為了能夠?qū)崿F(xiàn)部分提交
- 為了不再工作區(qū)創(chuàng)建狀態(tài)文件、會污染工作區(qū)。
- 暫存區(qū)記錄文件的修改時間等信息,提高文件比較的效率。
基本的 Git 工作流程如下:
- 在工作目錄中修改某些文件。
- 對修改后的文件進(jìn)行快照,然后保存到暫存區(qū)域。
- 提交更新,將保存在暫存區(qū)域的文件快照永久轉(zhuǎn)儲到 Git 目錄中。
所以,我們可以從文件所處的位置來判斷狀態(tài):如果是 Git 目錄中保存著的特定版本文件,就屬于已提交狀態(tài);如果作了修改并已放入暫存區(qū)域,就屬于已暫存狀態(tài);如果自上次取出后,作了修改但還沒有放到暫存區(qū)域,就是已修改狀態(tài)。到第二章的時候,我們會進(jìn)一步了解其中細(xì)節(jié),并學(xué)會如何根據(jù)文件狀態(tài)實施后續(xù)操作,以及怎樣跳過暫存直接提交。
為了理解 Git 分支的實現(xiàn)方式,我們需要回顧一下 Git 是如何儲存數(shù)據(jù)的。或許你還記得第一章的內(nèi)容,Git 保存的不是文件差異或者變化量,而只是一系列文件快照。

git倉庫(版本庫):git倉庫就是一個.git文件夾。這個文件夾內(nèi)包含了很多文件(見插圖2),其中有一個很重要的文件夾objects,保存了暫存區(qū)的所有文件對象,包括blob對象、tree對象、commit對象等,這些對象都是一以文件的形式來保存的。還有HEAD文件,保存著最新的提交的指針。當(dāng)然很多人到這里可能還是不理解objects中的文件對象和HEAD中保存的指針到底是什么意思,沒關(guān)系,下面會詳細(xì)講解。
工作區(qū):在一個項目目錄中,除了.git文件的其他所有文件的集合就是工作區(qū)。
暫存區(qū):暫存區(qū)可以理解為文件從修改到最后提交到git版本庫之間的一個緩存,為了防止一次提交了不必要的文件,有回退的余地,便有了暫存區(qū)。
HEAD:HEAD在.git文件夾中是一個文件,文件的內(nèi)容是一個32位的16進(jìn)制數(shù),這只是一個指針,他指向最近一個提交點、這個提交點實質(zhì)是一個commit對象,對象里包含里多個屬性,包括最后一個提交點目錄結(jié)構(gòu)索引、上一次提交點id、提交人、提交時間等。
3.Git版本庫
3.1Git對象
Git版本庫(實際上就是一個數(shù)據(jù)庫)不僅僅提供版本庫中所有文件的完整副本,還提供版本庫本身的副本。
Git定義了4種對象:blob、tree、commit和tag,它們都位于.git/objects/目錄下。git對象在原文件的基礎(chǔ)上增加了一個頭部,即對象內(nèi)容 = 對象頭 + 文件內(nèi)容。這種格式無法直接通過cat命令讀取,需要使用git cat-file這個底層命令才能正確讀取。
對象頭的格式為:對象頭 = 對象類型 + 空格 + 數(shù)據(jù)內(nèi)容長度 + null byte,例如一個文件內(nèi)容為“hello world”,其blob對象頭為"blob 11\000"。

- blob:文件快照,每個blob代表一個(版本的)文件,blob只包含文件的數(shù)據(jù),而忽略文件的其他元數(shù)據(jù),如名字、路徑、格式等。
- tree:一個目錄樹對象代表一層目錄信息。它記錄blob標(biāo)識符,路徑名和一個目錄里的所有文件的一些元數(shù)據(jù)。(也就是說,一個tree目錄對象包含一個文件的許多不同的blob,保存blob信息和元數(shù)據(jù))
- commit:一個提交對象保存版本庫中每一次變化的元數(shù)據(jù),包括作者,提交者,提交日期和日志消息。每一個提交對象指向一個目錄樹對象。(也就是說,文件提交一次就會產(chǎn)生一個提交對象,保存提交的信息,并將該次提交指向一個目錄樹對象中。)
-
tag:一個標(biāo)簽對象分配一個任意的且人類可以讀懂的名字給一個特定對象,通常是一個提交對象。commit ID 很難理解,所以可以通過tag對象來制定。(也就是說,提交一次,產(chǎn)生一個提交ID,就可以對應(yīng)一個tag對象。)
對于所有的數(shù)據(jù),它們都會被計算成一個十六進(jìn)制散列值(例如像1632acb65b01 c6b621d6e1105205773931bb1a41這樣的值)。這個散列值將會被用作相關(guān)對象的引用,以及日后恢復(fù)數(shù)據(jù)時所需的鍵值。
也就是說,一個提交對象的散列值實際上就是它的“版本號”,如果我們持有某一提交的散列值,就可以用它來檢查對應(yīng)版本是否存在于某一版本庫中。如果存在,我們就可以將其恢復(fù)到當(dāng)前工作區(qū)相應(yīng)的目錄中。如果該版本不存在,我們也可以從其他版本庫中單獨導(dǎo)入(拉回)該提交所引用的全部對象。
接下來,我們來看看采用這種散列值(hash值)和這種既定的版本庫結(jié)構(gòu)究竟有哪些優(yōu)勢。
高性能:通過散列值來訪問數(shù)據(jù)是非常快的。
冗余度—釋放存儲空間:相同的文件內(nèi)容只需存儲一次即可。
分布式版本號:由于相關(guān)散列值是根據(jù)文件,作者和日期來計算的,所以版本也可以“離線”產(chǎn)生,不用擔(dān)心將來會因此而發(fā)生版本沖突。
版本庫間的高效同步:當(dāng)我們將某一提交從一個版本庫傳遞給另一個版本庫時,只需要傳送那些目標(biāo)版本庫中不存在的對象即可。而正是因為有了散列值的幫助,我們才能很快地判斷相關(guān)對象是否已經(jīng)存在。
數(shù)據(jù)完整性:由于散列值是根據(jù)數(shù)據(jù)的內(nèi)容來計算的,所以我們可以隨時通過Git來查看某一散列值是否與相關(guān)數(shù)據(jù)匹配。以檢測該數(shù)據(jù)上可能的意外變化或惡意操作。
自動重命名檢測:被重命名的文件可以被自動檢測到,因為根據(jù)該文件內(nèi)容計算出的散列值并沒有發(fā)生變化。也正因為如此,Git中并沒有專用的重命名命令,只需移動命令即可。
注:散列值即hash值
3.2 索引
索引描述整個版本庫的目錄結(jié)構(gòu),它捕獲項目在某個時刻的整體結(jié)構(gòu)的一個版本。Git的關(guān)鍵特色之一在于它允許你用有條理的、定義好的步驟來改變索引的內(nèi)容。所謂的索引就是你用git add file后添加到緩存的文件的一個版本,你可以通過GIT命令再索引中暫存變更(即添加、刪除或者編輯某個文件或某些文件),索引會記錄和保存好這些變更直到你準(zhǔn)備好要git commit了。索引跟蹤文件的路徑名和相應(yīng)的blob。
3.3 Git追蹤內(nèi)容
首先要注意的是。Git追蹤的是內(nèi)容而不是文件,Git并不追蹤那些與文件次相關(guān)的文件名或者是目錄名。如果兩個文件的內(nèi)容完全一樣,Git在對象庫里只保存一份blob形式的內(nèi)容副本,并且該文件具有唯一的SHA1值。文件內(nèi)容改變,則Git會計算一個新的SHA1值,識別出它現(xiàn)在是一個不同的blob對象并把這個blob對象添加到對象庫里。因為Git使用一個文件的全部內(nèi)容散列值作為文件名,所有它必須對每個文件的完整副本進(jìn)行操作。GIT用戶所說的SHA1、散列碼和對象ID都是指同一個東西。在互聯(lián)網(wǎng)上,文件或者任意大小的blob都可以通過僅比較他們的SHA1標(biāo)識符來判斷是否相同。
內(nèi)容尋址
- 依賴底層命令
git hash-object命令,對文件內(nèi)容增加頭信息后計算hash值并返回,增加-w參數(shù)后在git倉庫內(nèi)創(chuàng)建blob對象(blob對象 = 對象頭 + 文件內(nèi)容)。 - blob對象存儲到git倉庫目錄(.git/objects/)時,依據(jù)40位(16進(jìn)制字符)長度的hash串指定存儲目錄(hash串前2位)和命名文件(hash串后38位)。例如某blob對象的hash值為
62/0d4582bfbf773ef15f9b52ac434906a3cdf9c3,那么它在git倉庫中的路徑為.git/objects/62/0d4582bfbf773ef15f9b52ac434906a3cdf9c3。 - Git內(nèi)容尋址本質(zhì)是:Git根據(jù)由文件內(nèi)容(增加文件頭)產(chǎn)生的Hash值來標(biāo)識和索引文件,另外進(jìn)行命令操作時沒有必要寫完整的hash串,只要輸入的hash串長度是唯一可識別和索引的即可。
- 無需考慮Hash碰撞的情況,在大型項目上也可以放心使用Git。因為在概率上SHA-1產(chǎn)生的哈希值碰撞的機會可以小到忽略。
3.4 打包文件
Git并不是每次修改一個文件都會完全儲存這兩個版本文件的全部內(nèi)容,而是采用一種叫做打包文件的儲存機制。要創(chuàng)建一個打包文件,Git會定位內(nèi)容非常相似的全部文件,為其中之一儲存整個內(nèi)容,然后計算相似文件之間的差異并只儲存差異。
4 Git分支
為了理解 Git 分支的實現(xiàn)方式,我們需要回顧一下 Git 是如何儲存數(shù)據(jù)的?;蛟S你還記得第一章的內(nèi)容,Git 保存的不是文件差異或者變化量,而只是一系列文件快照。
在 Git 中提交時,會保存一個提交(commit)對象,該對象包含一個指向暫存內(nèi)容快照的指針,包含本次提交的作者等相關(guān)附屬信息,包含零個或多個指向該提交對象的父對象指針:首次提交是沒有直接祖先的,普通提交有一個祖先,由兩個或多個分支合并產(chǎn)生的提交則有多個祖先。
為直觀起見,我們假設(shè)在工作目錄中有三個文件,準(zhǔn)備將它們暫存后提交。暫存操作會對每一個文件計算校驗和(即第一章中提到的 SHA-1 哈希字串),然后把當(dāng)前版本的文件快照保存到 Git 倉庫中(Git 使用 blob 類型的對象存儲這些快照),并將校驗和加入暫存區(qū)域:
$ git add README test.rb LICENSE
$ git commit -m 'initial commit of my project'

作些修改后再次提交,那么這次的提交對象會包含一個指向上次提交對象的指針(譯注:即下圖中的 parent 對象)。兩次提交后,倉庫歷史會變成 的樣子如下圖

現(xiàn)在來談分支。Git 中的分支,其實本質(zhì)上僅僅是個指向 commit 對象的可變指針。Git 會使用 master 作為分支的默認(rèn)名字。在若干次提交后,你其實已經(jīng)有了一個指向最后一次提交對象的 master 分支,它在每次提交的時候都會自動向前移動

新建分支

那么,Git 是如何知道你當(dāng)前在哪個分支上工作的呢?其實答案也很簡單,它保存著一個名為
HEAD 的特別指針。請注意它和你熟知的許多其他版本控制系統(tǒng)(比如 Subversion 或 CVS)里的HEAD 概念大不相同。在 Git 中,它是一個指向你正在工作中的本地分支的指針(譯注:將 HEAD 想象為當(dāng)前分支的別名。)。運行 git branch 命令,僅僅是建立了一個新的分支,但不會自動切換到這個分支中去,所以在這個例子中,我們依然還在 master 分支里工作(參考圖 3-5)。

切換分支
這樣 HEAD 就指向了 testing 分支

在分支上提交

再切換到master分支

在master分支上提交

由于 Git 中的分支實際上僅是一個包含所指對象校驗和(40 個字符長度 SHA-1 字串)的文件,所以創(chuàng)建和銷毀一個分支就變得非常廉價。說白了,新建一個分支就是向一個文件寫入 41 個字節(jié)(外加一個換行符)那么簡單,當(dāng)然也就很快了。
這和大多數(shù)版本控制系統(tǒng)形成了鮮明對比,它們管理分支大多采取備份所有項目文件到特定目錄的方式,所以根據(jù)項目文件數(shù)量和大小不同,可能花費的時間也會有相當(dāng)大的差別,快則幾秒,慢則數(shù)分鐘。而 Git 的實現(xiàn)與項目復(fù)雜度無關(guān),它永遠(yuǎn)可以在幾毫秒的時間內(nèi)完成分支的創(chuàng)建和切換。同時,因為每次提交時都記錄了祖先信息(譯注:即 parent 對象),將來要合并分支時,尋找恰當(dāng)?shù)暮喜⒒A(chǔ)(譯注:即共同祖先)的工作其實已經(jīng)自然而然地擺在那里了,所以實現(xiàn)起來非常容易。Git 鼓勵開發(fā)者頻繁使用分支,正是因為有著這些特性作保障。
5 Git是如何物理存儲對象的
所有的對象都以SHA值為索引用gzip格式壓縮存儲, 每個對象都包含了對象類型, 大小和內(nèi)容.
Git中存在兩種對象 - 松散對象(loose object)和打包對象(packed object).
5.1. 松散對象(對應(yīng)未改動文件)
松散對象是一種比較簡單格式. 它就是磁盤上的一個存儲壓縮數(shù)據(jù)的文件. 每一個對象都被寫入一個單獨文件中.
如果你對象的SHA值是ab04d884140f7b0cf8bbf86d6883869f16a46f65, 那么對應(yīng)的文件會被存儲在:
GIT_DIR/objects/ab/04d884140f7b0cf8bbf86d6883869f16a46f65
Git使用SHA值的前兩個字符作為子目錄名字, 所以一個目錄中永遠(yuǎn)不會包含過多的對象. 文件名則是余下的38個字符.
5.2. 打包對象(對應(yīng)休改動文件)
另外一種對象存儲方式是使用打包文件(packfile). 由于Git把每個文件的每個版本都作為一個單獨的對象, 它的效率可能會十分的低. 設(shè)想一下在一個數(shù)千行的文件中改動一行, Git會把修改后的文件整個存儲下來.

