15分鐘成為 GIT 專家
通過一步一步的實踐來探索 git 內(nèi)部。
Git 可能看起來像一個復(fù)雜的系統(tǒng)。如果上 Googl e搜索。Google 會自動彈出一些最常搜索的標(biāo)題:
為什么 Git 這么難。。。
Git 就是太難了。。。
我們能夠停止假裝 Git 很簡單、很容易學(xué)習(xí)嗎。。。
為什么 Git 如此復(fù)雜。。。
乍一看,這些問題好像都是真的,但是你一旦理解了內(nèi)部的概念,使用 Git 工作會變成一件愉悅的體驗。Git 的問題是它非常靈活。所有靈活的系統(tǒng)的特點就是復(fù)雜。我強烈的認(rèn)為解決其復(fù)雜性的唯一辦法就是深入它提供的用戶接口下面,理解內(nèi)部的模型和架構(gòu)。一旦你這么做了,就不會有什么魔力和非預(yù)期的結(jié)果。使用起這些復(fù)雜的工具得心應(yīng)手。
不管是以前使用過 Git 還是剛開始使用這個神奇的版本控制工具的開發(fā)者,閱讀了本文以后都會收獲頗豐。如果你是應(yīng)一名有經(jīng)驗的 GIT 使用者,你會更好的理解 checkout -> modify -> commit 這個過程。如果你剛開始使用 Git,本文將給你一個很好的開端。
在本文中我將使用一些底層的命令來展示 Git 內(nèi)部是怎么工作的。你不需要記住這些命令,因為在常規(guī)的工作流中幾乎不會使用這些命令,但是這些命令在解釋 Git 內(nèi)部架構(gòu)時不可或缺。
本文比較長,我相信你會按照以下兩種方式閱讀:
- 快速從頂部滑底部,看一下本文的目錄標(biāo)題
- 跟著本文的練習(xí)完整閱讀本文
通過練習(xí)你可以增強在這里獲得的信息。
Git 是一個文件夾
當(dāng)你在一個文件夾中執(zhí)行 git init 命令時,Git 會創(chuàng)建 .git 目錄。所以我們打開一個終端,創(chuàng)建一個新的目錄并在這里初始化一個空的 git 倉庫:
$ mkdir git-playground && cd git-playground
$ git init
Initialized empty Git repository in path/to/git-playground/.git/
$ ls .git
HEAD config description hooks info objects refs
這是 Git 存儲所有 commit 和其他用于操作這些 commit 相關(guān)信息的地方。當(dāng)你克隆一個倉庫的時候就是復(fù)制這個目錄到你的文件夾,為倉庫里的每一個分支創(chuàng)建一個遠(yuǎn)程跟蹤分支,并根據(jù) HEAD 文件檢出一個初始的分支。我們將在稍后討論在 Git 架構(gòu)中 HEAD 文件的用途,但是這里需要記住的就是克隆一個倉庫本質(zhì)上就是僅僅從別的地方復(fù)制一份 .git 目錄。
Git 是一個數(shù)據(jù)庫
Git 是一個簡單的 key-value 數(shù)據(jù)倉庫。你可以將數(shù)據(jù)存儲到倉庫中并獲得一個鍵值,通過這個鍵值你可以訪問存儲的數(shù)據(jù)。將數(shù)據(jù)存儲到數(shù)據(jù)庫的命令是 hash-object,這個命令會返回一個40個字符的哈希校驗和,這個校驗和會被用作鍵值。這個命令會在 git 倉庫中創(chuàng)建一個稱為 blob 的對象。我們向數(shù)據(jù)庫中寫入一個簡單的字符串 f1 content :
$ F1CONTENT_BLOB_HASH=$( \
echo 'f1 content' | git hash-object -w --stdin )
$ echo $F1CONTENT_BLOB_HASH
a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0
如果你對 shell 不熟悉,上面這一段代碼的主要命令是:
echo 'f1 content' | git hash-object -w --stdin
echo 命令輸出 f1 content 字符串,通過管道操作符 | 我們將輸出重定位到 git hash-object 命令。hash-object 的參數(shù) -w 表示要存儲這個對象;否則這個命令只是簡單的告訴你鍵值是什么。 --stdin 告訴命令從 stdin 讀取內(nèi)容;如果不指定這一點, hash-object 希望最后輸入一個文件路徑。前面已經(jīng)說到 git hash-object 命令會返回一個哈希值,我將這個值存儲到 F1CONTENT_BLOB_HASH變量中。我們也可以將主命令和變量賦值像這樣分開:
$ echo 'f1 content' | git hash-object -w --stdin
a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0
$ F1CONTENT_BLOB_HASH=a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0
但是為了方便,我將在后面的代碼中使用簡短的版本為變量賦值。這些變量會在需要哈希字符串的地方使用,它和 $ 符號拼接起來作為一個變量讀取存儲的數(shù)據(jù)。
通過鍵值讀取數(shù)據(jù)可以使用 帶有 -p 選項的 cat-file 命令。這個命令需要接收帶讀取數(shù)據(jù)的哈希值:
如我前面所說, .git 是一個文件夾,并且所有存儲的值/對象都放在這個文件夾中。所以我們可以瀏覽一下 .git/objects 文件夾,你會看到 Git 創(chuàng)建了一個名稱為 a1 的文件夾,這是哈希值的前兩個字母:
$ ls .git/objects/ -l
**a1/**
info/
pack/
這就是 Git 存儲對象的方式--每個 blob 一個文件夾。然而,Git 也可以將多個 blob 合并成一個文件生成一個 pack 文件,這些 pack 文件就存儲在你前面看到的 pack 目錄。Git 將這些 pack 對象相關(guān)的信息都存儲到 info 目錄。Git 基于 blob 的內(nèi)容為每一個 blob 生成哈希值,所以存儲在 Git 中的對象是不可修改的,因為修改內(nèi)容就會改變哈希值。
我們往倉庫中寫入另外一個字符串 f2 content:
$ F2CONTENT_BLOB_HASH=$( \
**echo 'f2 content' | git hash-object -w --stdin )**
如你所預(yù)期的那樣,你會看到 .git/objects/ 目錄下現(xiàn)在有兩條記錄 9b/ 和 a1/ :
$ ls .git/objects/ -l
**9b/**
**a1/ **
info/
pack/
樹(Tree)是一個內(nèi)部組件
現(xiàn)在我們的倉庫中有兩個blob:
F1CONTENT_BLOB_HASH -> ‘f1 content’
F2CONTENT_BLOB_HASH -> ‘f2 content'
我們需要一種方式來將他們組織到一起,并且將每一個 blob 和一個文件名關(guān)聯(lián)起來。這就是 tree 的作用。我們可以按照下面的語法通過 git mktree 為從而每一個 blob/文件 關(guān)聯(lián)創(chuàng)建一個樹:
[file-mode object-type object-hash file-name]
關(guān)于文件的 file mode 可以參考這個答案提供的解釋。我們將使用 100644 模式,這一模式下 blob 就是一個常規(guī)文件每一個用戶都可以讀寫。當(dāng)檢出文件到工作目錄時,Git 會根據(jù) tree 實體將相應(yīng)的文件/目錄設(shè)置成這個模式。
所以,這樣就可以將兩個 blob 和兩個文件建立關(guān)聯(lián):
$ INITIAL_TREE_HASH=$( \
printf '%s %s %s\t%s\n' \
100644 blob $F1CONTENT_BLOB_HASH f1.txt \
100644 blob $F2CONTENT_BLOB_HASH f2.txt |
git mktree )
和 hash-object 一樣,mktree 命令也會返回創(chuàng)建好的樹對象的哈希值:
$ echo $INITIAL_TREE_HASH
e05d9daa03229f7a7f6456d3d091d0e685e6a9db
所以,現(xiàn)在我們的倉庫中有這樣一個樹:
運行這個命令之后,git 在倉庫中創(chuàng)建了第三個 tree 類型的對象。我們一起來看看:
$ ls .git/objects -l
e0 <--- initial tree object (INITIAL_TREE_HASH)
9b <--- 'f1 content' blob (F2CONTENT_BLOB_HASH)
a1 <--- 'f2 content' blob (F2CONTENT_BLOB_HASH)
當(dāng)使用 mktree 命令的時候,我們也可以指定另外一個樹對象(而不是一個 blob)作為參數(shù)。新創(chuàng)建的樹會和目錄而不是一個常規(guī)文件關(guān)聯(lián)。例如,下面的命令會根據(jù)一個 subtree 創(chuàng)建一個和 nested-folder 目錄關(guān)聯(lián)的樹:
printf ‘%s %s %s\t%s\n’ 040000 tree e05d9da nested-folder | git mktree
文件模式 040000 表明是一個目錄,并且我們使用的類型 tree 而不是 blob。這就是 git 在項目結(jié)構(gòu)中存儲嵌套目錄的方式。
Index 是安裝樹的地方
每一個使用 GIT 工作的人都應(yīng)該很熟悉 index 或者 staging 區(qū)這兩個概念,并且可能看到過這張圖片:
在右側(cè)你可以看到 git repository,它用于存儲 git 對象:blobs,trees,commits 和 tags。我們已經(jīng)使用 hash-object 和 mktee 命令直接向倉庫中添加了兩個 blob 和一個樹對象到倉庫中。左側(cè)的工作目錄是你本地的文件系統(tǒng)(目錄),也就是你檢出所有項目文件的地方。中間這個區(qū)域我們稱為 index 文件或者簡稱 index。它是一個二進(jìn)制文件(通常存儲在 .git/index),類似于樹對象的結(jié)構(gòu)。它持有一個排序好的文件路徑列表,每一個文件路徑都有權(quán)限以及 blob/tree 對象的 SHA1 值。
在這個地方,git 在作如下操作之前準(zhǔn)備一個樹:
- 將一個樹寫入倉庫,或者
- 將一個樹檢出到工作目錄
現(xiàn)在我們的倉庫中已經(jīng)有一個在上一章節(jié)創(chuàng)建的樹。我們現(xiàn)在可以使用 read-tree 命令將這個樹從倉庫中讀取到 index 文件:
$ git read-tree $INITIAL_TREE_HASH
所以現(xiàn)在我們期望 index 文件中有兩個文件。我們可以使用 git ls-files -s 命令來檢查當(dāng)前 index 文件的結(jié)構(gòu):
$ git ls-files -s
100644 a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0 0 f1.txt
100644 9b96e21cb748285ebec53daec4afb2bdcb9a360a 0 f2.txt
由于我們還沒有對 index 文件做任何修改,它和我們用于生成index文件的樹完全一致。一旦我們在 index 文件中有了正確的結(jié)構(gòu),我們就可以通過帶有 -a 選項的 checkout-index 命令將它檢出到工作目錄:
$ git checkout-index -a
$ ls
f1.txt f2.txt
$ cat f1.txt
f1 content
$ cat f2.txt
f2 content
對的!我們已經(jīng)將沒使用任何 commit 就添加到 git 倉庫中的內(nèi)容檢出了。是不是很酷?
但是 index 文件并非總是停留在初始樹的狀態(tài)。你可能知道它可以通過這些命令改變,git add [file path] 和 git rm --cached [file path] 處理單個文件,git add . 和 git reset 處理一批已修改/已刪除的文件。我們將這個知識用于實踐,在倉庫中創(chuàng)建一個新的樹,這個樹包含一個和文本文件 f3.txt 關(guān)聯(lián)的 blob 文件。文件的內(nèi)容就是字符串 f3 content。但是和前一節(jié)手動創(chuàng)建樹不一樣,我們將使用index文件來創(chuàng)建。
現(xiàn)在我們的 index 文件結(jié)構(gòu)如下,
這就是我們應(yīng)用修改的基準(zhǔn)。你對 index 文件所做的所有修改在將樹寫入倉庫之前都是暫時的。然而你添加的對象是立刻寫入到倉庫的。如果你放棄當(dāng)前對樹的修改,這些對象稍后會被垃圾回收搜集并刪除。 這意味著如果你不小心丟棄了對某一個文件的修改,在 git 運行 GC 之前是可以恢復(fù)的。垃圾回收通常發(fā)生在有太多的未引用對象時才發(fā)生。
我們來刪除工作目錄中的兩個文件:
$ rm f1.txt f2.txt
如果我們運行git status 我們會看到以下信息:
$ git status
On branch master
Initial commit
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: f1.txt
new file: f2.txt
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: f1.txt
deleted: f2.txt
信息有點多。有兩個文件被刪除、兩個新文件同時還是 “Initial commit”。我們來看看為什么。當(dāng)你運行 git status 時,git做了兩個比較:
- 將 index 文件和當(dāng)前的工作目錄比較 --變化是 “not staged for commit”
- 將 index 文件和 HEAD 提交比較 --變化是 “to be committed”
所以在這里我們看到 git 將兩個已刪除的文件報告為 “Changes not staged for commit”,我們已經(jīng)知道這個信息是怎產(chǎn)生的--它將當(dāng)前的工作目錄和 index 文件比較發(fā)現(xiàn)工作目錄丟失兩個文件(因為我們剛才刪除了)。
我們同時還看在 “Changes to be committed” 下面 git 報告了了兩個新文件。這是因為到目前為止我們的倉庫中還沒有任何提交,所以這個 HEAD 文件(我們稍后做詳細(xì)的解釋)指向一個所謂的“空樹”對象(沒有任何文件)。所以 Git 以為我們剛剛創(chuàng)建了一個新的倉庫,所以為什么它顯示 “Initial commit”,并將 index 文件中的所有文件都當(dāng)做新文件。
現(xiàn)在如果我們執(zhí)行 git add . 它將修改 index 文件(刪除了兩個文件),然后再次執(zhí)行 git status 就會顯示沒有任何修改,因為現(xiàn)在我們的工作目錄和 index 文件中都沒有文件:
$ git add .
$ git status
On branch master
Initial commit
nothing to commit (create/copy files and use "git add" to track)
我們繼續(xù)通過創(chuàng)建新文件 f3.txt 來創(chuàng)建一個新的樹。
$ echo ‘f3 content’ > f3.txt
$ git add f3.txt
如果現(xiàn)在運行 git status:
$ git status
On branch master
Initial commit
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: f3.txt
我們發(fā)現(xiàn)檢查到了一個新文件。同樣,這個修改是報告在 "Changes to be committed" 下,所以現(xiàn)在 Git 是將 index 文件和 “空樹” 作比較。所以認(rèn)為 index 文件中已經(jīng)有了新的文件 blob。我們來確認(rèn)一下:
$ git ls-files -s
100644 5927d85c2470d49403f56ce27afd8f74b1a42589 0 f3.txt
# Save the hash of the f3.txt file blob
$ F3CONTENT_BLOB_HASH=5927d85c2470d49403f56ce27afd8f74b1a42589
好了,index 的結(jié)構(gòu)是正確的,我們現(xiàn)在可以通過這個 index 在倉庫中創(chuàng)建一個樹。我們通過 write-tree 命令來完成:
$ LATEST_TREE_HASH=$( git write-tree )
很棒。我們剛才通過 index 創(chuàng)建了一個樹。并且將新的樹的哈希值存到了 LATEST_TREE_HASH 變量。我們已經(jīng)通過手動將 f3 content blob 寫入到倉庫并且通過 mktree 來創(chuàng)建了一個樹,但是使用 index 文件更方便。
有趣的是如果你現(xiàn)在運行 git status 你會發(fā)現(xiàn)git 仍然認(rèn)為存在一個新文件 f3.txt:
$ git status
On branch master
Initial commit
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: f3.txt
那是因為盡管我們已經(jīng)創(chuàng)建了一個樹并將它存入了倉庫,但是我們還沒有更新用于比較的 HEAD 文件。
所以加上我們新創(chuàng)建的樹,倉庫中有以下對象:
Commit就是對樹的一次封裝
在這一節(jié)中將變得更有趣。在我們?nèi)粘5?Git 使用中,我們基本不會使用樹或者 blob。我們和 commit 對象交互。所以 git 中的 commit 是什么?實際上,簡單說它就是對樹對象的封裝:
- 允許給一個樹(一組文件)添加消息
- 允許指定父 commit
現(xiàn)在我們的倉庫中有兩個樹--initial tree 和 latest tree。我們通過 commit-tree 命令將第一個樹封裝成一個 commit(將樹的哈希值傳遞給它):
INITIAL_COMMIT_HASH=$( \
echo 'initial commit' | git commit-tree $INITIAL_TREE_HASH )
在運行上面的命令之后:
現(xiàn)在我么可以將這個commit檢出到工作目錄:
$ git checkout $INITIAL_COMMIT_HASH
A f3.txt
HEAD is now at a27a75a... initial commit
我們現(xiàn)在可以看到 f1.txt f2.txt 處于工作目錄中:
$ ls
f1.txt f2.txt
$ cat f1.txt
f1 content
$ cat f2.txt
f2 content
當(dāng)你運行 git checkout [commit-hash] 時,git 做了如下動作:
- 將 commit 點的樹讀入到 index 文件
- 將 index 文件檢出到工作目錄
- 使用 commit 的哈希值更新 HEAD 文件
這些都是我們在上一節(jié)手動執(zhí)行的操作。
Git歷史就是一串commit
所以現(xiàn)在我們知道了一個 commit 就是對一個樹的封裝。我也講到一個 commit 可以有一個父 commit。我們最初有兩個樹并在上一節(jié)將其中一個封裝成了一個commit,所以現(xiàn)在我們還有一個孤立的樹。我們來將它封裝成另外一個 commit 并指定其父 commit 為 initial commit。我們會使用和前一節(jié)相同的操作 commit-tree,不過需要通過-p 選項來指定父 commit。
$ LATEST_COMMIT_HASH=$( \
echo 'latest commit' |
git commit-tree $LATEST_TREE_HASH -p $INITIAL_COMMIT_HASH )
現(xiàn)在應(yīng)該是這樣:
所以如果你現(xiàn)在將最后一次 commit 的哈希值傳遞給 git log 你會看到提交歷史中有兩條提交記錄:
$ git log --pretty=oneline $LATEST_COMMIT_HASH
[some hash] latest commit
[some hash] initial commit
并且你可以在他們之間切換。這里是 initial commit:
$ git checkout $INITIAL_COMMIT_HASH
$ ls
f1.txt f2.txt
latest commit
$ git checkout $LATEST_COMMIT_HASH
$ ls
f3.txt
HEAD 是對已檢出的 commit 的引用
HEAD 是存放在 .git/HEAD 的文本文件,它是對當(dāng)前已檢出 commit 的引用。由于我們在前面一節(jié)中通過 $LATEST_COMMIT_HASH 檢出了最后的commit,此時 HEAD 文件包含的全部內(nèi)容:
$ cat .git/HEAD
88d3b9901d62fc1de9219f388e700d98bdb97ba9
$ [ $LATEST_COMMIT_HASH == "88d3b9901d62..." ]; echo 'equal'
equal
然而,通常 HEAD 文件是通過分支引用來引用當(dāng)前檢出的 commit。當(dāng)它直接引用一個 commit 的時候它是處于 detached state(分離狀態(tài))。但是即使當(dāng) HEAD 像這樣通過分支持有一個引用:
ref: refs/heads/master
它仍然是引用一個 commit 的哈希值。
你現(xiàn)在知道了在執(zhí)行 git status 命令時, Git 使用通過HEAD 引用的 commit 來產(chǎn)生一系列 index 文件和當(dāng)前檢出的樹/commit 之間的修改。HEAD 的另外一個用途就是決定下一個 commit 的父 commit。
有趣的是,HEAD 文件對大多數(shù)操作都是如此重要以至于如果你手動清除其內(nèi)容,Git 將認(rèn)為不是一個 git 倉庫并報錯:
fatal: Not a git repository (or any of the parent directories): .git
分支是一個指向某一個commit的文本文件
所以現(xiàn)在我們的倉庫中有兩條 commit,形成了如下提交歷史:
$ git log --pretty=oneline $LATEST_COMMIT_HASH
[some hash] latest commit
[some hash] initial commit
我們在已有的歷史中引入一個分叉。我們將檢出最初的 commit 并修改 f1.txt 文件內(nèi)容。然后使用你已經(jīng)習(xí)慣的 git commit 命令創(chuàng)建一條新的 commit:
$ git checkout $INITIAL_COMMIT_HASH
$ echo 'I am modified f1 content' > f1.txt
$ git add f1.txt
$ git commit -m "forked commit"
1 file changed, 1 insertion(+), 1 deletion(-)
以上的代碼片段:
- 檢出
"initial commit"將f1.txt和f2.txt添加到工作目錄 - 將
f1.txt的內(nèi)容也替換為字符串I am modified f1 content - 使用
git add更新index 文件
最后這個我們熟悉的git commit命令內(nèi)部做了以下操作: - 從 index 文件創(chuàng)建一個樹
- 將樹寫入倉庫
- 創(chuàng)建一個 commit 對象將樹封裝起來
- 將
initial commit作為新創(chuàng)建 commit 的父commit,因為當(dāng)前HEAD文件中的 commit 就是initial commit。
我們同樣需要將新的 commit 的哈希值存儲到變量中。由于 Git 根據(jù)當(dāng)前的 commit 文件更新 HEAD,我們可以這樣讀取這個值:
FORKED_COMMIT_HASH=$( cat .git/HEAD )
所以現(xiàn)在我們的 git 倉庫中是這樣一些對象的:
由此生成以下提交歷史:
由于分叉的出現(xiàn)我們現(xiàn)在有兩條工作線。這意味著我們需要引入兩條分支獨立跟蹤每一條工作線。我們創(chuàng)建 master 分支來跟蹤從 latest commit以來的直線歷史,創(chuàng)建 forked 分支來跟蹤自 forked commit 以來的歷史。
一個分支就是一個文本文件,它包含了一個commit的哈希值。它是 git引用的一部分--引用一個 commit 的一組對象。另外一個引用類型是輕量的 tag。Git 將所有的引用存儲到 .git/refs 目錄,將所有分支存儲在 .git/refs/heads 目錄。由于分支就是一個文本文件,我們可以使用 commit 的哈希值來創(chuàng)建一個分支。
所以下面的分支將指向主分支的 "latest commit"。
$ echo $LATEST_COMMIT_HASH > .git/refs/heads/master
這一個分支將指向 "forked" 分支的 "forked commit":
$ echo $FORKED_COMMIT_HASH > .git/refs/heads/forked
所以最終我們回到了你常常使用的工作流---我們現(xiàn)在可以在分支之間切換:
$ git checkout master
Switched to branch 'master'
$ git log --pretty=oneline
[some hash] latest commit
[some hash] first commit
$ ls -l
f3.txt
一起來看看另外一個 forked 分支:
$ git checkout forked
Switched to branch 'forked'
$ git log --pretty=oneline
f30305a8a23312f70ba985c8c644fcdca19dab95 forked commit
f30305a8a23312f70ba985c8c644fcdca19dab95 initial commit
$ git ls
f1.txt f2.txt
$ cat f1.txt
I am modified f1 content
一個 tag 就是指向某一個 commit 的文本文件
你興許已經(jīng)知道除了使用分支(一條工作線的)還可以使用 tag 來跟蹤單獨的 commit。Tag 通常用于標(biāo)記重要的開發(fā)節(jié)點如版本發(fā)布?,F(xiàn)在我們的倉庫中有3個 commit。我們可以使用 tag 來給它們命名。和分支一樣,一個 tag 就是一個文本文件,它包含了一個 commit 的哈希值,同樣也是引用組的一部分。
你已經(jīng)知道 git 將所有的引用都存儲在 .git/refs 目錄,所以tag都存儲在 .git/refs/tags 子目錄。由于它就是一個文本文件,我們可以創(chuàng)建一個文件并將 commit 的哈希值寫入其中。
所以這個 tag 會指向 latest commit:
$ echo $FORKED_COMMIT_HASH > .git/refs/tags/forked
這個 tag 會指向 initial commit:
$ echo $INITIAL_COMMIT_HASH > .git/refs/tags/initial
一旦完成了這一步我們就可以使用 tag 在 commit 之間切換。這樣就可以切換到 initial commit:
$ git checkout tags/initial
HEAD is now at 285aec7... second commit
$ cat f1.txt
f1 content
這樣就切換到 forked commit:
$ git checkout tags/forked
$ cat f1.txt
I am modified f1 content
此外還有 "annotated-tag",它和我們現(xiàn)在使用的輕量級 tag有所不同。它是一個對象,可以像commit一樣包含信息,并且是其他對象一起存放在倉庫中。
本文譯自Become a GIT pro by learning GIT architecture in 15 minutes