1. 程式人生 > >Git 內部原理--初探 .git

Git 內部原理--初探 .git

說到Git大家應該都非常熟悉,幾乎每天都會用到它。在日常使用過程中,我們貌似並不需要關注其內部的原理,只需要記住那幾個常用的命令,就可以說自己是會Git的人了。可是,事實真的是這樣子的嗎?今天我們就來聊聊那些不太被關注到的內部原理。

引言

首先我們要明白的一點就是,Git是一個內容定址(content-addressable)檔案系統,並在此之上提供了一個版本控制系統的使用者介面。

還有一點需要明白的就是,我們日常使用的命令(checkoutcommit)對使用者更加友好,因此被稱為高層(porcelain)命令;還有一些早期設計的命令(hash-objectwrite-tree)被設計成能以UNIX命令列的風格連線在一起,抑或藉由指令碼呼叫來實現功能,這些命令被稱為底層(plumbing)命令

。這裡,我們便通過底層命令來實現幾個常見的高層命令,以達到初步瞭解Git內部原理的效果。

Git 倉庫的初始化

在我們學習Git的時候,我們最開始接觸到的一定是Git倉庫的配置。在這裡,我們也通過Git的初始化,來看看Git的初始化的時候都幹了些啥。

frends-MacBook:GitTest frendguo$ git init
Initialized empty Git repository in /Users/frendguo/SourceCode/Demo/GitTest/.git/
frends-MacBook:GitTest frendguo$ ls -al
total 0
drwxr-xr-x  3 frendguo  staff   96 Jan 13 22:51 .
drwxr-xr-x  4 frendguo  staff  128 Jan 13 22:51 ..
drwxr-xr-x  9 frendguo  staff  288 Jan 13 22:51 .git

當我們輸入git init後,Git會幫我們生成一個.git的資料夾。它的結構是這樣子的:

.git/
├── 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
├── info
│   └── exclude
├── objects
│   ├── info
│   └── pack
└── refs
|   ├── heads
|   └── tags

對於一個全新的git init版本庫,這將是你看到的預設結構。description 檔案僅供 GitWeb 程式使用,我們無需關心。config 檔案包含專案特有的配置選項。info目錄包含一個全域性性排除(global exclude)檔案,用以放置那些不希望被記錄在 .gitignore檔案中的忽略模式(ignored patterns)。hooks目錄包含客戶端或服務端的鉤子指令碼(hook scripts)。

HEAD檔案、(尚待建立的)index 檔案,和 objects 目錄refs 目錄。這些條目是 Git 的核心組成部分。objects 目錄儲存所有資料內容;refs 目錄儲存指向資料(分支)的提交物件的指標;HEAD檔案指示目前被檢出的分支;index檔案儲存暫存區資訊。

既然初始化倉庫只是在本地建立幾個檔案/資料夾,那我們是否可以通過建立檔案/資料夾來初始化Git倉庫呢?答案是當然可以!

frends-MacBook:GitTest frendguo$ ls -al
total 0
drwxr-xr-x  2 frendguo  staff   64 Jan 13 23:20 .
drwxr-xr-x  4 frendguo  staff  128 Jan 13 22:51 ..
frends-MacBook:GitTest frendguo$ git status
fatal: not a git repository (or any of the parent directories): .git

當前GitTest資料夾並不是一個Git倉庫,在使用git status查詢Git倉庫狀態時,bash會提醒這不是一個Git倉庫。

於是我們在這個資料夾下建立寫檔案/資料夾:

frends-MacBook:GitTest frendguo$ mkdir -p .git/objects .git/refs/heads/ .git/refs/tags 
frends-MacBook:GitTest frendguo$ echo "ref:refs/heads/master" >> .git/HEAD
frends-MacBook:GitTest frendguo$ git status
On branch master

No commits yet

nothing to commit (create/copy files and use "git add" to track)

