1. 程式人生 > >Git原理入門簡析

Git原理入門簡析

為了獲得更好的閱讀體驗,建議訪問原地址:傳送門
前言: 之前聽過公司大佬分享過 Git 原理之後就想來自己總結一下,最近一忙起來就拖得久了,本來想塞更多的乾貨,但是不喜歡拖太久,所以先出一版足夠入門的;

一、Git 簡介


Git 是當前流行的分散式版本控制管理工具,最初由 Linux Torvalds (Linux 之父) 創造,於 2005 年釋出。

Git,這個詞其實源自英國俚語,意思大約是 “混賬”。Linux 為什麼會以這樣自嘲的名字來命名呢?這其中還有一段兒有趣的歷史可以說一說:

  • 以下摘自:https://www.liaoxuefeng.com/wiki/896043488029600/896202815778784

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. 每一次的迭代都更改了什麼東西,我現在完全看不出來了!
  2. 當我在迭代我的超級無敵怎麼樣都不改的版本的時候,突然回想起好像之前版本 1.0 的第一節內容和 2.0 版本第三節的內容加起來才是最棒的,我需要開啟多個文件並建立一個新的文件,仔細對比文件中的不同併為我的新文件新增新的東西,好麻煩啊...
  3. 到最後檔案多起來的時候,我甚至都不知道是我的 “超級無敵版” 是最終版,還是 “打死都不改版” 是最終版了;
  4. 更為要命的是,我儲存在我的桌面上,沒有備份,意味著我本地檔案手滑刪除了,那我就...我就...就...

並且可能問題還遠不止於此,所以每每想起,就不自覺對 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 目錄理論上就可以還原出你的完整專案!

參考文章


  1. https://www.liaoxuefeng.com/wiki/896043488029600/896202780297248 - 集中式vs分散式(廖雪峰的官方網站)
  2. https://yanhaijing.com/git/2017/02/08/deep-git-3/ - 起底Git-Git內部原理
  3. https://coding.net/help/doc/practice/git-principle.html - 使用原理視角看 Git

按照慣例黏一個尾巴:

歡迎轉載,轉載請註明出處!
獨立域名部落格:wmyskxz.com
簡書ID:@我沒有三顆心臟
github:wmyskxz
歡迎關注公眾微訊號:wmyskxz
分享自己的學習 & 學習資料 & 生活
想要交流的朋友也可以加qq群:3382693