Git內部儲存原理
Git是程式設計師工作中使用頻率非常高的工具,要提高日常的工作效率,就需要熟練掌握Git的使用方法。相對於傳統的版本控制系統而言,Git更為強大和靈活,其各種命令和命令引數也非常多,如果不瞭解Git的內部原理,要把Git使用得順手的話非常困難。本文將用一個具體的例子來幫助理解Git的內部儲存原理,加深對Git的理解,從掌握各種Git命令,以在使用Git進行工作時得心應手。
Git 目錄結構
Git的本質是一個檔案系統,其工作目錄中的所有檔案的歷史版本以及提交記錄(Commit)都是以檔案物件的方式儲存在.git目錄中的。
首先建立一個work目錄,並採用git init命令初始化git倉庫。該命令會在工作目錄下生成一個.git目錄,該目錄將用於儲存工作區中所有的檔案歷史的歷史版本,提交記錄,branch,tag等資訊。
$ mkdir work $ cd work $ git init
其目錄結構如下:
├── branches不這麼重要,暫不用管 ├── configgit配置資訊,包括使用者名稱,email,remote repository的地址,本地branch和remote |branch的follow關係 ├── description該git庫的描述資訊,如果使用了GitWeb的話,該描述資訊將會被顯示在該repo的頁面上 ├── HEAD工作目錄當前狀態對應的commit,一般來說是當前branch的head,HEAD也可以通過git checkout 命令被直接設定到一個特定的commit上,這種情況被稱之為 detached HEAD ├── hooks鉤子程式,可以被用於在執行git命令時自動執行一些特定操作,例如加入changeid │├── applypatch-msg.sample │├── commit-msg.sample │├── post-update.sample │├── pre-applypatch.sample │├── pre-commit.sample │├── prepare-commit-msg.sample │├── pre-push.sample │├── pre-rebase.sample │└── update.sample ├── info不這麼重要,暫不用管 │└── exclude ├── objects儲存git物件的目錄,包括三類物件commit,tag, tree和blob │├── info │└── pack └── refs儲存branch和tag對應的commit ├── headsbranch對應的commit └── tagstag對應的commit
Git Object儲存方式
目前objects目錄中還沒有任何內容,我們建立一個檔案並提交。
$ echo "my project" > README $ echo "hello world" > src/file1.txt $ git add . $ git commit -sm "init commit" [master (root-commit) b767d71] init commit 2 files changed, 2 insertions(+) create mode 100644 README create mode 100644 src/file1.txt
從列印輸出可以看到,上面的命令建立了一個commit物件,該commit包含兩個檔案。 檢視.git/objects目錄,可以看到該目錄下增加了5個子目錄 06,3b, 82, b7, ca,每個子目錄下有一個以一長串字母數字命令的檔案。
.git/objects ├── 06 │└── 5bcad11008c5e958ff743f2445551e05561f59 ├── 3b │└── 18e512dba79e4c8300dd08aeb37f8e728b8dad ├── 82 │└── 424451ac502bd69712561a524e2d97fd932c69 ├── b7 │└── 67d7115ef57666c9d279c7acc955f86f298a8d ├── ca │└── 964f37599d41e285d1a71d11495ddc486b6c3b ├── info └── pack
說明:Git Object目錄中儲存了三種物件:Commit, tree和blob。Git為物件生成一個檔案,並根據檔案資訊生成一個 SHA-1 雜湊值作為檔案內容的校驗和,建立以該校驗和前兩個字元為名稱的子目錄,並以 (校驗和) 剩下 38 個字元為檔案命名 ,將該檔案儲存至子目錄下。
檢視Git Object儲存內容
通過git cat-file
命令可以檢視Git Object中儲存的內容及物件型別,命令引數為Git Object的SHA-1雜湊值,即目錄名+檔名。在沒有歧義的情況下,不用輸入整個Hash,輸入前幾位即可。
當前分支的物件引用儲存在HEAD檔案中,可以檢視該檔案得到當前HEAD對應的branch,並通過branch查到對應的commit物件。
$ cat .git/HEAD ref: refs/heads/master cat .git/refs/heads/master b767d7115ef57666c9d279c7acc955f86f298a8d
使用 -t 引數檢視檔案型別:
$ git cat-file -t b767d7 commit
使用 -p 引數可以檢視檔案內容:
$ git cat-file -p b767d7 tree ca964f37599d41e285d1a71d11495ddc486b6c3b author Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="e2988a838d8a9783808b8c85a2858f838b8ecc818d8f">[email protected]</a>> 1548055516 +0800 committer Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="83f9ebe2ecebf6e2e1eaede4c3e4eee2eaefade0ecee">[email protected]</a>> 1548055516 +0800 init commit Signed-off-by: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="ef95878e80879a8e8d868188af88828e8683c18c8082">[email protected]</a>>
可以看出這是一個commit物件,commit物件中儲存了commit的作者,commit的描述資訊,簽名信息以及該commit中包含哪些tree物件和blob物件。
b767d7這個commit中儲存了一個tree物件,可以把該tree物件看成這次提交相關的所有檔案的根目錄。讓我們來看看該tree物件中的內容。
$ git cat-file -p ca964f 100644 blob 065bcad11008c5e958ff743f2445551e05561f59README 040000 tree 82424451ac502bd69712561a524e2d97fd932c69src
可以看到該tree物件中包含了一個blob物件,即README檔案;和一個tree物件,即src目錄。 分別檢視該blob物件和tree物件,其內容如下:
$ git cat-file -p 065bca my project $ git cat-file -p 824244 100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dadfile1.txt
檢視file1.txt的內容。
$ git cat-file -p 3b18e51 hello world
從上面的實驗我們可以得知,git中儲存了三種類型的物件,commit,tree和blob。分別對應git commit,此commit中的目錄和檔案。這些物件之間的關係如下圖所示。
HEAD---> refs/heads/master--> b767d7(commit) + | v ca964f(tree) + | +---------+----------+ || vv 065bca(blob)824244(tree) READMEsrc + | v 3b18e5(blob) file1.txt
Git branch和tag
從refs/heads/master的內容可以看到,branch是一個指向commit的指標,master branch實際是指向了b767d7這個commit。
$ git checkout -b work Switched to a new branch 'work' $ tree .git/refs/ .git/refs/ ├── heads │├── master │└── work └── tags $ cat .git/refs/heads/work .git/refs/heads/master b767d7115ef57666c9d279c7acc955f86f298a8d b767d7115ef57666c9d279c7acc955f86f298a8d
上面的命令建立了一個work branch。從其內容可以看到,該branch並沒有建立任何新的版本檔案,和master一樣指向了b767d7這個commit。
從上面的實驗可以看出,一個branch其實只是一個commit物件的應用,Git並不會為每個branch儲存一份拷貝,因此在git中建立branch幾乎沒有任何代價。
在work branch上進行一些修改,然後提交。
$ echo "new line" >> src/file1.txt $ echo "do nothing" >> Makefile $ git commit -sm "some change" [work 4f73993] some change 2 files changed, 2 insertions(+) create mode 100644 Makefile
檢視當前的HEAD和branch內容。
$ cat .git/HEAD ref: refs/heads/work <a href="/cdn-cgi/l/email-protection" data-cfemail="97ffe2f6f5fef9f0d7ffe2f6f5fef9f0baefe2f5e2f9e3e2">[email protected]</a>:~/work$ cat .git/refs/heads/work .git/refs/heads/master 4f73993cf81931bc15375f0a23d82c40b3ae6789 b767d7115ef57666c9d279c7acc955f86f298a8d
可以看到HEAD指向了work branch,而work branch則指向了4f73993這個commit,master branch指向的commit未變化,還是b767d7。
檢視4f73993這個commit物件的內容。
$ git cat-file -p 4f73993 tree 082b6d87eeddb15526b7c920e21f09f950f78b54 parent b767d7115ef57666c9d279c7acc955f86f298a8d author Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="562c3e37393e2337343f383116313b373f3a7835393b">[email protected]</a>> 1548069325 +0800 committer Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="a0dac8c1cfc8d5c1c2c9cec7e0c7cdc1c9cc8ec3cfcd">[email protected]</a>> 1548069325 +0800 some change Signed-off-by: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="8cf6e4ede3e4f9edeee5e2ebccebe1ede5e0a2efe3e1">[email protected]</a>>
可以看到commit有一個parent欄位,指向了前一個commi b767d7。該commit也包含了一個tree物件,讓我們看看其中的內容。
$git cat-file -p082b6d 100644 blob 8cc95f278445722c59d08bbd798fbaf60da8ca14Makefile 100644 blob 065bcad11008c5e958ff743f2445551e05561f59README 040000 tree 9aeacd1fa832ca167b0f72fb1d0c744a9ee1902fsrc $ git cat-file -p 9aeacd 100644 blob 79ee69e841a5fd382faef2be2f2eb6e836cc980afile1.txt
可以看到該tree物件中包含了該版本的所有檔案和目錄,由於README沒有變化,還是指向的065bca這個blob物件。Makefile是一個新建的blob物件,src和file1.txt則指向了新版本的物件。
增加了這次commit後,git中各個物件的關係如下圖所示:
(parent) HEAD--> refs/heads/work--> 4f7399(commit) +-------> b767d7(commit)<---refs/heads/master ++ || vv 082b6d(tree)ca964f(tree) ++ || +-----------------------------++--------+-----------+ ||||| vvvvv 9aeacd(tree)8cc95f(blob)065bca(blob)824244(tree) src (version 2)MakefileREADMEsrc (version 1) ++ || vv 79ee69(blob)3b18e5(blob) file1.txt (version 2)file1.txt (version 1)
從上圖可以看到,Git會為每次commit時修改的目錄/檔案生成一個新的版本的tree/blob物件,如果檔案沒有修改,則會指向老版本的tree/blob物件。而branch則只是指向某一個commit的一個指標。即Git中整個工作目錄的version是以commit物件的形式存在的,可以認為一個commit就是一個version,而不同version可以指向相同或者不同的tree和blob物件,對應到不同版本的子目錄和檔案。如果某一個子目錄/檔案在版本間沒有變化,則不會為該子目錄/檔案生成新的tree/blob物件,不同version的commit物件會指向同一個tree/object物件。
Tag和branch類似,也是指向某個commit的指標。不同的是tag建立後其指向的commit不能變化,而branch建立後,其指標會在提交新的commit後向前移動。
$ git tag v1.0 $ cat .git/refs/tags/v1.0 .git/refs/heads/work 4f73993cf81931bc15375f0a23d82c40b3ae6789 4f73993cf81931bc15375f0a23d82c40b3ae6789
可以看到新建立的v1.0 tag和work branch都是指向了4f7399這個commit。
Git Stash實現原理
Git stash的功能說明:經常有這樣的事情發生,當你正在進行專案中某一部分的工作,裡面的東西處於一個比較雜亂的狀態,而你想轉到其他分支上進行一些工作。問題是,你不想提交進行了一半的工作,否則以後你無法回到這個工作點。解決這個問題的辦法就是git stash命令。
“‘儲藏”“可以獲取你工作目錄的中間狀態——也就是你修改過的被追蹤的檔案和暫存的變更——並將它儲存到一個未完結變更的堆疊中,隨時可以重新應用。
Git是如何實現Stash的呢?理解了Commit, Tree, Blog這三種Git儲存物件,我們就可以很容易理解Git Stash的實現原理。因為和bransh及tag類似,Git Stash其實也是通過Commit來實現的。
通過實驗來測試一下:
$ echo "another line" >> src/file1.txt $ git stash
通過上面的命令,我們在file1.txt中增加了一行,然後通過git stash命令將這些改動“暫存”在了一個“堆疊”中,讓我們來看看.git目錄發生了什麼變化。
$ tree .git/ .git/ ├── branches ├── COMMIT_EDITMSG ├── config ├── description ├── HEAD ├── hooks │├── applypatch-msg.sample │├── commit-msg.sample │├── post-update.sample │├── pre-applypatch.sample │├── pre-commit.sample │├── prepare-commit-msg.sample │├── pre-push.sample │├── pre-rebase.sample │└── update.sample ├── index ├── info │└── exclude ├── logs │├── HEAD │└── refs │├── heads ││├── master ││└── work │└── stash ├── objects │├── 06 ││└── 5bcad11008c5e958ff743f2445551e05561f59 │├── 08 ││└── 2b6d87eeddb15526b7c920e21f09f950f78b54 │├── 11 ││└── a6d1031e4fa2d4da0b6303dd74ed8e85c54057 │├── 33 ││└── f98923002cd224dabf32222c808611badd6d48 │├── 3b ││└── 18e512dba79e4c8300dd08aeb37f8e728b8dad │├── 4f ││└── 73993cf81931bc15375f0a23d82c40b3ae6789 │├── 6a ││├── 1474c4da0653af0245970997b6fab0a0a7c1df ││└── d88760c3be94d8cb582bf2d06b99083d034428 │├── 75 ││└── e170cc1d928ae5a28547b4a3f2f3394a675b9a │├── 79 ││└── ee69e841a5fd382faef2be2f2eb6e836cc980a │├── 82 ││└── 424451ac502bd69712561a524e2d97fd932c69 │├── 8c ││└── c95f278445722c59d08bbd798fbaf60da8ca14 │├── 90 ││└── c43dbb1e71c271510994d6b147c425cbffa673 │├── 9a ││└── eacd1fa832ca167b0f72fb1d0c744a9ee1902f │├── b7 ││└── 67d7115ef57666c9d279c7acc955f86f298a8d │├── ca ││└── 964f37599d41e285d1a71d11495ddc486b6c3b │├── e8 ││└── 83e779eb08e2d9bca1fc1ee722fc80addac312 │├── info │└── pack ├── ORIG_HEAD └── refs ├── heads │├── master │└── work ├── stash └── tags └── v1.0
可以看到objects目錄中增加了一些物件檔案,refs中增加了一個stash檔案。通過命令檢視該檔案內容:
$ cat .git/refs/stash 11a6d1031e4fa2d4da0b6303dd74ed8e85c54057 $ git cat-file -p 11a6 tree 90c43dbb1e71c271510994d6b147c425cbffa673 parent 4f73993cf81931bc15375f0a23d82c40b3ae6789 parent 6a1474c4da0653af0245970997b6fab0a0a7c1df author Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="146e7c757b7c6175767d7a73547379757d783a777b79">[email protected]</a>> 1548326421 +0800 committer Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="3b41535a54534e5a5952555c7b5c565a525715585456">[email protected]</a>> 1548326421 +0800 WIP on work: 4f73993 some change $ git cat-file -p 90c4 100644 blob 8cc95f278445722c59d08bbd798fbaf60da8ca14Makefile 100644 blob 065bcad11008c5e958ff743f2445551e05561f59README 040000 tree 33f98923002cd224dabf32222c808611badd6d48src $ git cat-file -p 33f9 100644 blob 75e170cc1d928ae5a28547b4a3f2f3394a675b9afile1.txt $ git cat-file -p 75e1 hello world new line another line
從命令列輸出可以看到,git stash實際上建立了一個新的commit物件11a6d1, 該commit物件的父節點為4f7399。commit物件中包含了修改後的file1.txt blob物件75e170。通過git log可以檢視:
$ git log --oneline --graph <a href="/cdn-cgi/l/email-protection" data-cfemail="bbc8cfdac8d3fb">[email protected]</a>{0} *f566001 WIP on work: 4f73993 some change |\ | * 0796ced index on work: 4f73993 some change |/ * 4f73993 some change * b767d71 init commit
備註:git stash生成的commit物件有兩個parent,一個是前面一次git commit命令生成的commit,另一個對應於儲存到stage中的commit。
從該試驗可以得知,git stash也是以commit,tree和object物件實現的。Git stash儲存到“堆疊”中的修改其實一個commit物件。
Git reset 實現原理
在進行一些改動以後並通過git commit 將改動的程式碼提交到本地的repo後,如果你測試發現剛才的改動不合理,希望回退剛才的改動,應該如何處理?
我們先提交一個錯誤的改動:
$ echo "I did something wrong" >> src/file1.txt $ git add . $ git commit -sm "This commit should not be there" [work ccbc363] This commit should not be there 1 file changed, 1 insertion(+)
你可以通過git revert回退剛才的改動,或者修改程式碼後再次提交,但這樣的話你的提交log會顯得非常凌亂;如果不想把中間過程的commit push到遠端倉庫,可以通過git reset 回退剛才的改動。
先檢視目前的log
$ git log commit ccbc3638142191bd68454d47a0f67fd12519806b Author: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="bfc5d7ded0d7cadeddd6d1d8ffd8d2ded6d391dcd0d2">[email protected]</a>> Date:Fri Jan 25 12:35:31 2019 +0800 This commit should not be there Signed-off-by: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="e59f8d848a8d9084878c8b82a58288848c89cb868a88">[email protected]</a>> commit 4f73993cf81931bc15375f0a23d82c40b3ae6789 Author: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="4f35272e20273a2e2d2621280f28222e2623612c2022">[email protected]</a>> Date:Mon Jan 21 19:15:25 2019 +0800 some change Signed-off-by: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="7c06141d1314091d1e15121b3c1b111d1510521f1311">[email protected]</a>> commit b767d7115ef57666c9d279c7acc955f86f298a8d Author: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="245e4c454b4c5145464d4a43644349454d480a474b49">[email protected]</a>> Date:Mon Jan 21 15:25:16 2019 +0800 init commit Signed-off-by: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="ceb4a6afa1a6bbafaca7a0a98ea9a3afa7a2e0ada1a3">[email protected]</a>>
通過 git reset回退到上一個commit。注意這裡HEAD是一個指向當前branch最後一個commit指標,因此HEAD~1表示之前的一個commit。git reset命令也可以直接使用commit號作為命令引數。
$ git reset HEAD~1 Unstaged changes after reset: Msrc/file1.txt $ git log commit 4f73993cf81931bc15375f0a23d82c40b3ae6789 Author: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="176d7f76787f6276757e797057707a767e7b3974787a">[email protected]</a>> Date:Mon Jan 21 19:15:25 2019 +0800 some change Signed-off-by: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="83f9ebe2ecebf6e2e1eaede4c3e4eee2eaefade0ecee">[email protected]</a>> commit b767d7115ef57666c9d279c7acc955f86f298a8d Author: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="e49e8c858b8c9185868d8a83a48389858d88ca878b89">[email protected]</a>> Date:Mon Jan 21 15:25:16 2019 +0800 init commit Signed-off-by: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="92e8faf3fdfae7f3f0fbfcf5d2f5fff3fbfebcf1fdff">[email protected]</a>>
可以看到剛才的commit被回退了,但修改的檔案還存在,處於Unstaged狀態,你可以對這些檔案進行改動後再次提交。
如果你不想保留修改的檔案,可以使用–hard引數直接回退到指定的commit,該引數會將HEAD指向該commit,並且工作區中的檔案也會和該comit保持一致,該commit後的修改會被直接丟棄。
$ git reset HEAD --hard HEAD is now at 4f73993 some change $ git status On branch work nothing to commit, working directory clean
Git object儲存方式
Git object是通過下面的方式處理並存儲在git內部的檔案系統中的:
- 首先建立一個header,header的值為 “物件型別 內容長度\0”
- 將header和檔案內容連線起來,計算得到其SHA-1 hash值
- 將連線得到的內容採用zlib壓縮
- 將壓縮後的內容寫入到以 “hash值前兩位命令的目錄/hash值後38位命令的檔案” 中
可以通過Ruby手工建立一個 Git object 來驗證上面的步驟。
$ irb irb(main):001:0> content = "what is up, doc?"//檔案內容 => "what is up, doc?" irb(main):002:0> header = "blob #{content.length}\0"//建立header => "blob 16\u0000" irb(main):003:0> store = header + content//拼接header和檔案內容 => "blob 16\u0000what is up, doc?" irb(main):004:0> require 'digest/sha1' => true irb(main):005:0> sha1 = Digest::SHA1.hexdigest(store) => "bd9dbf5aae1a3862dd1526723246b20206e5fc37"//計算得到hash值 irb(main):006:0>require 'zlib' => true irb(main):007:0> zlib_content = Zlib::Deflate.deflate(store)//壓縮header+檔案內容 => "x\x9CK\xCA\xC9OR04c(\xCFH,Q\xC8,V(-\xD0QH\xC9O\xB6\a\x00_\x1C\a\x9D" irb(main):008:0>path = '.git/objects/' + sha1[0,2] + '/' + sha1[2,38] => ".git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37"//通過hash值計算檔案儲存路徑 irb(main):009:0> require 'fileutils' => true irb(main):010:0>FileUtils.mkdir_p(File.dirname(path))//寫檔案 => [".git/objects/bd"] irb(main):011:0> File.open(path, 'w') { |f| f.write zlib_content } => 32 irb(main):012:0>
檔案以及寫入到Git的內部儲存中,我們嘗試通過git cat-file 驗證並讀取該檔案內容:
$ git cat-file -p bd9dbf5aae1a3862dd1526723246b20206e5fc37 what is up, doc?
可以看到,可以通過git cat-file檔案讀取該檔案內容,因此該檔案是一個合法的git object,和通過git 命令寫入的檔案格式相同。
總結
Git圍繞三種Object來實現了版本控制以及Branch,Tag等機制。
- Commit: Commit可以看作Git中一個Version的所有目錄和檔案的Snapshot,可以通過git checkout 檢視任意一個commit中的內容。
- Tree: 目錄物件,內部包含目錄和檔案
- Blob: 檔案物件,對應一個檔案
理解了Git object的儲存機制,就可以理解Git的各個命令的實現原理,更好地使用Git來實現原始碼管理。
參考
「真誠讚賞,手留餘香」