只是簡單的建立幾個檔案/資料夾,就可以非常簡單的建立一個Git倉庫。所以,如果想要在本地備份一個版本庫,就可以直接拷走.git 資料夾。

git add/commit 背後的那些事

建立好Git倉庫後,我們便要開始向倉庫提交一些東西了。待我們在工作區的內容編輯完成後,我們就需要將內容提交到暫存區了,對於平常的使用中,我們直接git add . 就可以將當前資料夾下所有的檔案/資料夾全都提交到暫存區。那這條命令背後到底幹了些啥呢?接下來我們便來探索下其背後的故事。

Git 物件

前面提到過Git是一個內容定址系統。也就是說Git的核心其實就是一個簡單的鍵值對資料庫,對於新增到Git倉庫任意的物件,Git都有一個唯一的鍵來與之對應。這裡可以通過底層命令hash-object來演示效果。

frends-MacBook:GitTest frendguo$ echo "hello git" | git hash-object -w --stdin
8d0e41234f24b6da002d962a26c2495ea16a425f

其中引數-w表示將物件寫到Git的資料庫當中。--stdin表示接受標準輸入,這裡接收到的就是hello git。對於返回值8d0e41234f24b6da002d962a26c2495ea16a425f就是前文提到的鍵。

那麼這個鍵是怎麼計算得來的呢?—hash。它通過將頭部資訊(其中包含物件型別和物件大小)和原始資訊拼接起來,然後計算HASH值,得到一個40位的序列。並將前兩位作為資料夾,後面38位作為檔名將檔案資訊儲存到Git倉庫中。

上文中的hello git儲存到Git中如下所示:

frends-MacBook:GitTest frendguo$ find .git/objects
.git/objects
.git/objects/8d
.git/objects/8d/0e41234f24b6da002d962a26c2495ea16a425f

這裡再介紹一個底層命令cat file。我們可以通過這個命令從Git中取資料,比如我們要看下8d0e41234f24b6da002d962a26c2495ea16a425f的檔案型別,我們可以這麼做:

frends-MacBook:GitTest frendguo$ git cat-file -t 8d0e41234f24b6da002d962a26c2495ea16a425f
blob

-t表示檢視其型別。blob是Git中物件的一種型別,表示資料物件(BinaryLargeOBject)。與之對應的還有tree(樹物件,下文會提到)、commit(提交物件,見下文)。

-p表示需要檢視該鍵對應的內容。如(其他用法可以通過man git-cat-file來檢視):

frends-MacBook:GitTest frendguo$ git cat-file -p 8d0e41234f24b6da002d962a26c2495ea16a425f
hello git

接下來,我們向資料庫中新增本地已存在的檔案:

frends-MacBook:GitTest frendguo$ echo "version 1" >> test.md
frends-MacBook:GitTest frendguo$ ls 
test.md
frends-MacBook:GitTest frendguo$ git hash-object -w test.md 
83baae61804e65cc73a7201a7252750c76066a30

修改test.md檔案並再次儲存到Git中:

frends-MacBook:GitTest frendguo$ echo "version 2" > test.md 
frends-MacBook:GitTest frendguo$ git hash-object -w test.md 
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a

檢視objects/中的檔案:

frends-MacBook:GitTest frendguo$ find .git/objects/ -type f
.git/objects//1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
.git/objects//83/baae61804e65cc73a7201a7252750c76066a30
.git/objects//8d/0e41234f24b6da002d962a26c2495ea16a425f

可以看到包括之前的hello git,總共有三個檔案儲存到了Git倉庫中。我們可以通過非常簡單的方法將其恢復。

frends-MacBook:GitTest frendguo$ git cat-file -p 8d0e41234f24b6da002d962a26c2495ea16a425f > test.md 
frends-MacBook:GitTest frendguo$ cat test.md 
hello git

然而在這個簡單的檔案系統中,我們並沒有儲存檔名。那麼Git是怎麼儲存檔名的呢?--答案就是樹物件

