之前做版本管理,我使用最多的是SVN,而且也只是在用一些最常用的操作。最近公司里很多項目都開始上Git,借這個機會,我計划好好學習一下Git的操作和原理,以及蘊含在其中的設計思想。同事推薦了一本《Pro Git》,讀起來感覺很好,在這里分享下閱讀時的思考。此書的在線閱讀地址:http://iissnan.com/progit/
第一章 起步
這一章介紹了Git的相關歷史和基本特點,以及安裝配置方法。這里提到的Git的特點包括“直接記錄快照,而非差異比較”、“近乎所有操作都是本地執行”、“時刻保持數據完整性”、“多數操作僅添加數據”、“文件的三種狀態”,除了最后一點我會放在下一章里梳理,下面會對其中一部分進行一些思考的分享。
直接記錄快照,而非差異比較
Git 和其他版本控制系統的主要差別在於,Git 只關心文件數據的整體是否發生變化,而大多數其他系統則只關心文件內容的具體差異。
這個策略要求Git記錄每個版本的完整文件。如果需要對比同一個文件的兩個連續版本間差異,Git會直接比較兩個文件,而其他系統可以直接把保存的具體差異取出來;但是如果比較間隔版本的文件,后者需要將差異全部合並,才能顯示。這意味着版本的間隔越多,基於差異的系統在比較差異所需要的計算量會越大,而Git完全不會受到這個影響。
可以把這個策略看作是空間換時間的實踐。現在單位存儲空間的費用越來越低,TB級硬盤也已淪為白菜價,即使是開發人員使用的百兆級SSD也已經普及,額外的空間消耗完全可以不做考慮。
時刻保持數據完整性
在保存到 Git 之前,所有數據都要進行內容的校驗和(checksum)計算,並將此結果作為數據的唯一標識和索引。換句話說,不可能在你修改了文件或目錄之后,Git 一無所知。這項特性作為 Git 的設計哲學,建在整體架構的最底層。所以如果文件在傳輸時變得不完整,或者磁盤損壞導致文件數據缺失,Git 都能立即察覺。
Git 使用 SHA-1 算法計算數據的校驗和,通過對文件的內容或目錄的結構計算出一個 SHA-1 哈希值,作為指紋字符串。該字串由 40 個十六進制字符(0-9 及 a-f)組成,看起來就像是:
24b9da6552252987aa493b52f8696cd6d3b00373
Git 的工作完全依賴於這類指紋字串,所以你會經常看到這樣的哈希值。實際上,所有保存在 Git 數據庫中的東西都是用此哈希值來作索引的,而不是靠文件名。
使用SHA-1產生的hash值而不是文件名做索引的好處是,hash值的長度固定,並且隨機性很好,符合哈希充分散列的要求。SHA-1本身就是一種常用的hash函數,其應用不在這里重述。前一段時間Google宣布“將在Chrome瀏覽器中逐漸降低SHA-1證書的安全指示”,但它這樣做的原因是出於安全考慮,並不意味着Git使用SHA-1做hash函數不合適,有興趣的讀者可以看看相關的分析,如:深度:為什么Google急着殺死加密算法SHA-1。
文件名做索引有什么壞處呢?長度不固定並不是主要的問題。以用maven管理的代碼為例,如果依賴比較復雜,那么各個package中都有各自的pom.xml,它們的文件名是完全一樣的,會導致嚴重的hash碰撞。
第二章 Git基礎
這章介紹了最基本的 Git 本地操作:創建和克隆倉庫,做出修改,暫存並提交這些修改,以及查看所有歷史修改記錄。這些操作的命令不再一一列出,來看看第一章提到但沒有詳細講述的文件狀態。
文件狀態
梳理一下文件各個狀態的轉換過程和邏輯,可以畫出下面的圖示。在這張圖中,常用的本地的文件操作命令以及將會導致的狀態變更就很清楚了:
除了文件狀態,簡單說下Git里標簽的意義。眾所周知,SVN里每個版本都是有版本號的,從1開始,每次提交都會升高。而在Git中,每次提交只會返回一個SHA-1 校驗和其他的信息,是沒有版本號的。
發布時,如何指定Git上的代碼版本?這時就可以用tag來做標記了。tag相當於為一個特定的版本增加的標記,可以替代SVN里版本號的功能,而且更強大。
第三章 Git分支
用鏈表組織分支
如果要理解Git,要理解Git的分支;如果要理解Git的分支,首先要理解Git中的四個基本的對象模型:blob、tree、commit、tag。這部分原書寫的比較簡單,具體可以參考《Git Community Book》第一章。幸運的是,該書也有網絡版,這一部分內容的地址是:http://gitbook.liuhui998.com/1_2.html。簡單地說,這四種對象分別對應於:
- blob:表示文件內容,是指向文件的索引。
- tree:表示目錄層級關系,保存了其他blob對象和tree對象的指針。
- commit:保存一次提交的信息,以及一個tree對象根目錄。
- tag:標記一次commit。
分支是把commit對象組織成了鏈表的形式,不同的分支指向了對應的commit對象,每次在分支上提交,都會在鏈表表頭上插入新的對象,如下圖上的master和testing兩個分支,圖中的綠色方框代表一個commit對象。此時可以通過控制HEAD指針所在位置來指明使用了哪個分支。
簡單回憶下鏈表的相關操作可以發現,只要保存各個分支對應的表頭,我們可以很容易的通過給HEAD賦值在各個分支之間切換。同時對於每次提交,鏈表插入的操作也很簡單。
在理解了Git版本管理的鏈表式的實現方式之后,只要具有基本的算法知識,其他操作原理的理解會變得非常迅速。以下各圖來自於《Pro Git》。
1.從master拉新分支iss53,只需新增該分支的指針,未做修改后的提交時,iss53指向master。提交新內容時,創建對應commit對象,iss53指針前移。
2.當分支hotfix的祖先節點中包括master分支,將hotfix分支merge回master分支,只需要把master的指針移動hotfix上,沒有任何文件處理工作,因而稱之為Fast forward。
3.當分支iss53合並回master分支,但是master不是iss53的祖先時,先計算二者的最近一個的公共祖先,把它和這兩個分支的commit進行合並計算,創建新的commit對象。這個對象有兩個祖先。如果合並時遇到沖突,不會提交,而是等人工處理完沖突並git add后才能進行提交。
如何尋找交叉鏈表的第一個公共節點?這是一個常見算法問題,可以參考舊作《編程之美》3.6判斷鏈表是否相交之擴展:鏈表找環方法證明的擴展問題2。
4.(個人推測)查看某個分支是否已合並到master分支:比較兩個分支指針是否指向同一個對象。
5.(個人推測)刪除已合並master的分支:直接刪除該分支的指針;刪除未合並的分支(git branch -d XXX):刪除該分支不在master上所有commit對象及相關的對象、刪除分支指針。
merge還是rebase?
merge是直接將兩個分支合並到一起:創建一個合並后的commit節點,祖先有兩個,是被合並的兩個分支A和分支B,節點內容是三方(分支A、分支B、分支A和B的共有最近祖先)合並的結果。原有的鏈表上的節點保留,分支上的提交歷史沒有發生改變。如下圖(來自《Pro Git》)所示:
rebase則是將一個分支A中的內容產生的補丁在另一個分支B上重新打一遍,打完之后,分支A的節點變成了分支B的后繼。rebase完成后,分支A的特有節點發生了變動。如下圖(來自《Pro Git》)所示,C3和C3`是不同的節點:
實際上,merge和rebase產生的節點的內容上是一樣的,發生沖突時仍需要人工解決,不同點只是提交的歷史節點。rebase更適用於未公開提交(可以理解為push到遠程倉庫)的對象,清理提交歷史;如果對已公開的提交對象rebase,並且已經有人對這些已提交對象開展了后續開發,會使得提交歷史非常混亂。詳細的例子可以查看原書“分支的衍合”一節。
第六章 Git工具
在“使用Git調試”一節,提到了git bisect進行各個commit的二分查找。眾所周知,單鏈表本身是不支持二分查找的,推測Git可能使用了以下兩種方式支持:
(1)將起始和結束的兩個commit中間所有節點的指針保存到一個臨時數組中,二分查找基於這個臨時數組進行;
(2)git使用的了類似於跳表的鏈表。跳表可參考http://www.cnblogs.com/liuhao/archive/2012/07/26/2610218.html。
進一步地推測,在進行二分查找時,對commit進行修改,可能會導致查詢錯誤。
第九章 Git內部原理
底層命令究竟做了些什么
在第一次閱讀這一章時,我從第二節開始就有點暈頭轉向,不知道究竟行文的思路是什么。第二次閱讀時才有點眉目,並發覺第一次沒看懂的原因是,原文很多地方只是描述底層命令執行后發生的現象,並沒有完整地告訴讀者這個命令執行的結果。網上很多對git的介紹文章偏實用主義,對這些底層命令並沒有花費多少筆墨。好在git自身的文檔很完善,git -help <command>對底層命令也有效,可以自行查看。不過方便起見,這里會簡單介紹下這些底層命令。以下介紹底層命令時,實際用法為git XXX,如git hash-object,簡記為hash-object。
當然,這里的介紹不是文檔的翻譯,其中也加入了一些個人的理解,因此,各個命令的介紹可能有少量的連續性。
另外一個有趣的事實:Git高層命令是可以自動補全的,而底層命令不行。
hash-object
計算一個文件(可以通過--stdin指定為從標准輸入讀取)的對象ID,這個對象ID實際上是git這個內容尋址文件系統的K-V關系的鍵值。可以使用-w選項將該對象添加到git的文件對象庫,而不僅僅是把對象的鍵值顯示在屏幕上。
cat-file
顯示git對象的內容或類型,需要指定對象ID。-p用於輸出格式化內容。你會發現,通過hash-object生成的文件直接打開是亂碼,想要查看原始內容,必須用git cat-file。可以推測,git對象中不僅保存了文件內容,還應該保存了結構信息等,並有被壓縮的可能。這節的結尾便證實了這點:先寫文件頭(包括文件類型和內容長度)、內容正文,再計算SHA-1校驗和(作為文件路徑和文件名,不參與文件本身的保存),最后進行壓縮。
如果對一個tree對象使用cat-file -p,可以看到這個tree對象包括了其他tree或blob對象的引用(同樣是對象ID)。
update-index
為文件創建或更新index。這樣做會導致文件被放入暫存區域(回想下git中文件的staged狀態)。運行這個命令之后,往往接下來要運行git write-tree。對同一個文件重復運行也沒有任何提示。
write-tree
為當前的index(注意:此時暫存區可能有多個文件)創建一個tree對象。把update-index和write-tree分開的目的,我認為是Git為了獲得更細粒度的控制能力。
read-tree
將一個的tree對象(可以以--prefix指定其對應的目錄名,這個目錄此時還不存在)讀入index。通過這個命令以及update-index、write-tree,我們就可以任意地裝配出任何目錄-文件的結構了。注意我這里使用的是“裝配”而非“組裝”,是因為這三個命令是無法進行目錄結構的拆分的。
commit-tree
指定一個tree對象,以此創建一個commit對象。如果對一個commit對象再次運行該命令,可以git log看到完整的提交歷史,也即這兩個commit對象。
update-ref
安全地更新一個用對象ID表示的文件的引用。其結果和git branch指定某個分支(對應於update-ref的引用)為某個commit(對應於update-ref的對象ID)一樣。
輕量級tag對象是可以通過update-ref進行創建的。
symbolic-ref
給一個標記(如最常見的HEAD)指定一個引用。不指定引用則讀取這個標記當前的引用。
gc
清理文件。實際上是將文件進行打包壓縮。
verify-pack
查看通過gc進行的已打包的git對象。
閱讀的時候遇到了兩處翻譯錯誤,我已提交了pull request:
1.第9-2節“-stdin
指定從標准輸入設備 (stdin) 來讀取內容,若不指定這個參數則需指定一個要存儲的文件的路徑。”應為“要讀取的文件的路徑”。
2.第9-4節 “然后可以用git cat-file命令...”,下面用的是du命令。實際原文中在這里沒有提到“git cat-file”。