Git 的核心概念解讀

lufficc發表於2016-10-04

本文不是Git使用教學篇,而是偏向理論方面,旨在更加深刻的理解Git,這樣才能更好的使用它,讓工具成為我們得力的助手。

版本控制系統

Git 是目前世界上最優秀的分散式版本控制系統。版本控制系統是能夠隨著時間的推進記錄一系列檔案的變化以便於你以後想要的退回到某個版本的系統。版本控制系統分為三大類:本地版本控制系統,集中式版本控制系統和分散式版本控制系統

本地版本控制(Local Version Control Systems)是將檔案的各個版本以一定的資料格式儲存在本地的磁碟(有的VCS 是儲存檔案的變化補丁,即在檔案內容變化時計算出差量儲存起來),這種方式在一定程度上解決了手動複製貼上的問題,但無法解決多人協作的問題。

本地版本控制

集中式版本控制(Centralized Version Control Systems)相比本地版本控制沒有什麼本質的變化,只是多了個一箇中央伺服器,各個版本的資料庫儲存在中央伺服器,管理員可以控制開發人員的許可權,而開發人員也可以從中央伺服器拉取資料。集中式版本控制雖然解決了團隊協作問題,但缺點也很明顯:所有資料儲存在中央伺服器,伺服器一旦當機或者磁碟損壞,會造成不可估量的損失。

集中式版本控制

分散式版本控制( Distributed Version Control System)與前兩者均不同。首先,在分散式版本控制系統中,像 Git,Mercurial,Bazaar 以及 Darcs 等,系統儲存的的不是檔案變化的差量,而是檔案的快照,即把檔案的整體複製下來儲存,而不關心具體的變化內容。其次,最重要的是分散式版本控制系統是分散式的,當你從中央伺服器拷貝下來程式碼時,你拷貝的是一個完整的版本庫,包括歷史紀錄,提交記錄等,這樣即使某一臺機器當機也能找到檔案的完整備份。

分散式版本控制

Git基礎

Git是一個分散式版本控制系統,儲存的是檔案的完整快照,而不是差異變化或者檔案補丁。

儲存每一次變化檔案的完整內容

Git每一次提交都是對專案檔案的一個完整拷貝,因此你可以完全恢復到以前的任一個提交而不會發生任何區別。這裡有一個問題:如果我的專案大小是10M,那Git佔用的空間是不是隨著提交次數的增加線性增加呢?我提交(commit)了10次,佔用空間是不是100M呢?很顯然不是,Git是很智慧的,如果檔案沒有變化,它只會儲存一個指向上一個版本的檔案的指標,即,對於一個特定版本的檔案,Git只會儲存一個副本,但可以有多個指向該檔案的指標

另外注意,Git最適合儲存文字檔案,事實上Git就是被設計出來就是為了儲存文字檔案的,像各種語言的原始碼,因為Git可以對文字檔案進行很好的壓縮和差異分析(大家都見識過了,Git的差異分析可以精確到你新增或者刪除了某個字母)。而二進位制檔案像視訊,圖片等,Git也能管理,但不能取得較好的效果(壓縮比率低,不能差異分析)。實驗證明,一個 500k 的文字檔案經Git壓縮後僅 50k 左右,稍微改變內容後兩次提交,會有兩個 50k 左右的檔案,沒錯的,儲存的是完整快照。而對於二進位制檔案,像視訊,圖片,壓縮率非常小, Git 佔用空間幾乎隨著提交次數線性增長。

未變化的檔案只儲存上一個版本的指標

Git工程有三個工作區域:工作目錄,暫存區域,以及本地倉庫。工作目錄是你當前進行工作的區域;暫存區域是你執行git add命令後檔案儲存的區域,也是下次提交將要儲存的檔案(注意:Git 提交實際讀取的是暫存區域的內容,而與工作區域的檔案無關,這也是當你修改了檔案之後,如果沒有新增git add到暫存區域,並不會儲存到版本庫的原因);本地倉庫就是版本庫,記錄了你工程某次提交的完整狀態和內容,這意味著你的資料永遠不會丟失。