樹物件

接下來我們便來討論討論樹物件。它不僅能儲存檔名,還能將多個檔案組織到一起。我們先來看看樹物件怎麼來建立吧~

通常,Git會根據某一時刻暫存區(存放在.git/index中)所表示的狀態來建立並記錄一個對應的樹物件,如此重複便能建立一系列的樹物件。因而,在建立樹物件之前,我們需要先將檔案加入到暫存區。這裡需要用到另一個底層命令update-index,這個命令就是將檔案加到暫存區中。

frends-MacBook:GitTest frendguo$ git ls-files --stag
frends-MacBook:GitTest frendguo$ git update-index --add --cacheinfo 10644 8d0e41234f24b6da002d962a26c2495ea16a425f hello
frends-MacBook:GitTest frendguo$ git ls-files --stag
100644 8d0e41234f24b6da002d962a26c2495ea16a425f 0   hello

其中,git ls-files --stag是用來顯示暫存區中的檔案資訊的。--add是因為hello檔案不在暫存區中。--cacheinfo是因為該內容是在Git倉庫中,不在當前資料夾下。10644在UNIX系統中用來表示該檔案模式為普通檔案。

這個時候,我們用git status看看Git倉庫的狀態:

frends-MacBook:GitTest frendguo$ git status
On branch master

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

    new file:   hello

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:    hello

可以看到hello檔案已經加到暫存區中了。比較奇怪的是下面那個deleted: hello。這個更改是在為加到暫存區的更改,因為本地路徑中不存在該檔案,而暫存區中存在該檔案,所以Git認為是人為的將該檔案刪除了。我們可以通過檢出(checkout)來將檔案恢復到本地。

frends-MacBook:GitTest frendguo$ git checkout hello
frends-MacBook:GitTest frendguo$ ls -l
total 8
-rw-r--r--  1 frendguo  staff  10 Jan 14 00:41 hello

這個時候的狀態就像我們使用了git add hello一樣。

frends-MacBook:GitTest frendguo$ git status
On branch master

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

    new file:   hello

這裡也可以聯想到我們在將工作區的內容恢復到暫存區的內容的時候,我們也會使用檢出(checkout)命令。(這個命令的內部實現,我們以後再探討。)

嘿,別走了,我們這就建立樹物件。這裡需要用到write-tree這個底層命令,它的作用就是將暫存區的內容寫成一個樹物件。

frends-MacBook:GitTest frendguo$ git ls-files --stag
100644 8d0e41234f24b6da002d962a26c2495ea16a425f 0   hello
frends-MacBook:GitTest frendguo$ git write-tree
66eea8c80abea0e9836aab458e48ab9a379186e5
frends-MacBook:GitTest frendguo$ git cat-file -t 66eea8c80abea0e9836aab458e48ab9a379186e5
tree
frends-MacBook:GitTest frendguo$ git cat-file -p 66eea8c80abea0e9836aab458e48ab9a379186e5
100644 blob 8d0e41234f24b6da002d962a26c2495ea16a425f    hello

可以看到我們的樹物件建立成功了,裡面的內容就是暫存區的內容--一個hello檔案。接下來我們建立一個新的樹物件。

frends-MacBook:GitTest frendguo$ echo "version 1" > test.md
frends-MacBook:GitTest frendguo$ git update-index --add test.md 
frends-MacBook:GitTest frendguo$ git ls-files --stag
100644 8d0e41234f24b6da002d962a26c2495ea16a425f 0   hello
100644 83baae61804e65cc73a7201a7252750c76066a30 0   test.md
frends-MacBook:GitTest frendguo$ git write-tree
3071bf0ff03f10445bb9c43e194ae990944006f4
frends-MacBook:GitTest frendguo$ git cat-file -p 3071bf0ff03f10445bb9c43e194ae990944006f4
100644 blob 8d0e41234f24b6da002d962a26c2495ea16a425f    hello
100644 blob 83baae61804e65cc73a7201a7252750c76066a30    test.md

