文章內容來自自己的理解 和 https://git-scm.com/book/zh/v2 。
本文不是 Git 使用教學篇,而是偏向理論方面,旨在更加深刻的理解 Git,這樣才能更好的使用它,讓工具成為我們得力的助手。
版本控制系統#
Git 是目前世界上最優秀的分佈式版本控制系統。版本控制系統是能夠隨著時間的推進記錄一系列文件的變化以便於你以後想要的退回到某個版本的系統。版本控制系統分為三大類:本地版本控制系統,集中式版本控制系統和分佈式版本控制系統。
本地版本控制(Local Version Control Systems)是將文件的各個版本以一定的數據格式存儲在本地的磁碟(有的 VCS 是保存文件的變化補丁,即在文件內容變化時計算出差量保存起來),這種方式在一定程度上解決了手動複製粘貼的問題,但無法解決多人協作的問題。
{.to-figure}
集中式版本控制(Centralized Version Control Systems)相比本地版本控制沒有什麼本質的變化,只是多了一個中央伺服器,各個版本的數據庫存儲在中央伺服器,管理員可以控制開發人員的權限,而開發人員也可以從中央伺服器拉取數據。集中式版本控制雖然解決了團隊協作問題,但缺點也很明顯:所有數據存儲在中央伺服器,伺服器一旦宕機或者磁碟損壞,會造成不可估量的損失。
{.to-figure}
分佈式版本控制( Distributed Version Control System)與前兩者均不同。首先,在分佈式版本控制系統中,像 Git,Mercurial,Bazaar 以及 Darcs 等,系統保存的的不是文件變化的差量,而是文件的快照,即把文件的整體複製下來保存,而不關心具體的變化內容。其次,最重要的是分佈式版本控制系統是分佈式的,當你從中央伺服器拷貝下來代碼時,你拷貝的是一個完整的版本庫,包括歷史紀錄,提交紀錄等,這樣即使某一台機器宕機也能找到文件的完整備份。
{.to-figure}
Git 基礎#
Git 是一個分佈式版本控制系統,保存的是文件的完整快照,而不是差異變化或者文件補丁。
{.to-figure}
Git 每一次提交都是對項目文件的一個完整拷貝,因此你可以完全恢復到以前的任一個提交而不會發生任何區別。這裡有一個問題:如果我的項目大小是 10M,那 Git 佔用的空間是不是隨著提交次數的增加線性增加呢?我提交(commit)了 10 次,佔用空間是不是 100M 呢?很顯然不是,Git 是很智能的,如果文件沒有變化,它只會保存一個指向上個版本的文件的指針
,即,對於一個特定版本的文件,Git 只會保存一個副本,但可以有多個指向該文件的指針
。
另外注意,Git 最適合保存文本文件,事實上 Git 就是被設計出來就是為了保存文本文件的,像各種語言的源代碼,因為 Git 可以對文本文件進行很好的壓縮和差異分析(大家都見識過了,Git 的差異分析可以精確到你添加或者刪除了某個字母)。而二進制文件像視頻,圖片等,Git 也能管理,但不能取得較好的效果(壓縮比率低,不能差異分析)。實驗證明,一個 500k 的文本文件經 Git 壓縮後僅 50k 左右,稍微改變內容後兩次提交,會有兩個 50k 左右的文件,沒錯的,保存的是完整快照。而對於二進制文件,像視頻,圖片,壓縮率非常小, Git 佔用空間幾乎隨著提交次數線性增長。
{.to-figure}
Git 工程有三個工作區域:工作目錄,暫存區,以及本地倉庫。工作目錄是你當前進行工作的區域;暫存區是你運行git add
命令後文件保存的區域,也是下次提交將要保存的文件(注意:Git 提交實際讀取的是暫存區的內容,而與工作區域的文件無關,這也是當你修改了文件之後,如果沒有添加git add
到暫存區,並不會保存到版本庫的原因);本地倉庫就是版本庫,記錄了你工程某次提交的完整狀態和內容,這意味著你的數據永遠都不會丟失。
相應的,文件也有三種狀態:已提交(committed),已修改(modified)和已暫存(staged)。已提交表示該文件已經被安全地保存在本地版本庫中了;已修改表示修改了某個文件,但還沒有提交保存;已暫存表示把已修改的文件放在下次提交時要保存的清單中,即暫存區。所以使用 Git 的基本工作流程就是:
- 在工作區域增加,刪除或者修改文件。
- 運行
git add
,將文件快照保存到暫存區。 - 提交更新,將文件永久版保存到版本庫中。
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 所做的實際操作是:
- 在
.git\refs\heads
文件夾下新建一個文件名為dev
(沒有擴展名)的文本文件。 - 將 HEAD 指向的當前分支(當前為
master
)的 40 位 SHA-1 校驗和外加一個換行符寫入dev
文件。 - 結束。
創建分支就是這麼簡單,那麼切換分支呢?更簡單:
- 修改
.git
文件下的HEAD
文件為ref: refs/heads/<分支名稱>
。 - 按照分支指向的提交紀錄將工作區的文件恢復至一模一樣。
- 結束。
記住,HEAD
文件指向當前分支的最後一次提交,同時,它也是以當前分支再次創建一個分支時,將要寫入的內容。
分支合併#
再來說一說合併,首先是 Fast-forward,換句話說,如果順著一個分支走下去可以到達另一個分支的話,那麼 Git 在合併兩者時,只會簡單地把指針右移,因為這種單線的歷史分支不存在任何需要解決的分歧,所以這種合併過程可以稱為快進(Fast forward)。比如:
注意箭頭方向,因為每一次提交都有一個指向上一次提交的指針,所以箭頭方向向左,更為合理
當在master
分支合併dev
分支時,因為他們在一條線上,這種單線的歷史分支不存在任何需要解決的分歧,所以只需要master
分支指向dev
分支即可,所以非常快。
當分支出現分叉時,就有可能出現衝突,而這時 Git 就會要求你去解決衝突,比如像下面的歷史:
因為master
分支和dev
分支不在一條線上,即v7
不是v5
的直接祖先,Git 不得不進行一些額外處理。就此例而言,Git 會用兩個分支的末端(v7
和 v5
)以及它們的共同祖先(v3
)進行一次簡單的三方合併計算。合併之後會生成一個和並提交v8
:
注意:和並提交有兩個祖先(v7
和v5
)。
分支的變基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
)後續的歷次提交對象(包括v4
,v5
),生成一系列文件補丁,然後以基底分支(也就是主幹分支 master
)最後一個提交對象(v7
)為新的出發點,逐個應用之前準備好的補丁文件,最後會生成兩個新的合併提交對象(v4'
,v5'
),從而改寫 dev
的提交歷史,使它成為 master 分支的直接下游,如下圖:
現在,就可以回到master
分支進行快速合併 Fast-forward 了,因為master
分支和dev
分支在一條線上:
$ git checkout master
$ git merge dev
現在的v5'
對應的快照,其實和普通的三方合併,即上個例子中的 v8
對應的快照內容一模一樣。雖然最後整合得到的結果沒有任何區別,但變基能產生一個更為整潔的提交歷史。如果視察一個變基過的分支的歷史紀錄,看起來會更清楚:仿佛所有修改都是在一根線上先後進行的,儘管實際上它們原本是同時並行發生的。
總結#
- Git 保存文件的完整內容,不保存差量變化。
- Git 以儲鍵值對(
key-value
)的方式保存文件。 - 每一個文件,相同文件的不同版本,都有一個唯一的 40 位的 SHA-1 校驗和與之對應。
- SHA-1 校驗和是文件的指針,Git 依靠它來區分文件。
- 每一個文件都會在 Git 的版本庫裡生成
blob
對象來保存。 - 對於沒有變化的文件,Git 只會保留上個版本的指針。
- Git 實際上是通過維持複雜的文件樹來實現版本控制的。
- 使用 Git 的工作流程基本就是就是文件在三個工作區域之間的流動。
- 應該大量使用分支進行團隊協作。
- 分支只是對提交對象的一個引用。