相應的,檔案也有三種狀態:已提交(committed),已修改(modified)和已暫存(staged)。已提交表示該檔案已經被安全地儲存在本地版本庫中了;已修改表示修改了某個檔案,但還沒有提交儲存;已暫存表示把已修改的檔案放在下次提交時要儲存的清單中,即暫存區域。所以使用Git的基本工作流程就是:

  1. 在工作區域增加,刪除或者修改檔案。
  2. 執行git add,將檔案快照儲存到暫存區域。
  3. 提交更新,將檔案永久版儲存到版本庫中。

Git物件

現在已經明白Git的基本流程,但Git是怎麼完成的呢?Git怎麼區分檔案是否發生變化?下面簡單介紹一下Git的基本原理。

SHA-1 校驗和

Git 是一套內容定址檔案系統。意思就是Git 從核心上來看不過是簡單地儲存鍵值對(key-value),value是檔案的內容,而key是檔案內容與檔案頭資訊的 40個字元長度的 SHA-1 校驗和,例如:5453545dccD33565a585ffe5f53fda3e067b84d8。Git使用該校驗和不是為了加密,而是為了資料的完整性,它可以保證,在很多年後,你重新checkout某個commit時,一定是它多年前的當時的狀態,完全一摸一樣。當你對檔案進行了哪怕一丁點兒的修改,也會計算出完全不同的 SHA-1 校驗和,這種現象叫做“雪崩效應”(Avalanche effect)。

SHA-1 校驗和因此就是上文提到的檔案的指標,這和C語言中的指標很有些不同:C語言將資料在記憶體中的地址作為指標,Git將檔案的 SHA-1 校驗和作為指標,目的都是為了唯一區分不同的物件。但是當C語言指標指向的記憶體中的內容發生變化時,指標並不發生變化,但Git指標指向的檔案內容發生變化時,指標也會發生變化。所以,Git中每一個版本的檔案,都有一個唯一的指標指向它。

檔案(blob)物件,樹(tree)物件,提交(commit)物件

blob 物件儲存的僅僅是檔案的內容,tree 物件更像是作業系統中的資料夾,它可以儲存blob物件和tree 物件。一個單獨的 tree 物件包含一條或多條 tree 記錄,每一條記錄含有一個指向 blob 物件或子 tree 物件的 SHA-1 指標,並附有該物件的許可權模式 (mode)、型別和檔名資訊等:

當你對檔案進行修改並提交時,變化的檔案會生成一個新的blob物件,記錄檔案的完整內容(是全部內容,不是變化內容),然後針對該檔案有一個唯一的 SHA-1 校驗和,修改此次提交該檔案的指標為該 SHA-1 校驗和,而對於沒有變化的檔案,簡單拷貝上一次版本的指標即 SHA-1 校驗和,而不會生成一個全新的blob物件,這也解釋了10M大小的專案進行10次提交總大小遠遠小於100M的原因。

另外,每次提交可能不僅僅只有一個 tree 物件,它們指明瞭專案的不同快照,但你必須記住所有物件的 SHA-1 校驗和才能獲得完整的快照,而且沒有作者,何時,為什麼儲存這些快照的原因。commit物件就是問了解決這些問題誕生的,commit 物件的格式很簡單:指明瞭該時間點專案快照的頂層tree物件、作者/提交者資訊(從 Git 設定的 user.name 和 user.email中獲得)以及當前時間戳、一個空行,上一次的提交物件的ID以及提交註釋資訊。你可以簡單的執行git log來獲取這新資訊:

$ git log
commit 2cb0bb475c34a48957d18f67d0623e3304a26489
Author: lufficc <luffy.lcc@gmail.com>
Date:   Sun Oct 2 17:29:30 2016 +0800

    fix some font size

