為了獲得更好的閱讀體驗,建議訪問原地址:傳送門
前言: 之前聽過公司大佬分享過 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 = git@github.com: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 我沒有三顆心臟 <wmyskxz@wmyskxzdeMacBook-Pro.local> 1565742122 +0800
committer 我沒有三顆心臟 <wmyskxz@wmyskxzdeMacBook-Pro.local> 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 龍滔 <longtao@longtaodeMacBook-Pro.local> 1565878699 +0800
committer 龍滔 <longtao@longtaodeMacBook-Pro.local> 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