Git 原理入門

阮一峰發表於2018-10-10

Git 是最流行的版本管理工具,也是程式設計師的必備技能之一。

即使天天使用它,很多人也未必瞭解它的原理。Git 為什麼可以管理版本?git addgit commit這些基本命令,到底在做什麼,你說得清楚嗎?

這篇文章用一個例項,解釋 Git 的執行過程,幫助你理解 Git 的原理。

一、初始化

首先,讓我們建立一個專案目錄,並進入該目錄。


$ mkdir git-demo-project
$ cd git-demo-project

我們打算對該專案進行版本管理,第一件事就是使用git init命令,進行初始化。


$ git init

git init命令只做一件事,就是在專案根目錄下建立一個.git子目錄,用來儲存版本資訊。


$ ls .git

branches/
config
description
HEAD
hooks/
info/
objects/
refs/

上面命令顯示,.git內部還有一些子目錄,這裡先不解釋它們的含義。

二、儲存物件

接下來,新建一個空檔案test.txt


$ touch test.txt

然後,把這個檔案加入 Git 倉庫,也就是為test.txt的當前內容建立一個副本。


$ git hash-object -w test.txt

e69de29bb2d1d6434b8b29ae775ad8c2e48c5391

上面程式碼中,git hash-object命令把test.txt的當前內容壓縮成二進位制檔案,存入 Git。壓縮後的二進位制檔案,稱為一個 Git 物件,儲存在.git/objects目錄。

這個命令還會計算當前內容的 SHA1 雜湊值(長度40的字串),作為該物件的檔名。下面看一下這個新生成的 Git 物件檔案。


$ ls -R .git/objects

.git/objects/e6:
9de29bb2d1d6434b8b29ae775ad8c2e48c5391

上面程式碼可以看到,.git/objects下面多了一個子目錄,目錄名是雜湊值的前2個字元,該子目錄下面有一個檔案,檔名是雜湊值的後38個字元。

再看一下這個檔案的內容。


$ cat .git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391

上面程式碼輸出的檔案內容,都是一些二進位制字元。你可能會問,test.txt是一個空檔案,為什麼會有內容?這是因為二進位制物件裡面還儲存一些後設資料。

如果想看該檔案原始的文字內容,要用git cat-file命令。


$ git cat-file -p e69de29bb2d1d6434b8b29ae775ad8c2e48c5391

因為原始檔案是空檔案,所以上面的命令什麼也看不到。現在向test.txt寫入一些內容。


$ echo 'hello world' > test.txt

因為檔案內容已經改變,需要將它再次儲存成 Git 物件。


$ git hash-object -w test.txt

3b18e512dba79e4c8300dd08aeb37f8e728b8dad

上面程式碼可以看到,隨著內容改變,test.txt的雜湊值已經變了。同時,新檔案.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad也已經生成了。現在可以看到檔案內容了。


$ git cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad

hello world

三、暫存區

檔案儲存成二進位制物件以後,還需要通知 Git 哪些檔案發生了變動。所有變動的檔案,Git 都記錄在一個區域,叫做"暫存區"(英文叫做 index 或者 stage)。等到變動告一段落,再統一把暫存區裡面的檔案寫入正式的版本歷史。

git update-index命令用於在暫存區記錄一個發生變動的檔案。


$ git update-index --add --cacheinfo 100644 \
3b18e512dba79e4c8300dd08aeb37f8e728b8dad test.txt

上面命令向暫存區寫入檔名test.txt、二進位制物件名(雜湊值)和檔案許可權。

git ls-files命令可以顯示暫存區當前的內容。


$ git ls-files --stage

100644 3b18e512dba79e4c8300dd08aeb37f8e728b8dad 0   test.txt

上面程式碼表示,暫存區現在只有一個檔案test.txt,以及它的二進位制物件名和許可權。知道了二進位制物件名,就可以在.git/objects子目錄裡面讀出這個檔案的內容。

git status命令會產生更可讀的結果。


$ git status

要提交的變更:
    新檔案:   test.txt

上面程式碼表示,暫存區裡面只有一個新檔案test.txt,等待寫入歷史。

四、git add 命令

上面兩步(儲存物件和更新暫存區),如果每個檔案都做一遍,那是很麻煩的。Git 提供了git add命令簡化操作。


$ git add --all

上面命令相當於,對當前專案所有變動的檔案,執行前面的兩步操作。

五、commit 的概念

暫存區保留本次變動的檔案資訊,等到修改了差不多了,就要把這些資訊寫入歷史,這就相當於生成了當前專案的一個快照(snapshot)。

專案的歷史就是由不同時點的快照構成。Git 可以將專案恢復到任意一個快照。快照在 Git 裡面有一個專門名詞,叫做 commit,生成快照又稱為完成一次提交。

下文所有提到"快照"的地方,指的就是 commit。

六、完成提交

首先,設定一下使用者名稱和 Email,儲存快照的時候,會記錄是誰提交的。


$ git config user.name "使用者名稱" 
$ git config user.email "Email 地址"

接下來,要儲存當前的目錄結構。前面儲存物件的時候,只是儲存單個檔案,並沒有記錄檔案之間的目錄關係(哪個檔案在哪裡)。

git write-tree命令用來將當前的目錄結構,生成一個 Git 物件。


$ git write-tree

c3b8bb102afeca86037d5b5dd89ceeb0090eae9d

上面程式碼中,目錄結構也是作為二進位制物件儲存的,也儲存在.git/objects目錄裡面,物件名就是雜湊值。

讓我們看一下這個檔案的內容。


$ git cat-file -p c3b8bb102afeca86037d5b5dd89ceeb0090eae9d