commit f0c8b4b31735b5e5e96e456f9b0c8d5fc7a3e68a
Author: lufficc <luffy.lcc@gmail.com>
Date:   Sat Oct 1 02:55:48 2016 +0800

    fix post show css

***********省略***********

上圖的Test.txt是第一次提交之前生成的,第一次它的初始 SHA-1 校驗和以3c4e9c開頭。隨後對它進行了修改,所以第二次提交時生成了一個全新blob物件,校驗和以1f7a7a開頭。而第三次提交時Test.txt並沒有變化,所以只是儲存最近版本的 SHA-1 校驗和而不生成全新的blob物件。在專案開發過程中新增加的檔案在提交後都會生成一個全新的blob物件來儲存它。注意除了第一次每個提交物件都有一個指向上一次提交物件的指標。

因此簡單來說,blob物件儲存檔案的內容;tree物件類似資料夾,儲存blob物件和其它tree物件;commit物件儲存tree物件,提交資訊,作者,郵箱以及上一次的提交物件的ID(第一次提交沒有)。而Git就是通過組織和管理這些物件的狀態以及複雜的關係實現的版本控制以及以及其他功能如分支。

Git引用

現在再來看引用,就會很簡單了。如果我們想要看某個提交記錄之前的完整歷史,就必須記住這個提交ID,但提交ID是一個40位的 SHA-1 校驗和,難記。所以引用就是SHA-1 校驗和的別名,儲存在.git/refs資料夾中。

最常見的引用也許就是master了,因為這是Git預設建立的(可以修改,但一般不修改),它始終指向你專案主分支的最後一次提交記錄。如果在專案根目錄執行cat .git/refs/heads,會輸出一個SHA-1 校驗和,例如:

$ cat .git/refs/heads/master
4f3e6a6f8c62bde818b4b3d12c8cf3af45d6dc00

因此master只是一個40位SHA-1 校驗和的別名罷了。

還有一個問題,Git如何知道你當前分支的最後一次的提交ID?在.git資料夾下有一個HEAD檔案,像這樣:

$ cat .git/HEAD
ref: refs/heads/master

HEAD檔案其實並不包含 SHA-1 值,而是一個指向當前分支的引用,內容會隨著切換分支而變化,內容格式像這樣:ref: refs/heads/<branch-name>。當你執行git commit命令時,它就建立了一個commit物件,把這個commit物件的父級設定為HEAD 指向的引用的 SHA-1 值。

再來說說 Git 的 tag,標籤。標籤從某種意義上像是一個引用, 它指向一個 commit 物件而不是一個 tree,包含一個標籤,一組資料,一個訊息和一個commit 物件的指標。但是區別就是引用隨著專案進行它的值在不斷向前推進變化,但是標籤不會變化——永遠指向同一個 commit,僅僅是提供一個更加友好的名字。

Git分支

分支

分支是Git的殺手級特徵,而且Git鼓勵在工作流程中頻繁使用分支與合併,哪怕一天之內進行許多次都沒有關係。因為Git分支非常輕量級,不像其他的版本控制,建立分支意味著要把專案完整的拷貝一份,而Git建立分支是在瞬間完成的,而與你工程的複雜程度無關。

因為在上文中已經說到,Git儲存檔案的最基本的物件是blob物件,Git本質上只是一棵巨大的檔案樹,樹的每一個節點就是blob物件,而分支只是樹的一個分叉。說白了,分支就是一個有名字的引用,它包含一個提交物件的的40位校驗和,所以建立分支就是向一個檔案寫入 41 個位元組(外加一個換行符)那麼簡單,所以自然就快了,而且與專案的複雜程度無關。

Git的預設分支是master,儲存在.git\refs\heads\master檔案中,假設你在master分支執行git branch dev建立了一個名字為dev的分支,那麼git所做的實際操作是:

  1. .git\refs\heads資料夾下新建一個檔名為dev(沒有副檔名)的文字檔案。
  2. 將HEAD指向的當前分支(當前為master)的40位SHA-1 校驗和外加一個換行符寫入dev檔案。
  3. 結束。

