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
(完)