Git 是最流行的版本管理工具,也是程式設計師的必備技能之一。
即使天天使用它,很多人也未必瞭解它的原理。Git 為什麼可以管理版本?git add
、git 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
的執行過程是這樣的:
- 查詢
HEAD
指標對應的分支,本例是master
- 找到
master
指標指向的快照,本例是785f188674ef3c6ddc5b516307884e1d551f53ca
- 找到父節點(前一個快照)
c9053865e9dff393fd2f7a92a18f9bd7f2caa7fa
- 以此類推,顯示當前分支的所有快照
最後,補充一點。前面說過,分支指標是動態的。原因在於,下面三個命令會自動改寫分支指標。
git commit
:當前分支指標移向新建立的快照。git pull
:當前分支與遠端分支合併後,指標指向新建立的快照。git reset [commit_sha]
:當前分支指標重置為指定快照。
十、參考連結
- How does git work internally, Shalitha Suranga
(完)