100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad    test.txt

可以看到,當前的目錄裡面只有一個test.txt檔案。

所謂快照,就是儲存當前的目錄結構,以及每個檔案對應的二進位制物件。上一個操作,目錄結構已經儲存好了,現在需要將這個目錄結構與一些後設資料一起寫入版本歷史。

git commit-tree命令用於將目錄樹物件寫入版本歷史。


$ echo "first commit" | git commit-tree c3b8bb102afeca86037d5b5dd89ceeb0090eae9d

c9053865e9dff393fd2f7a92a18f9bd7f2caa7fa

上面程式碼中,提交的時候需要有提交說明,echo "first commit"就是給出提交說明。然後,git commit-tree命令將後設資料和目錄樹,一起生成一個 Git 物件。現在,看一下這個物件的內容。


$ git cat-file -p c9053865e9dff393fd2f7a92a18f9bd7f2caa7fa

tree c3b8bb102afeca86037d5b5dd89ceeb0090eae9d
author ruanyf  1538889134 +0800
committer ruanyf  1538889134 +0800

first commit

上面程式碼中,輸出結果的第一行是本次快照對應的目錄樹物件(tree),第二行和第三行是作者和提交人資訊,最後是提交說明。

git log命令也可以用來檢視某個快照資訊。


$ git log --stat c9053865e9dff393fd2f7a92a18f9bd7f2caa7fa

commit c9053865e9dff393fd2f7a92a18f9bd7f2caa7fa
Author: ruanyf 
Date:   Sun Oct 7 13:12:14 2018 +0800

    first commit

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

七、git commit 命令

Git 提供了git commit命令,簡化提交操作。儲存進暫存區以後,只要git commit一個命令,就同時提交目錄結構和說明,生成快照。


$ git commit -m "first commit"

此外,還有兩個命令也很有用。

git checkout命令用於切換到某個快照。


$ git checkout c9053865e9dff393fd2f7a92a18f9bd7f2caa7fa

git show命令用於展示某個快照的所有程式碼變動。


$ git show c9053865e9dff393fd2f7a92a18f9bd7f2caa7fa

八、branch 的概念

到了這一步,還沒完。如果這時用git log命令檢視整個版本歷史,你看不到新生成的快照。


$ git log

上面命令沒有任何輸出,這是為什麼呢?快照明明已經寫入歷史了。

原來git log命令只顯示當前分支的變動,雖然我們前面已經提交了快照,但是還沒有記錄這個快照屬於哪個分支。

所謂分支(branch)就是指向某個快照的指標,分支名就是指標名。雜湊值是無法記憶的,分支使得使用者可以為快照起別名。而且,分支會自動更新,如果當前分支有新的快照,指標就會自動指向它。比如,master 分支就是有一個叫做 master 指標,它指向的快照就是 master 分支的當前快照。

使用者可以對任意快照新建指標。比如,新建一個 fix-typo 分支,就是建立一個叫做 fix-typo 的指標,指向某個快照。所以,Git 新建分支特別容易,成本極低。

Git 有一個特殊指標HEAD, 總是指向當前分支的最近一次快照。另外,Git 還提供簡寫方式,HEAD^指向 HEAD的前一個快照(父節點),HEAD~6則是HEAD之前的第6個快照。

每一個分支指標都是一個文字檔案,儲存在.git/refs/heads/目錄,該檔案的內容就是它所指向的快照的二進位制物件名(雜湊值)。

九、更新分支

下面演示更新分支是怎麼回事。首先,修改一下test.txt


$ echo "hello world again" > test.txt

然後,儲存二進位制物件。


$ git hash-object -w test.txt

c90c5155ccd6661aed956510f5bd57828eec9ddb

接著,將這個物件寫入暫存區,並儲存目錄結構。


$ git update-index test.txt
$ git write-tree

1552fd52bc14497c11313aa91547255c95728f37

最後,提交目錄結構,生成一個快照。


$ echo "second commit" | git commit-tree 1552fd52bc14497c11313aa91547255c95728f37 -p c9053865e9dff393fd2f7a92a18f9bd7f2caa7fa

785f188674ef3c6ddc5b516307884e1d551f53ca

上面程式碼中,git commit-tree-p引數用來指定父節點,也就是本次快照所基於的快照。

現在,我們把本次快照的雜湊值,寫入.git/refs/heads/master檔案,這樣就使得master指標指向這個快照。


$ echo 785f188674ef3c6ddc5b516307884e1d551f53ca > .git/refs/heads/master

現在,git log就可以看到兩個快照了。


$ git log

commit 785f188674ef3c6ddc5b516307884e1d551f53ca (HEAD -> master)
Author: ruanyf 
Date:   Sun Oct 7 13:38:00 2018 +0800

    second commit

commit c9053865e9dff393fd2f7a92a18f9bd7f2caa7fa
Author: ruanyf 
Date:   Sun Oct 7 13:12:14 2018 +0800

    first commit

git log的執行過程是這樣的:

  1. 查詢HEAD指標對應的分支,本例是master
  2. 找到master指標指向的快照,本例是785f188674ef3c6ddc5b516307884e1d551f53ca
  3. 找到父節點(前一個快照)c9053865e9dff393fd2f7a92a18f9bd7f2caa7fa
  4. 以此類推,顯示當前分支的所有快照

最後,補充一點。前面說過,分支指標是動態的。原因在於,下面三個命令會自動改寫分支指標。

  • git commit:當前分支指標移向新建立的快照。
  • git pull:當前分支與遠端分支合併後,指標指向新建立的快照。
  • git reset [commit_sha]:當前分支指標重置為指定快照。

十、參考連結

(完)

相關文章