建立分支就是這麼簡單,那麼切換分支呢?更簡單:

  1. 修改.git檔案下的HEAD檔案為ref: refs/heads/<分支名稱>
  2. 按照分支指向的提交記錄將工作區的檔案恢復至一模一樣。
  3. 結束。

記住,HEAD檔案指向當前分支的最後一次提交,同時,它也是以當前分支再次建立一個分支時,將要寫入的內容。

分支合併

再來說一說合並,首先是Fast-forward,換句話說,如果順著一個分支走下去可以到達另一個分支的話,那麼 Git 在合併兩者時,只會簡單地把指標右移,因為這種單線的歷史分支不存在任何需要解決的分歧,所以這種合併過程可以稱為快進(Fast forward)。比如:


注意箭頭方向,因為每一次提交都有一個指向上一次提交的指標,所以箭頭方向向左,更為合理

當在master分支合併dev分支時,因為他們在一條線上,這種單線的歷史分支不存在任何需要解決的分歧,所以只需要master分支指向dev分支即可,所以非常快。

當分支出現分叉時,就有可能出現衝突,而這時Git就會要求你去解決衝突,比如像下面的歷史:

因為master分支和dev分支不在一條線上,即v7不是v5的直接祖先,Git 不得不進行一些額外處理。就此例而言,Git 會用兩個分支的末端(v7 和 v5)以及它們的共同祖先(v3)進行一次簡單的三方合併計算。合併之後會生成一個和並提交v8

注意:和並提交有兩個祖先(v7v5)。

分支的變基rebase

把一個分支中的修改整合到另一個分支的辦法有兩種:merge 和 rebase。首先merge 和 rebase最終的結果是一樣的,但rebase能產生一個更為整潔的提交歷史。仍然以上圖為例,如果簡單的merge,會生成一個提交物件v8,現在我們嘗試使用變基合併分支,切換到dev

$ git checkout dev
$ git rebase master
First, rewinding head to replay your work on top of it...
Applying: added staged command

這段程式碼的意思是:回到兩個分支最近的共同祖先v3,根據當前分支(也就是要進行變基的分支 dev)後續的歷次提交物件(包括v4v5),生成一系列檔案補丁,然後以基底分支(也就是主幹分支 master)最後一個提交物件(v7)為新的出發點,逐個應用之前準備好的補丁檔案,最後會生成兩個新的合併提交物件(v4'v5'),從而改寫 dev 的提交歷史,使它成為 master 分支的直接下游,如下圖:

現在,就可以回到master分支進行快速合併Fast-forward了,因為master分支和dev分支在一條線上:

$ git checkout master
$ git merge dev

現在的v5'對應的快照,其實和普通的三方合併,即上個例子中的 v8 對應的快照內容一模一樣。雖然最後整合得到的結果沒有任何區別,但變基能產生一個更為整潔的提交歷史。如果視察一個變基過的分支的歷史記錄,看起來會更清楚:彷彿所有修改都是在一根線上先後進行的,儘管實際上它們原本是同時並行發生的。

總結

1、Git儲存檔案的完整內容,不儲存差量變化。

2、Git以儲鍵值對(key-value)的方式儲存檔案。

3、每一個檔案,相同檔案的不同版本,都有一個唯一的40位的 SHA-1 校驗和與之對應。

4、SHA-1 校驗和是檔案的指標,Git依靠它來區分檔案。

5、每一個檔案都會在Git的版本庫裡生成blob物件來儲存。

6、對於沒有變化的檔案,Git只會保留上一個版本的指標。

7、Git實際上是通過維持複雜的檔案樹來實現版本控制的。

8、使用Git的工作流程基本就是就是檔案在三個工作區域之間的流動。

9、應該大量使用分支進行團隊協作。

10、分支只是對提交物件的一個引用。

相關文章