Git 如何储存档案内容?
Git 储存内容时,都是透过 git hash-object 取得 SHA1 并储存起来,如下所示,只要档案内容稍有变动,git hash-object 的结果就会天差地远:
$ echo 'f' | git hash-object --stdin6a69f92020f5df77af6e8813ff1232493383b708$ echo 'fi' | git hash-object --stdine133fadec9f9f77fd6add0e533715d74d393eace
备注:事实上 git hash-object 是将「档案内容和 Header」做 SHA1。Header 是 “blob” 加上空格、内容长度、结尾 null bytes。
SHA1("blob 2\u0000fi")e133fadec9f9f77fd6add0e533715d74d393eace
了解了 Git 产生 SHA1 的方式,我们可以进一步把这个 SHA1 储存起来,依循前面的例子,不一样的是这次我们将 SHA1 储存起来:
$ echo 'f' | git hash-object --stdin -w6a69f92020f5df77af6e8813ff1232493383b708$ echo 'fi' | git hash-object --stdin -we133fadec9f9f77fd6add0e533715d74d393eace
看起来跟前面例子输出一样,要怎么验证 SHA1 已经被 Git 储存起来了呢?我们可以列出 .git 目录下的 objects 资料夹内容:
$ tree .git/objects/.git/objects/├── 6a│ └── 69f92020f5df77af6e8813ff1232493383b708├── e1│ └── 33fadec9f9f77fd6add0e533715d74d393eace├── info└── pack4 directories, 2 files
我们可以看到,git hash-object 所产生的 SHA1,前面 2 个数字、字母会被切出来当成资料夹,剩余的 38 个数字、字母当成档名,被放置在 .git/objects 资料夹底下。
那这两个档案储存的内容是什么呢?
首先我们用 cat 这个指令来看一下内容:
$ cat .git/objects/6a/69f92020f5df77af6e8813ff1232493383b708xK��OR0bH�nb
你没看错,档案内容是乱码。原因是因为 git 用二进制的方式将内容储存起来,我们可以用 git 提供的指令来轻易地检视这些内容。
$ git cat-file -p 6a69f92020f5df77af6e8813ff1232493383b708f$ git cat-file -p e133fadec9f9f77fd6add0e533715d74d393eacefi
不一样的是,git cat-file 指令要直接给整串 SHA1,而不再是目录、档名。然后你可以看到,结果就是我们前面给的档案内容。
至此,你了解了 Git 是将档案内容,加上 Header 后,产生对应的 SHA1,当成目录、档名,而其内容就是原始档案的二进制内容。一旦档案内容有所更动,SHA1 就会不一样,反之,内容相同,SHA1 就相同。
了解这样不够,我们会有些疑问:
万一我有档名、资料夹呢?哪里会纪录的我档名、资料夹名称?还有 Commit 呢?我 Commit 的内容储存在哪?Branch、Tag 储存在哪?…不急,我们先继续往下读,你会在最后不经意说出「哦!原来是这样」。
Blob
首先,我们先介绍 git cat-file 的另一个功能,也就是检视该 SHA1 的「类型」:
$ git cat-file -t 6a69f92020f5df77af6e8813ff1232493383b708blob$ git cat-file -t e133fadec9f9f77fd6add0e533715d74d393eaceblob
可以看到,前面我们所建立的两个 SHA1 都是 blob 类型,由此可知,Git 将档案内容转成二进制,并产生 SHA1 储存起来的「物件」,称为 blob,这个很重要,请先记得。
再来,我们延续前面的内容,有了 blob 还不够,人是贪心的,我想要存档名、资料夹名称时,该怎么办?
因此 Git 提供了另外一种「类型」,称为 Tree。
Tree
在了解 Tree 以前,我们先偷窥一下 Tree 的範例内容(我们没有这些档案,只是让你看一下可能的内容):
$ git cat-file -p 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0100644 blob a906cb2a4a904a152e80877d4088654daad0c859 README100644 blob 8f94139338f9404f26296befa88755fc2598c289 Rakefile040000 tree 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0 lib
上图 Tree 里面,纪录了两个档案(Blob)档名及其对应的 SHA1,如果我们用 git cat-file 加上 README 的 SHA1,就可以取得 README 这个档案的内容。而 lib 是一个资料夹,资料夹里面可能还有其他档案,所以用另一个 Tree 储存这些资料。
Tree 本身也是跟 Blob 一样,用 SHA1 当档名,然后储存二进制内容,但不一样的是,Blob 储存了我们的「档案内容」,而 Tree 则是储存了我们的「档名」,甚至还包含了另一个 Tree!如下图所示:
如此一来,有了 Tree 这个物件,我们就可以解决前面提到的问题,我们可以储存档案的名称、资料夹名称、档案权限等等。也因为 Tree 记录了相关连的 Blob、Tree 的 SHA1,所以我们可以轻鬆地透过 Tree,找到子目录、子子目录的档案内容、名称。
至此我们就可以大致窥探到 Git 是如何储存一整个资料夹的内容、关联。首先要先有 SHA1,然后再透过 Tree 的方式记录关联,透过 Blob 记录档案内容!
了解 Tree、Blob 后,我们进一步来看看 Commit 是怎么回事。
Commit
Commit 就和 Tree、Blob 一样,也是拥有 SHA1 档名,储存在 .git/objects 目录下,我们来看看一个 Commit 物件可能的内容:
$ git cat-file -p fb1298cab9b794b251e3f8ece5bc4380ab9328f8tree d7ab14dd76c4990b64031c890f95fd9eae042c9aauthor shavenking <shavenking@gmail.com> 1512874376 +0800committer shavenking <shavenking@gmail.com> 1512874376 +0800awesome
就如同 Tree、Blob 一样,我们用 git cat-file 这个指令可以看到其储存的内容,第一行是 Tree,接着的是该 Commit 的相关资讯(作者、Commit 讯息)。
备注:Commit 只能纪录 Tree,不能纪录 Blob。
让我们直接用图来看看加上 Commit 之后的样子:
如果我们再新增一个 Commit 呢?请看下图:
如果用 git cat-file 可以看到档案内容如下:
$ git cat-file -p 5ed902ec9ef442ec0a9dd5fd2bf9639175539c8btree a031edc7aa1c34a9e46b86f64d2798d9fcb8ad03parent fb1298cab9b794b251e3f8ece5bc4380ab9328f8author shavenking <shavenking@gmail.com> 1512874387 +0800committer shavenking <shavenking@gmail.com> 1512874387 +0800awesome again
除了 Tree 以外,多了一个 Parent,纪录前一个 Commit 的 SHA1,因此所有 Commit 就能串连在一起,我可以透过 git cat-file 一个一个往回找。
特别注意:Commit、Tree、Blob 都是除存在 .git/objects 里面,以 SHA1 当档名的二进制档案!
了解这些之后,接下来我们再进一步说明 Branch 及 Tag 的原理。
Branch and Tag
Branch 和 Tag 就稍微不太一样了,他们不再是以 SHA1 当档名的二进制档案,取而代之的是纪录「Commit」的 SHA1,并储存在 .git/refs 里面。
我们知道,git init 初始化后,Git 会帮我们建立一个 master,在我们 git commit 后,这个 master 就会出现在 .git/refs 底下,如下:
$ tree .git/refs/.git/refs/├── heads│ └── master└── tags2 directories, 1 file
可以看到 heads 资料夹下有一个名为 master 的档案,其内容如下:
$ cat .git/refs/heads/master5ed902ec9ef442ec0a9dd5fd2bf9639175539c8b
注意到了吗?master 这个档案(也就是 Branch),记录了 Commit 的 SHA1,所以如果我们用 git cat-file 继续追蹤的话,你就会看到下列内容:
$ git cat-file -p 5ed902ec9ef442ec0a9dd5fd2bf9639175539c8btree a031edc7aa1c34a9e46b86f64d2798d9fcb8ad03parent fb1298cab9b794b251e3f8ece5bc4380ab9328f8author shavenking <shavenking@gmail.com> 1512874387 +0800committer shavenking <shavenking@gmail.com> 1512874387 +0800awesome again
由此可见,分支只不过是一个指标,指向某一个 Commit,所以此时如果我建立另外一个分支,就会长这样:
$ git branch dev$ tree .git/refs.git/refs├── heads│ ├── dev│ └── master└── tags2 directories, 2 files$ cat .git/refs/heads/dev5ed902ec9ef442ec0a9dd5fd2bf9639175539c8b$ git cat-file -p 5ed902ec9ef442ec0a9dd5fd2bf9639175539c8btree a031edc7aa1c34a9e46b86f64d2798d9fcb8ad03parent fb1298cab9b794b251e3f8ece5bc4380ab9328f8author shavenking <shavenking@gmail.com> 1512874387 +0800committer shavenking <shavenking@gmail.com> 1512874387 +0800awesome again
可以看到,Git 只是帮我们新增了一个「Branch」档案,纪录了 Commit 的 SHA1!如果用图表示的话,就会长这样:
那 Tag 呢?也是同样的道理,我们直接用实例来看:
$ git tag v1$ tree .git/refs.git/refs├── heads│ ├── dev│ └── master└── tags └── v12 directories, 3 files$ cat .git/refs/tags/v15ed902ec9ef442ec0a9dd5fd2bf9639175539c8b
可以看到,Tag 也只是一个档案,纪录了某个 Commit 的 SHA1,那 Branch 和 Tag 差在哪里呢?
简单来说,Branch 会一直跟着新的 Commit 成长,而 Tag 则是永远记录同一个 Commit,直到海枯石烂。
我们可以简单地再新增一个 Commit 来看看就能明白了。
首先看到我增加了第三个 Commit:
$ git logcommit ec40ddb953522ca3cba864736e3b2a01e426992bAuthor: shavenking <shavenking@gmail.com>Date: Sun Dec 10 11:14:33 2017 +0800 new commitcommit 5ed902ec9ef442ec0a9dd5fd2bf9639175539c8b (tag: v1, dev)Author: shavenking <shavenking@gmail.com>Date: Sun Dec 10 10:53:07 2017 +0800 awesome againcommit fb1298cab9b794b251e3f8ece5bc4380ab9328f8Author: shavenking <shavenking@gmail.com>Date: Sun Dec 10 10:52:56 2017 +0800 awesome
此时我们看看 .git/refs 目录结构:
$ tree .git/refs.git/refs├── heads│ ├── dev│ └── master└── tags └── v12 directories, 3 files
没错,还是维持一样,改变的只是档案内容,还记得我们的 master 跟 v1 都指向第二个 Commit 吗?一旦我们新增第三个 Commit,我们就能看出差异了:
$ cat .git/refs/heads/masterec40ddb953522ca3cba864736e3b2a01e426992b$ cat .git/refs/tags/v15ed902ec9ef442ec0a9dd5fd2bf9639175539c8b
可以清楚地看到,Branch 随着我的新 Commit 而随时更改,反之 Tag 则是待在原地呆呆地守住他的幸福。此时的图长这样:
由以上描述可见,一旦你在某个 Branch 有新的 Commit 时,Git 就会自动更新该 Branch 档案,纪录最新的 Commit。而 Tag 则是一旦指定 Commit 后,就不会因为有任何新 Commit 而影响。
最后,还有一个关键的档案,就是 .git 目录下的 HEAD。
HEAD
我们有那么多 Branch、Tag、Commit,我们怎么知道我们现在在哪里?Git 又怎么知道该显示哪些档案?
所以才需要 HEAD 这个档案,记录我们目前所在的位置。
我们可以看一下,如果我们在 master 时,HEAD 的档案内容显示:
$ cat .git/headref: refs/heads/master
就如同 Branch、Tag 的运作方式,HEAD 这个档案仅仅是储存了一个指标,告诉我们目前所在的位置,如果用图表示就会长这样:
回想基本指令
我们了解以上内容后,我们可以来回想一下日常生活中,我们用的 Git 指令。
git add:建立 Tree、Blob 物件,準备稍后 git commit 时使用。git commit:就如同该指令名称,我们建立了一个 Commit 的档案储存在 .git/objects。git branch:建立 Branch 档案,纪录某个 Commit SHA1,并储存在 .git/refs。git tag:建立 Tag 档案,纪录某个 Commit SHA1,并储存在 .git/refs。git checkout:这个指令可以指定 Branch、Tag、Commit 的 SHA1,也就是说你能把目前的位置(HEAD),指向任一个 Branch、Tag、Commit,让你拥有穿梭未来、过去的能力。