Git是一個(gè)快速,可擴(kuò)展的分布式版本控制系統(tǒng)。從根本上來說,Git是一個(gè)內(nèi)容尋址(content-addressable)文件系統(tǒng)。
Git提供了非常豐富的指令集,根據(jù)功能劃分為底層命令和高層命令,平時(shí)工作中使用最多的add、commit、reset等就屬于高層命令。大部分情況下我們不會(huì)接觸到Git的底層命令,但是為了了解Git的工作原理,了解底層命令就很重要了。
接下來我們來探索Git的內(nèi)部工作原理
.git目錄結(jié)構(gòu)
首先我們新建一個(gè)空文件夾,執(zhí)行git init初始化命令,創(chuàng)建git版本庫。
$ mkdir test1
$ cd test1
$ git init
$ cd .git
$ ls -F1
.git目錄結(jié)構(gòu)如下:
HEAD // HEAD指針,指向當(dāng)前分支
branches/ // 分支
config // 項(xiàng)目特有的配置選項(xiàng)
description // 僅供 GitWeb 程序使用,無需關(guān)心
hooks/ // 客戶端或服務(wù)端的鉤子腳本
info/
objects/ // 存儲(chǔ)所有數(shù)據(jù)內(nèi)容
refs/ // 存儲(chǔ)指向數(shù)據(jù)(分支)的提交對象的指針
index // 保存暫存區(qū)信息(尚待創(chuàng)建)
.git的目錄結(jié)構(gòu)及作用上面已經(jīng)添加了備注,其中有四個(gè)條目很重要:HEAD文件、尚未創(chuàng)建的index文件,和objects目錄、refs目錄。這些條目是Git的核心組成部分。
Git對象
Git一共有四種類型的對象:
- Blob object
- Commit object
- Tree object
- Tag object
Blob對象存儲(chǔ):
- 數(shù)據(jù)內(nèi)容(文本文件、源代碼、圖片等)
Commit對象存儲(chǔ):
- tree對象
- parent指針(如果有)
- 作者對象(姓名、郵箱、提交時(shí)間)
- 提交者對象(姓名、郵箱、提交時(shí)間)
Tree對象存儲(chǔ):
- 指向數(shù)據(jù)內(nèi)容或者Tree對象的指針(SHA-1)
- 模式
- 類型
- 文件名
Tag對象一般是Commit對象
Tree對象對應(yīng)文件目錄,blob對象對應(yīng)文件,commit對象對應(yīng)當(dāng)前分支的快照(snapshot)
執(zhí)行g(shù)it add和git commit時(shí)發(fā)生了什么?
上面已經(jīng)創(chuàng)建一個(gè)test1的空目錄,接下來我們添加一個(gè)文件到test1目錄。
echo 'hello' > test.txt
執(zhí)行g(shù)it status命令,可以看到目錄下多了個(gè)未跟蹤的文件test.txt,這時(shí)候執(zhí)行g(shù)it add命令,觀察.git目錄有什么變化。
git add test.txt
cd .git
→ tree
.
├── 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
│ ├── ce
│ │ └── 013625030ba8dba906f756967f9e9ca394464a
│ ├── info
│ └── pack
└── refs
├── heads
└── tags
10 directories, 17 files
可以看到,執(zhí)行g(shù)it add命令后,.git/object/目錄下新增了一個(gè)目錄和一個(gè)文件(其實(shí)是一個(gè)40位哈希值,前兩位是目錄名,后38位是文件名),其實(shí)只是多出了一個(gè)blob對象,同時(shí)創(chuàng)建了一個(gè)index文件。后面我們會(huì)講解這個(gè)新增的blob對象以及index文件是如何生成的。
我們接著執(zhí)行g(shù)it commit命令,看看會(huì)發(fā)生什么。
→ tree
.
├── COMMIT_EDITMSG
├── 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
├── logs
│ ├── HEAD
│ └── refs
│ └── heads
│ └── master
├── objects
│ ├── 2b
│ │ └── f705f913222f2032e114e609dfe7f2e97f23bd
│ ├── 92
│ │ └── 0512d27e4df0c79ca4a929bc5d4254b3d05c4c
│ ├── ce
│ │ └── 013625030ba8dba906f756967f9e9ca394464a
│ ├── info
│ └── pack
└── refs
├── heads
│ └── master
└── tags
15 directories, 23 files
觀察控制臺(tái)的輸出可以看到,commit之后多出了5個(gè)目錄和5個(gè)文件,我們只關(guān)注objects/目錄和refs/目錄。執(zhí)行commit之后,objects目錄下新增了兩個(gè)對象,同時(shí)refs/heads/目錄下新增了一個(gè)master文件。
總結(jié)一下,執(zhí)行g(shù)it add和git commit命令,objects目錄下新增了3個(gè)對象,新增了一個(gè)index文件和master文件。
這些對象和文件是如何產(chǎn)生的呢,下面我們通過git的底層命令來復(fù)盤上面的操作。
使用Git底層命令演示git add和git commit的原理
下面我們新建一個(gè)空目錄test2,并執(zhí)行g(shù)it init命令
mkdir test2
cd test2
git init
使用git has-object往Git數(shù)據(jù)庫寫入內(nèi)容
echo 'hello' | git hash-object -w --stdin
ce013625030ba8dba906f756967f9e9ca394464a
可以看到,控制臺(tái)輸出了一個(gè)40位的哈希值,與上面對比發(fā)現(xiàn)是一樣的。為什么是一樣的呢?因?yàn)镚it是通過頭部信息(header)+數(shù)據(jù)內(nèi)容(content)通過SHA-1檢驗(yàn)計(jì)算校驗(yàn)和生成的。
執(zhí)行完has-object命令后,觀察objects/目錄的變化,我們用find命令來查看
→ find .git/objects -type f
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
可以看到object/目錄下多了一個(gè)對象,正是我們剛剛寫進(jìn)Git數(shù)據(jù)后返回的哈希值。
Git數(shù)據(jù)庫是一個(gè)簡單的key-value data store,哈希值作為key,數(shù)據(jù)內(nèi)容作為value。我們可以通過cat-file命令查看這個(gè)key對應(yīng)的內(nèi)容以及類型。
→ git cat-file -p ce013625030ba8dba906f756967f9e9ca394464a
hello
→ git cat-file -t ce013625030ba8dba906f756967f9e9ca394464a
blob
可以看到,輸出的內(nèi)容正是我們之前寫入的,類型是blob類型。
接著我們執(zhí)行g(shù)it status命令看看發(fā)生了什么
→ git status
On branch master
No commits yet
nothing to commit (create/copy files and use "git add" to track)
這里有一個(gè)問題,我們之前僅僅保存了文件內(nèi)容,并沒有指定文件名。我們使用tree命令,查看.git目錄,可以發(fā)現(xiàn),index文件還沒有被創(chuàng)建。
下面我們使用update-index命令來創(chuàng)建一個(gè)暫存區(qū),并為之前寫入的文件內(nèi)容指定一個(gè)文件名。
git update-index --add --cacheinfo 100644 ce013625030ba8dba906f756967f9e9ca394464a test.txt
再次使用tree命令查看
→ tree .git
.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
│ ├── ce
│ │ └── 013625030ba8dba906f756967f9e9ca394464a
│ ├── info
│ └── pack
└── refs
├── heads
└── tags
10 directories, 17 files
可以發(fā)現(xiàn)index文件被創(chuàng)建出來了。這個(gè)時(shí)候,再次執(zhí)行g(shù)it status
→ git status
On branch master
No commits yet
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: test.txt
Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
deleted: test.txt
可以發(fā)現(xiàn),test.txt文件已經(jīng)加入暫存區(qū)了,下面還有一個(gè)標(biāo)記為刪除的test.txt,為什么呢?
因?yàn)槲覀儽镜貨]有test.txt文件,而暫存區(qū)有這個(gè)文件,使用git status的時(shí)候是對比工作目錄和暫存區(qū)的文件。
如果介意這個(gè)deleted的log,可以已通過git checkout -- test.txt命令讓暫存區(qū)的文件覆蓋工作區(qū)的文件,我們先不管。
執(zhí)行g(shù)it add的時(shí)候,除了上面兩步,還有一步是將文件寫入一個(gè)樹對象??梢酝ㄟ^git write-tree命令來完成
git write-tree ce013625030ba8dba906f756967f9e9ca394464a
920512d27e4df0c79ca4a929bc5d4254b3d05c4c
執(zhí)行完后發(fā)現(xiàn),輸出了一個(gè)40位的哈希值,與上面git add之后對比,可以發(fā)現(xiàn)是同一個(gè)哈希值。
我們驗(yàn)證下這個(gè)對象的類型:
→ git cat-file -t 920512d27e4df0c79ca4a929bc5d4254b3d05c4c
tree
可以發(fā)現(xiàn)這確實(shí)是個(gè)樹對象。
至此,我們使用底層命令完成了git add所做的工作。git add的時(shí)候做什么,我們可以總結(jié)下:
- 將文件內(nèi)容寫入Git數(shù)據(jù)庫
- 更新暫存區(qū)并指定文件名,如果是首次提交到暫存區(qū)則是創(chuàng)建暫存區(qū)(對應(yīng).git目錄下的index文件)
- 將文件寫入樹對象
到這里我們還沒有提交對象,是時(shí)候創(chuàng)建一次提交了。我們可以使用commit-tree命令創(chuàng)建一次提交。
→ echo 'first commit' | git commit-tree 9205
c4cf32be0098f786df455e3fee67ba21779dd70a
可以發(fā)現(xiàn)這里輸出的哈希值與test1演示項(xiàng)目中的值不同,其他兩個(gè)都相同,為什么呢?因?yàn)閏ommit對象包含時(shí)間戳信息,所以計(jì)算出來的哈希值肯定是不一樣的。
驗(yàn)證下這個(gè)對象的類型:
→ git cat-file -t c4cf
commit
可以發(fā)現(xiàn)這確實(shí)是一個(gè)commit對象。
使用git log c4cf命令查看:
commit c4cf32be0098f786df455e3fee67ba21779dd70a
Author: yfm <imyangfm@gmail.com>
Date: Mon Apr 29 16:30:31 2019 +0800
first commit
可以看到我們在不使用高層命令的情況下,也完成了一個(gè)完整的提交歷史。
別高興的太早,我們我們使用git log命令查看,發(fā)現(xiàn)似乎少了點(diǎn)什么東西
→ git log
fatal: your current branch 'master' does not have any commits yet
使用git log命令查看,發(fā)現(xiàn)master分支還沒有任何提交信息,為什么呢?
對照test1項(xiàng)目,可以發(fā)現(xiàn)我們r(jià)efs/heads目錄下還少了個(gè)master文件。master文件執(zhí)行最近一次提交的引用。不可能每次通過哈希值去追溯提交歷史,我們可以起個(gè)簡單的名字方便我們記憶,Git默認(rèn)的分支名是master,我們就用這個(gè)名字代替提交對象的哈希值。
echo 'c4cf32be0098f786df455e3fee67ba21779dd70a' > .git/refs/heads/master
再次運(yùn)行g(shù)it log,發(fā)現(xiàn)已經(jīng)與test1項(xiàng)目完全一樣了,至此我們使用底層命令完成了git add和git commit所做的所有工作。
git log --pretty=oneline
c4cf32be0098f786df455e3fee67ba21779dd70a (HEAD -> master) first commit
總結(jié)下git commit時(shí)做了什么:
- 創(chuàng)建一個(gè)提交對象
- 創(chuàng)建一個(gè)指向改提交對象的master指針
最后用一張圖總結(jié):