這裡的樹物件包含兩個檔案hellotest.md。現在我們將原來那個樹物件新增到暫存區,可以通過read-tree將樹物件的內容讀取到暫存區。

frends-MacBook:GitTest frendguo$ git read-tree --prefix=FirstTree \ 66eea8c80abea0e9836aab458e48ab9a379186e5
frends-MacBook:GitTest frendguo$ git ls-files
FirstTree/hello
hello
test.md
frends-MacBook:GitTest frendguo$ git write-tree
ac3c7527fcc566453b513c8706d9f36ba138980a
frends-MacBook:GitTest frendguo$ git cat-file -p ac3c7527fcc566453b513c8706d9f36ba138980a
040000 tree 66eea8c80abea0e9836aab458e48ab9a379186e5    FirstTree
100644 blob 8d0e41234f24b6da002d962a26c2495ea16a425f    hello
100644 blob 83baae61804e65cc73a7201a7252750c76066a30    test.md

可以看到新的樹物件包含了最開始我們建立的那個樹物件和兩個檔案,如果我們把他們檢出,是不是就能在工作目錄看到根目錄下有一個子目錄和兩個檔案了。

frends-MacBook:GitTest frendguo$ ls
hello   test.md
frends-MacBook:GitTest frendguo$ rm -rf hello test.md 
frends-MacBook:GitTest frendguo$ ls -l
frends-MacBook:GitTest frendguo$ git checkout .
frends-MacBook:GitTest frendguo$ ls -l
total 16
drwxr-xr-x  3 frendguo  staff  96 Jan 14 01:05 FirstTree
-rw-r--r--  1 frendguo  staff  10 Jan 14 01:05 hello
-rw-r--r--  1 frendguo  staff  10 Jan 14 01:05 test.md

果然是這樣子的。是不是想起了我們使用的檔案系統也是這種樹形的結構呢。

提交物件

現在我們有三個樹物件,分別表示了專案開發週期中不同時期的快照。如果我們想要重用這些快照,那麼就必須記住三個樹物件的SHA-1值。而且,我們並沒有足夠的資訊來表明是誰,什麼時候建立的快照以及為什麼會有這個快照。因此,大佬們就想出來了一個提交物件用來儲存這些資訊。

我們先用commit-tree命令來建立一個提交物件,這個過程中,我們必須要指定樹物件的SHA-1值,以及提交的父物件(如果存在的話)。

frends-MacBook:GitTest frendguo$ echo "first commit" | git commit-tree \ 66eea8c80abea0e9836aab458e48ab9a379186e5
1417b00016aa48b2e24518c5e1f8737b66b6a993
frends-MacBook:GitTest frendguo$ git cat-file -t 1417b00016aa48b2e24518c5e1f8737b66b6a993
commit

再來看看這個提交物件裡面有些什麼內容:

frends-MacBook:GitTest frendguo$ git cat-file -p 1417b00016aa48b2e24518c5e1f8737b66b6a993
tree 66eea8c80abea0e9836aab458e48ab9a379186e5
author frendguo <[email protected]> 1547399734 +0800
committer frendguo <[email protected]> 1547399734 +0800

first commit

通過檢視提交物件的內容,我們可以知道,提交物件的格式:它先指定一個頂層的樹物件,代表當前專案的快照;然後是作者/提交者的資訊(根據user.nameuser.email來設定);留空一行,就是提交的註釋了(表示為什麼會有這個快照)。(這裡需要提到的是提交者和作者的資訊,它們大多數情況下都是一樣的,但在一些情況下(比如:cherry-pick),為了保證版權,就需要保留作者的資訊。)

接下來我們再根據其他兩個樹物件建立兩個提交。

