Git原理入門簡析
- 2019 年 10 月 3 日
- 筆記
為了獲得更好的閱讀體驗,建議訪問原地址:傳送門
前言: 之前聽過公司大佬分享過 Git 原理之後就想來自己總結一下,最近一忙起來就拖得久了,本來想塞更多的乾貨,但是不喜歡拖太久,所以先出一版足夠入門的;
一、Git 簡介
Git 是當前流行的分散式版本控制管理工具,最初由 Linux Torvalds (Linux 之父) 創造,於 2005 年發布。
Git,這個詞其實源自英國俚語,意思大約是 「混賬」。Linux 為什麼會以這樣自嘲的名字來命名呢?這其中還有一段兒有趣的歷史可以說一說:
Git 的誕生:
很多人都知道,Linus 在 1991 年創建了開源的 Linux,從此,Linux 系統不斷發展,已經成為最大的伺服器系統軟體了。
Linus 雖然創建了 Linux,但 Linux 的壯大是靠全世界熱心的志願者參與的,這麼多人在世界各地為 Linux 編寫程式碼,那 Linux 的程式碼是如何管理的呢?
事實是,在 2002 年以前,世界各地的志願者把源程式碼文件通過 diff 的方式發給 Linus,然後由 Linus 本人通過手工方式合併程式碼!
你也許會想,為什麼 Linus 不把 Linux 程式碼放到版本控制系統里呢?不是有 CVS、SVN 這些免費的版本控制系統嗎?因為 Linus 堅定地反對 CVS 和 SVN,這些集中式的版本控制系統不但速度慢,而且必須聯網才能使用。有一些商用的版本控制系統,雖然比 CVS、SVN 好用,但那是付費的,和 Linux 的開源精神不符。
不過,到了 2002 年,Linux 系統已經發展了十年了,程式碼庫之大讓 Linus 很難繼續通過手工方式管理了,社區的弟兄們也對這種方式表達了強烈不滿,於是 Linus 選擇了一個商業的版本控制系統 BitKeeper,BitKeeper 的東家 BitMover 公司出於人道主義精神,授權 Linux 社區免費使用這個版本控制系統。
安定團結的大好局面在 2005 年就被打破了,原因是 Linux 社區牛人聚集,不免沾染了一些梁山好漢的江湖習氣。開發 Samba 的 Andrew 試圖破解 BitKeeper 的協議(這麼乾的其實也不只他一個),被 BitMover 公司發現了(監控工作做得不錯!),於是 BitMover 公司怒了,要收回 Linux 社區的免費使用權。
Linus 可以向 BitMover 公司道個歉,保證以後嚴格管教弟兄們,嗯,這是不可能的。實際情況是:Linus 花了兩周時間自己用 C 寫了一個分散式版本控制系統,這就是 Git!一個月之內,Linux 系統的源碼已經由 Git 管理了!牛是怎麼定義的呢?大家可以體會一下。
Git 迅速成為最流行的分散式版本控制系統,尤其是 2008 年,GitHub 網站上線了,它為開源項目免費提供 Git 存儲,無數開源項目開始遷移至 GitHub,包括 jQuery,PHP,Ruby 等等。
歷史就是這麼偶然,如果不是當年 BitMover 公司威脅 Linux 社區,可能現在我們就沒有免費而超級好用的 Git 了。
版本控制系統
不管是集中式的 CVS、SVN 還是分散式的 Git 工具,實際上都是一種版本控制系統,我們可以通過他們很方便的管理我們的文件、程式碼等,我們可以先來暢想一下如果自己來設計這麼一個系統,你會怎麼設計?
摁,這不禁讓我想起了之前寫畢業論文的日子,我先在一個開闊的空間創建了一個文件夾用於保存我的各種版本,然後開始了我的 「畢業論文版本管理」,參考下圖:
這好像暴露了我寫畢業論文愉快的經歷..但不管怎麼樣,我在用一個粗粒度版本的制度,在對我的畢業論文進行著管理,摁,我通過不停在原基礎上迭代出新的版本的方式,不僅保存了我各個版本的畢業論文,還有這清晰的一個路徑,完美?NO!
問題是:
- 每一次的迭代都更改了什麼東西,我現在完全看不出來了!
- 當我在迭代我的超級無敵怎麼樣都不改的版本的時候,突然回想起好像之前版本 1.0 的第一節內容和 2.0 版本第三節的內容加起來才是最棒的,我需要打開多個文檔並創建一個新的文檔,仔細對比文檔中的不同並為我的新文檔添加新的東西,好麻煩啊…
- 到最後文件多起來的時候,我甚至都不知道是我的 「超級無敵版」 是最終版,還是 「打死都不改版」 是最終版了;
- 更為要命的是,我保存在我的桌面上,沒有備份,意味著我本地文件手滑刪除了,那我就…我就…就…
並且可能問題還遠不止於此,所以每每想起,就不自覺對 Linux 膜拜了起來。
集中式與分散式的不同
Git 採用與 CSV/SVN 完全不同的處理方式,前者採用分散式,而後面兩個都是集中式的版本管理。
先說集中式版本控制系統,版本庫是集中存放在中央伺服器的,而幹活的時候,用的都是自己的電腦,所以要先從中央伺服器取得最新的版本,然後開始幹活,幹完活了,再把自己的活推送給中央伺服器。中央伺服器就好比是一個圖書館,你要改一本書,必須先從圖書館借出來,然後回到家自己改,改完了,再放回圖書館。
集中式版本控制系統最大的毛病就是必須聯網才能工作,如果在區域網內還好,頻寬夠大,速度夠快,可如果在互聯網上,遇到網速慢的話,可能提交一個10M的文件就需要5分鐘,這還不得把人給憋死啊。
那分散式版本控制系統與集中式版本控制系統有何不同呢?首先,分散式版本控制系統根本沒有 「中央伺服器」,每個人的電腦上都是一個完整的版本庫,這樣,你工作的時候,就不需要聯網了,因為版本庫就在你自己的電腦上。既然每個人電腦上都有一個完整的版本庫,那多個人如何協作呢?比方說你在自己電腦上改了文件 A,你的同事也在他的電腦上改了文件 A,這時,你們倆之間只需把各自的修改推送給對方,就可以互相看到對方的修改了。
和集中式版本控制系統相比,分散式版本控制系統的安全性要高很多,因為每個人電腦里都有完整的版本庫,某一個人的電腦壞掉了不要緊,隨便從其他人那裡複製一個就可以了。而集中式版本控制系統的中央伺服器要是出了問題,所有人都沒法幹活了。
在實際使用分散式版本控制系統的時候,其實很少在兩人之間的電腦上推送版本庫的修改,因為可能你們倆不在一個區域網內,兩台電腦互相訪問不了,也可能今天你的同事病了,他的電腦壓根沒有開機。因此,分散式版本控制系統通常也有一台充當 「中央伺服器」 的電腦,但這個伺服器的作用僅僅是用來方便 「交換」 大家的修改,沒有它大家也一樣幹活,只是交換修改不方便而已。
當然,Git 的強大還遠不止此。
二、Git 原理入門
Git 初始化
首先,讓我們來創建一個空的項目目錄,並進入該目錄。
$ mkdir git-demo-project $ cd git-demo-project
如果我們打算對該項目進行版本管理,第一件事就是使用 git init
命令,進行初始化。
$ git init
git init
命令只會做一件事,就是在項目的根目錄下創建一個 .git
的子目錄,用來保存當前項目的一些版本資訊,我們可以繼續使用 tree -a
命令查看該目錄的完整結構,如下:
$ tree -a . └── .git ├── HEAD ├── branches ├── config ├── description ├── hooks │ ├── applypatch-msg.sample │ ├── commit-msg.sample │ ├── fsmonitor-watchman.sample │ ├── post-update.sample │ ├── pre-applypatch.sample │ ├── pre-commit.sample │ ├── pre-push.sample │ ├── pre-rebase.sample │ ├── pre-receive.sample │ ├── prepare-commit-msg.sample │ └── update.sample ├── index ├── info │ └── exclude ├── objects │ ├── .DS_Store │ ├── info │ └── pack └── refs ├── heads └── tags
Git 目錄簡單解析
config 目錄
config 是倉庫的配置文件,一個典型的配置文件如下,我們創建的遠端,分支都在等資訊都在配置文件里有表現;fetch
操作的行為也是在這裡配置的:
[core] repositoryformatversion = 0 filemode = false bare = false logallrefupdates = true symlinks = false ignorecase = true [remote "origin"] url = [email protected]:yanhaijing/zepto.fullpage.git fetch = +refs/heads/*:refs/remotes/origin/* [branch "master"] remote = origin merge = refs/heads/master [branch "dev"] remote = origin merge = refs/heads/dev
objects 目錄
Git 可以通過一種演算法可以得到任意文件的 「指紋」(40 位 16 進位數字),然後通過文件指紋存取數據,存取的數據都位於 objects 目錄。
例如我們可以手動創建一個測試文本文件並使用 git add .
命令來觀察 .git
文件夾出現的變化:
$ touch test.txt $ git add .
git add .
命令就是用於把當前新增的變化添加進 Git 本地倉庫的,在我們使用後,我們驚奇的發現 .git
目錄下的 objects/
目錄下多了一個目錄:
$ tree -a . ├── .git │ ├── HEAD │ ├── branches │ ├── config │ ├── description │ ├── hooks │ │ ├── 節省篇幅..省略.. │ ├── index │ ├── info │ │ └── exclude │ ├── objects │ │ ├── .DS_Store │ │ ├── e6 │ │ │ └── 9de29bb2d1d6434b8b29ae775ad8c2e48c5391 │ │ ├── info │ │ └── pack │ └── refs │ ├── heads │ └── tags └── test.txt
我們可以使用 git hash-object test.txt
命令來看看剛才我們創建的 test.txt
的 「文件指紋」:
$ git hash-object test.txt e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
這時候我們可以發現,新創建的目錄 e6
其實是該文件哈希值的前兩位,這其實是 Git 做的一層類似於索引一樣的東西,並且默認採用 16 進位的兩位數來當索引,是非常合適的。
objects 目錄下有 3 種類型的數據:
- Blob;
- Tree;
- Commit;
文件都被存儲為 blob 類型的文件,文件夾被存儲為 tree 類型的文件,創建的提交節點被存儲為 Commit 類型的數據;
一般我們系統中的目錄(tree),在 Git 會像下面這樣存儲:
而 Commit 類型的數據則整合了 tree 和 blob 類型,保存了當前的所有變化,例如我們可以再在剛才的目錄下新建一個目錄,並添加一些文件試試:
$ mkdir test $ touch test/test.file $ tree -a . ├── .git │ ├── HEAD │ ├── branches │ ├── config │ ├── description │ ├── hooks │ │ ├── 節省篇幅..省略.. │ ├── index │ ├── info │ │ └── exclude │ ├── objects │ │ ├── .DS_Store │ │ ├── e6 │ │ │ └── 9de29bb2d1d6434b8b29ae775ad8c2e48c5391 │ │ ├── info │ │ └── pack │ └── refs │ ├── heads │ └── tags ├── test │ └── test.file └── test.txt
提交一個 Commit 再觀察變化:
$ git commit -a -m "test: 新增測試文件夾和測試文件觀察.git文件的變化" [master (root-commit) 30d51b1] test: 新增測試文件夾和測試文件觀察.git文件的變化 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 test.txt $ tree -a . ├── .git │ ├── COMMIT_EDITMSG │ ├── HEAD │ ├── branches │ ├── config │ ├── description │ ├── hooks │ │ ├── 節省篇幅..省略.. │ ├── index │ ├── info │ │ └── exclude │ ├── logs │ │ ├── HEAD │ │ └── refs │ │ └── heads │ │ └── master │ ├── objects │ │ ├── .DS_Store │ │ ├── 30 │ │ │ └── d51b1edd2efd551dd6bd52d4520487b5708c0e │ │ ├── 5e │ │ │ └── fb9bc29c482e023e40e0a2b3b7e49cec842034 │ │ ├── e6 │ │ │ └── 9de29bb2d1d6434b8b29ae775ad8c2e48c5391 │ │ ├── info │ │ └── pack │ └── refs │ ├── heads │ │ └── master │ └── tags ├── test │ └── test.file └── test.txt
首先我們可以觀察到我們提交了一個 Commit 的時候在第一句話裡面返回了一個短的像是哈希值一樣的東西: [master (root-commit) 30d51b1]
中 的 30d51b1
,對應的我們也可以在 objects 找到剛才 commit 的對象,我們可以使用 git cat-file -p
命令輸出一下當前文件的內容:
$ git cat-file -p 30d5 tree 5efb9bc29c482e023e40e0a2b3b7e49cec842034 author 我沒有三顆心臟 <[email protected]> 1565742122 +0800 committer 我沒有三顆心臟 <[email protected]> 1565742122 +0800 test: 新增測試文件夾和測試文件觀察.git文件的變化
我們發現這裡面有提交的內容資訊、作者資訊、提交者資訊以及 commit message,當然我們可以進一步看到提交的內容具體有哪些:
$ git cat-file -p 5efb 100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 test.txt
我們再試著提交一個 commit 來觀察變化:
$ touch test/test2.file $ git commit -a -m "test: 新增加一個 commit 以觀察變化." [master 9dfabac] test: 新增加一個 commit 以觀察變化. 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 test/test.file create mode 100644 test/test2.file $ git cat-file -p 9dfabac tree c562bfb9441352f4c218b0028148289f1ea7d7cd parent 30d51b1edd2efd551dd6bd52d4520487b5708c0e author 龍滔 <[email protected]> 1565878699 +0800 committer 龍滔 <[email protected]> 1565878699 +0800 test: 新增加一個 commit 以觀察變化.
可以觀察到這一次的 commit 多了一個 parent 的行,其中的 「指紋」 和上一次的 commit 一模一樣,當我們提交兩個 commit 之後我們的 Git 倉庫可以簡化為下圖:
- 說明:其中因為我們 test 文件夾新增了文件,也就是出現了變化,所以就被標識成了新的 tree 類型的對象;
refs 目錄
refs 目錄存儲都是引用文件,如本地分支,遠端分支,標籤等
- refs/heads/xxx 本地分支
- refs/remotes/origin/xxx 遠端分支
- refs/tags/xxx 本地tag
引用文件的內容都是 40 位長度的 commit
$ cat .git/refs/heads/master 9dfabac68470a588a4b4a78742249df46438874a
這就像是一個指針一樣,它指向了你的最後一次提交(例如這裡就指向了第二次提交的 commit),我們補充上分支資訊,現在的 Git 倉庫就會像下圖所示:
HEAD 目錄
HEAD 目錄下存儲的是當前所在的位置,其內容是分支的名稱:
$ cat HEAD ref: refs/heads/master
我們再補充上 HEAD 的資訊,現在的 Git 倉庫如下圖所示:
Git 中的衝突
您也在上面了解到了,在 Git 中分支是一種十分輕便的存在,僅僅是一個指針罷了,我們在廣泛的使用分支中,不可避免的會遇到新創建分支的合併,這時候不論是選擇 merge 還是 rebase,都有可能發生衝突,我們先來看一下衝突是如何產生的:
圖上的情況,並不是移動分支指針就能夠解決問題的,它需要一種合併策略。首先我們需要明確的是誰與誰的合併,是 2,3 與 4, 5, 6 兩條線的合併嗎?其實並不是的,真實合併的其實只有 3 和 6,因為每一次的提交都包含了項目完整的快照,即合併只是 tree 與 tree 的合併。
這可能說起來有點繞,我們可以先來想一個簡單的演算法,用來比較 3 和 6 的不同。如果我們只是單純的比較 3 和 6 的資訊,其實並沒有意義,因為它們之間並不能確切的表達出當前的衝突狀態。因此我們需要選取它們兩個分支的分歧點(merge base)作為參考點,進行比較。
首先我們把 1 作為基礎,然後把 1、3、6 中所有的文件做一個列表,然後依次遍歷這個列表中的文件。我們現在拿列表中的一個文件進行舉例,把在提交在 1、3、6 中的該文件分別稱為版本1、版本3、版本6,可能出現如下幾種情況:
1. 版本 1、版本 3、版本 6 的 「指紋」 值都相同:這種情況則說明沒有衝突;
2. 版本 3 or 版本 6 至少有一個與版本 1 狀態相同(指的是指紋值相同或都不存在):這種情況可以自動合併,比如版本 1 中存在一個文件,在版本 3 中沒有對該文件進行修改,而版本 6 中刪除了這個文件,則以版本 6 為準就可以了;
3. 版本 3 or 版本 6 都與版本 1 的狀態不同:這種情況複雜一些,自動合併策略很難生效了,所以需要手動解決;
merge 操作
在解決完衝突後,我們可以將修改的內容提交為一個新的提交,這就是 merge。
可以看到 merge 是一種不修改分支歷史提交記錄的方式,這也是我們常用的方式。但是這種方式在某些情況下使用起來不太方便,比如我們創建了一些提交發送給管理者,管理者在合併操作中產生了衝突,還需要去解決衝突,這無疑增加了他人的負擔。
而我們使用 rebase 可以解決這種問題。
rebase 操作
假設我們的分支結構如下:
rebase 會把從 Merge Base 以來的所有提交,以修補程式的形式一個一個重新打到目標分支上。這使得目標分支合併該分支的時候會直接 Fast Forward(可以簡單理解為直接後移指針),即不會產生任何衝突。提交歷史是一條線,這對強迫症患者可謂是一大福音。
其實 rebase 主要是在 .git/rebase-merge 下生成了兩個文件,分別為 git-rebase-todo 和 done 文件,這兩個文件的作用光看名字就大概能夠看得出來。git-rebase-todo 中存放了 rebase 將要操作的 commit,而 done 存放正操作或已操作完畢的 commit,比如我們這裡,git-rebase-todo 存放了 4、5、6 三個提交。
首先 Git 會把 4 這個 commit 放入 done,表示正在操作 4,然後將 4 以修補程式的方式打到 3 上,形成了新的 4`,這一步是可能產生衝突的,如果有衝突,需要解決衝突之後才能繼續操作。
接著按同樣的方式把 5、6 都放入 done,最後把指針移動到最新的提交 6` 上,就完成了 rebase 的操作。
從剛才的圖中,我們就可以看到 rebase 的一個缺點,那就是修改了分支的歷史提交。如果已經將分支推送到了遠程倉庫,會導致無法將修改後的分支推送上去,必須使用 -f 參數(force)強行推送。
所以使用 rebase 最好不要在公共分支上進行操作。
Squash and Merge 操作
簡單說就是壓縮提交,把多次的提交融合到一個 commit 中,這樣的好處不言而喻,我們著重來討論一下實現的技術細節,還是以我們上面最開始的分支情況為例,首先,Git 會創建一個臨時分支,指向當前 feature 的最新 commit。
然後按照上面 rebase 的方式,變基到 master 的最新 commit 處。
接著用 rebase 來 squash 之,壓縮這些提交為一個提交。
最後以 fast forward 的方式合併到 master 中。
可見此時 master 分支多且只多了一個描述了這次改動的提交,這對於大型工程,保持主分支的簡潔易懂有很大的幫助。
說明:想要了解更多的諸如 checkout、cherry-pick 等操作的話可以看看參考文章的第三篇,這裡就不做細緻描述了。
三、總結
通過上面的了解,其實我們已經大致的掌握了 Git 中的基本原理,我們的 Commit 就像是一個鏈表節點一樣,不僅有自身的節點資訊,還保存著上一個節點的指針,然後我們以 Branch 這樣輕量的指針保存著一條又一條的 commit 鏈條,不過值得注意的是,objects 目錄下的文件是不會自動刪除的,除非你手動 GC,不然本地的 objects 目錄下就保留著你當前項目完整的變化資訊,所以我們通常都會看到 Git 上面的項目通常是沒有 .git 目錄的,不然僅僅通過 .git 目錄理論上就可以還原出你的完整項目!
參考文章
- https://www.liaoxuefeng.com/wiki/896043488029600/896202780297248 – 集中式vs分散式(廖雪峰的官方網站)
- https://yanhaijing.com/git/2017/02/08/deep-git-3/ – 起底Git-Git內部原理
- https://coding.net/help/doc/practice/git-principle.html – 使用原理視角看 Git
按照慣例黏一個尾巴:
歡迎轉載,轉載請註明出處!
獨立域名部落格:wmyskxz.com
簡書ID:@我沒有三顆心臟
github:wmyskxz
歡迎關注公眾微訊號:wmyskxz
分享自己的學習 & 學習資料 & 生活
想要交流的朋友也可以加qq群:3382693