SHA-1檢驗(yàn)計(jì)算
哈希(hash)使用數(shù)據(jù)摘要算法(或稱散列算法),是信息安全領(lǐng)域中重要的理論基石。該算法將任意長度的輸入經(jīng)過散列運(yùn)算轉(zhuǎn)換為固定長度輸出。固定長度的輸出可以稱為對應(yīng)輸入內(nèi)容的數(shù)組摘要或哈希值。
前文看到的哪些哈希值是如何計(jì)算的呢?
下面內(nèi)容摘抄自:https://git-scm.com/book/zh/v2/Git-%E5%86%85%E9%83%A8%E5%8E%9F%E7%90%86-Git-%E5%AF%B9%E8%B1%A1
可以通過 irb 命令啟動(dòng) Ruby 的交互模式:
$ irb
>> content = "what is up, doc?"
=> "what is up, doc?"
Git 以對象類型作為開頭來構(gòu)造一個(gè)頭部信息,本例中是一個(gè)“blob”字符串。 接著 Git 會(huì)添加一個(gè)空格,隨后是數(shù)據(jù)內(nèi)容的長度,最后是一個(gè)空字節(jié)(null byte):
>> header = "blob #{content.length}\0"
=> "blob 16\u0000"
Git 會(huì)將上述頭部信息和原始數(shù)據(jù)拼接起來,并計(jì)算出這條新內(nèi)容的 SHA-1 校驗(yàn)和。 在 Ruby 中可以這樣計(jì)算 SHA-1 值——先通過 require 命令導(dǎo)入 SHA-1 digest 庫,然后對目標(biāo)字符串調(diào)用 Digest::SHA1.hexdigest():
>> store = header + content
=> "blob 16\u0000what is up, doc?"
>> require 'digest/sha1'
=> true
>> sha1 = Digest::SHA1.hexdigest(store)
=> "bd9dbf5aae1a3862dd1526723246b20206e5fc37"
Git 會(huì)通過 zlib 壓縮這條新內(nèi)容。在 Ruby 中可以借助 zlib 庫做到這一點(diǎn)。 先導(dǎo)入相應(yīng)的庫,然后對目標(biāo)內(nèi)容調(diào)用 Zlib::Deflate.deflate():
>> require 'zlib'
=> true
>> zlib_content = Zlib::Deflate.deflate(store)
=> "x\x9CK\xCA\xC9OR04c(\xCFH,Q\xC8,V(-\xD0QH\xC9O\xB6\a\x00_\x1C\a\x9D"
最后,需要將這條經(jīng)由 zlib 壓縮的內(nèi)容寫入磁盤上的某個(gè)對象。 要先確定待寫入對象的路徑(SHA-1 值的前兩個(gè)字符作為子目錄名稱,后 38 個(gè)字符則作為子目錄內(nèi)文件的名稱)。 如果該子目錄不存在,可以通過 Ruby 中的 FileUtils.mkdir_p() 函數(shù)來創(chuàng)建它。 接著,通過 File.open() 打開這個(gè)文件。最后,對上一步中得到的文件句柄調(diào)用 write() 函數(shù),以向目標(biāo)文件寫入之前那條 zlib 壓縮過的內(nèi)容:
>> path = '.git/objects/' + sha1[0,2] + '/' + sha1[2,38]
=> ".git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37"
>> require 'fileutils'
=> true
>> FileUtils.mkdir_p(File.dirname(path))
=> ".git/objects/bd"
>> File.open(path, 'w') { |f| f.write zlib_content }
=> 32
就是這樣——你已創(chuàng)建了一個(gè)有效的 Git 數(shù)據(jù)對象。 所有的 Git 對象均以這種方式存儲(chǔ),區(qū)別僅在于類型標(biāo)識(shí)——另兩種對象類型的頭部信息以字符串“commit”或“tree”開頭,而不是“blob”。 另外,雖然數(shù)據(jù)對象的內(nèi)容幾乎可以是任何東西,但提交對象和樹對象的內(nèi)容卻有各自固定的格式。