frends-MacBook:GitTest frendguo$ echo "second commit" | git commit-tree 3071bf0ff03f10445bb9c43e194ae990944006f4 -p 1417b00016aa48b2e24518c5e1f8737b66b6a993
088bb9b3d9aa4c48e28a5c27672bb75e110fba64
frends-MacBook:GitTest frendguo$ echo "third commit" | git commit-tree ac3c7527fcc566453b513c8706d9f36ba138980a -p 088bb9b3d9aa4c48e28a5c27672bb75e110fba64
8a94cd783dfb4fdf45e836f7a0ae292f49593606

這個時候我們就可以用git log --stat來檢視Git的提交歷史了。這裡的--stat是表示檢視與當前HEAD不一致的路徑。

frends-MacBook:GitTest frendguo$ git log --stat 8a94cd783dfb4fdf45e836f7a0ae292f49593606
commit 8a94cd783dfb4fdf45e836f7a0ae292f49593606
Author: frendguo <[email protected]>
Date:   Mon Jan 14 01:25:16 2019 +0800

    third commit

 FirstTree/hello | 1 +
 1 file changed, 1 insertion(+)

commit 088bb9b3d9aa4c48e28a5c27672bb75e110fba64
Author: frendguo <[email protected]>
Date:   Mon Jan 14 01:24:07 2019 +0800

    second commit

 test.md | 1 +
 1 file changed, 1 insertion(+)

commit 1417b00016aa48b2e24518c5e1f8737b66b6a993
Author: frendguo <[email protected]>
Date:   Mon Jan 14 01:15:34 2019 +0800

    first commit

 hello | 1 +
 1 file changed, 1 insertion(+)

截止到這裡,我們算是用底層命令基本完成了git addgit commit的功能。

Git 引用

之所以說基本完成了,那是因為我們並不能像高層命令那樣提交後,直接使用git log就能檢視提交日誌。

這裡不得不提到的就是HEAD。就像前文說到的那樣,HEAD表示現在被檢出的分支。

frends-MacBook:GitTest frendguo$ cat .git/HEAD 
ref:refs/heads/master
frends-MacBook:GitTest frendguo$ cat .git/refs/heads/master
cat: .git/refs/heads/master: No such file or directory

嘗試將最新的提交物件的SHA-1值給refs/heads/master?

frends-MacBook:GitTest frendguo$ echo "8a94cd783dfb4fdf45e836f7a0ae292f49593606" > .git/refs/heads/master
frends-MacBook:GitTest frendguo$ git log
commit 8a94cd783dfb4fdf45e836f7a0ae292f49593606 (HEAD -> master)
Author: frendguo <[email protected]>
Date:   Mon Jan 14 01:25:16 2019 +0800

    third commit

commit 088bb9b3d9aa4c48e28a5c27672bb75e110fba64
Author: frendguo <[email protected]>
Date:   Mon Jan 14 01:24:07 2019 +0800

    second commit

commit 1417b00016aa48b2e24518c5e1f8737b66b6a993
Author: frendguo <[email protected]>
Date:   Mon Jan 14 01:15:34 2019 +0800

    first commit

於是我們把最後一步走完了,現在就跟高層命令一致了。

這裡強烈不建議直接修改引用檔案。如果想要修改,請使用update-ref命令來完成修改。

frends-MacBook:GitTest frendguo$ git update-ref refs/heads/master 8a94cd783dfb4fdf45e836f7a0ae292f49593606

通過修改master分支,可以看到分支的本質其實就是一個引用(或者說一個指標,一個提交物件的鍵值)。所以當我們在使用git branch命令的時候,其內部其實就是通過git update-ref來實現的。而標籤引用和分支有點異曲同工之妙,本文就不再贅述了。有興趣的可以自己探索。


總結

到這裡,初探.git就結束了。本文中通過使用底層命令來實現幾個簡單的高層命令的功能來探索Git的內部原理。

參考

  1. Pro Git