一、Git 分支本质
- 如果对仓库中从一个提交(比如 1a410e)开始往前的历史感兴趣,那么可以运行 git log 1a410e 这样的命令来显示历史,不过需要记得 1a410e 是查看历史的起点提交。如果我们有一个文件来保存 SHA-1 值,而该文件有一个简单的名字, 然后用这个名字指针来替代原始的 SHA-1 值的话会更加简单。
- 在 Git 中,这种简单的名字被称为“引用(references,或简写为 refs)”,可以在 .git/refs 目录下找到这类含有 SHA-1 值的文件。在目前的项目中,这个目录没有包含任何文件,但它包含了一个简单的目录结构:
$ find .git/refs
.git/refs
.git/refs/heads
.git/refs/tags
$ find .git/refs -type f
- 若要创建一个新引用来帮助记忆最新提交所在的位置,从技术上讲只需简单地做如下操作:
$ echo 1a410efbd13591db07496601ebc7a059dd55cfe9 > .git/refs/heads/master
- 现在,就可以在 Git 命令中使用这个刚创建的新引用来代替 SHA-1 值:
$ git log --pretty=oneline master
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit
- 但是,不提倡直接编辑引用文件,如果想更新某个引用,Git 提供了一个更加安全的命令 update-ref 来完成此事:
$ git update-ref refs/heads/master 1a410efbd13591db07496601ebc7a059dd55cfe9
- 这基本就是 Git 分支的本质:一个指向某一系列提交之首的指针或引用,若想在第二个提交上创建一个分支,可以这么做:
$ git update-ref refs/heads/test cac0ca
$ git log --pretty=oneline test
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit
- 至此,我们的 Git 数据库从概念上看起来像这样:
- 当运行类似于 git branch 这样的命令时,Git 实际上会运行 update-ref 命令,取得当前所在分支最新提交对应的 SHA-1 值,并将其加入想要创建的任何新引用中。
二、HEAD 引用
- 现在的问题是,当执行 git branch 时,Git 如何知道最新提交的 SHA-1 值呢? 答案是 HEAD 文件。
- HEAD 文件通常是一个符号引用(symbolic reference),指向目前所在的分支。所谓符号引用,表示它是一个指向其他引用的指针。
- 然而在某些罕见的情况下,HEAD 文件可能会包含一个 git 对象的 SHA-1 值。当在检出一个标签、提交或远程分支,让仓库变成 “分离 HEAD”状态时,就会出现这种情况。
- 如果查看 HEAD 文件的内容,通常会看到类似这样的内容:
$ cat .git/HEAD
ref: refs/heads/master
- 如果执行 git checkout test,Git 会像这样更新 HEAD 文件:
$ cat .git/HEAD
ref: refs/heads/test
- 当执行 git commit 时,该命令会创建一个提交对象,并用 HEAD 文件中那个引用所指向的 SHA-1 值设置其父提交字段。
- 也可以手动编辑该文件,然而同样存在一个更安全的命令来完成此事:git symbolic-ref,借助此命令来查看 HEAD 引用对应的值:
$ git symbolic-ref HEAD
refs/heads/master
$ git symbolic-ref HEAD refs/heads/test
$ cat .git/HEAD
ref: refs/heads/test
$ git symbolic-ref HEAD test
fatal: Refusing to point HEAD outside of refs/
三、标签引用
- 我们知道, Git 有三种主要的对象类型(数据对象、树对象和提交对象,具体请参考:Git内部原理之深入解析Git对象),然而实际上还有第四种:标签对象(tag object), 它非常类似于一个提交对象,包含一个标签创建者信息、一个日期、一段注释信息,以及一个指针。主要的区别在于,标签对象通常指向一个提交对象,而不是一个树对象,像是一个永不移动的分支引用,永远指向同一个提交对象,只不过给这个提交对象加上一个更友好的名字罢了。
- 正如 Git之深入解析本地仓库的基本操作·仓库的获取更新和提交历史的查看撤销以及标签别名的使用 中所讨论的那样,存在两种类型的标签:附注标签和轻量标签,可以像这样创建一个轻量标签:
$ git update-ref refs/tags/v1.0 cac0cab538b970a37ea1e769cbbde608743bc96d
- 这就是轻量标签的全部内容,一个固定的引用。 然而,一个附注标签则更复杂一些,若要创建一个附注标签,Git 会创建一个标签对象,并记录一个引用来指向该标签对象,而不是直接指向提交对象。可以通过创建一个附注标签来验证这个过程(使用 -a 选项):
$ git tag -a v1.1 1a410efbd13591db07496601ebc7a059dd55cfe9 -m 'test tag'
$ cat .git/refs/tags/v1.1
9585191f37f7b0fb9444f35a9bf50de191beadc2
- 现在对该 SHA-1 值运行 git cat-file -p 命令:
$ git cat-file -p 9585191f37f7b0fb9444f35a9bf50de191beadc2
object 1a410efbd13591db07496601ebc7a059dd55cfe9
type commit
tag v1.1
tagger Scott Chacon <schacon@gmail.com> Sat May 23 16:48:58 2009 -0700
test tag
- 不难注意到,object 条目指向打了标签的那个提交对象的 SHA-1 值。另外,标签对象并非必须指向某个提交对象,可以对任意类型的 Git 对象打标签。例如,在 Git 源码中,项目维护者将它们的 GPG 公钥添加为一个数据对象,然后对这个对象打了一个标签,可以克隆一个 Git 版本库,然后通过执行下面的命令来在这个版本库中查看上述公钥:
$ git cat-file blob junio-gpg-pub
- Linux 内核版本库同样有一个不指向提交对象的标签对象,首个被创建的标签对象所指向的是最初被引入版本库的那份内核源码所对应的树对象。
四、远程引用
- 现在将看到的第三种引用类型是远程引用(remote reference),如果添加了一个远程版本库并对其执行过推送操作,Git 会记录下最近一次推送操作时每一个分支所对应的值,并保存在 refs/remotes 目录下。例如,可以添加一个叫做 origin 的远程版本库,然后把 master 分支推送上去:
$ git remote add origin git@github.com:schacon/simplegit-progit.git
$ git push origin master
Counting objects: 11, done.
Compressing objects: 100% (5/5), done.
Writing objects: 100% (7/7), 716 bytes, done.
Total 7 (delta 2), reused 4 (delta 1)
To git@github.com:schacon/simplegit-progit.git
a11bef0..ca82a6d master -> master
- 此时,如果查看 refs/remotes/origin/master 文件,可以发现 origin 远程版本库的 master 分支所对应的 SHA-1 值,就是最近一次与服务器通信时本地 master 分支所对应的 SHA-1 值:
$ cat .git/refs/remotes/origin/master
ca82a6dff817ec66f44342007202690a93763949
- 远程引用和分支(位于 refs/heads 目录下的引用)之间最主要的区别在于,远程引用是只读的。虽然可以 git checkout 到某个远程引用,但是 Git 并不会将 HEAD 引用指向该远程引用。因此,永远不能通过 commit 命令来更新远程引用,Git 将这些远程引用作为记录远程服务器上各分支最后已知位置状态的书签来管理。
五、包文件
- 如果跟着做完了上文的所有步骤,那么现在应该有了一个测试用的 Git 仓库, 其中包含 11 个对象:四个数据对象,三个树对象,三个提交对象和一个标签对象:
$ find .git/objects -type f
.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341 # tree 2
.git/objects/1a/410efbd13591db07496601ebc7a059dd55cfe9 # commit 3
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a # test.txt v2
.git/objects/3c/4e9cd789d88d8d89c1073707c3585e41b0e614 # tree 3
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 # test.txt v1
.git/objects/95/85191f37f7b0fb9444f35a9bf50de191beadc2 # tag
.git/objects/ca/c0cab538b970a37ea1e769cbbde608743bc96d # commit 2
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 'test content'
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 # tree 1
.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92 # new.txt
.git/objects/fd/f4fc3344e67ab068f836878b6c4951e3b15f3d # commit 1
- Git 使用 zlib 压缩这些文件的内容,而且并没有存储太多东西,所以上文中的文件一共只占用了 925 字节。接下来,我们添加一些大文件到仓库中,以此展示 Git 的一个很有趣的功能。为了便于演示,要把之前在 Grit 库中用到过的 repo.rb 文件添加进来,如下所示,这是一个大小约为 22K 的源代码文件:
$ curl https:
$ git checkout master
$ git add repo.rb
$ git commit -m 'added repo.rb'
[master 484a592] added repo.rb
3 files changed, 709 insertions(+), 2 deletions(-)
delete mode 100644 bak/test.txt
create mode 100644 repo.rb
rewrite test.txt (100%)
- 如果查看生成的树对象,可以看到 repo.rb 文件对应的数据对象的 SHA-1 值:
$ git cat-file -p master^{tree}
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5 repo.rb
100644 blob e3f094f522629ae358806b17daf78246c27c007b test.txt
- 接下来可以使用 git cat-file 命令查看这个对象有多大:
$ git cat-file -s 033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5
22044
$ echo '# testing' >> repo.rb
$ git commit -am 'modified repo.rb a bit'
[master 2431da6] modified repo.rb a bit
1 file changed, 1 insertion(+)
- 查看这个最新的提交生成的树对象,可以看到一些有趣的东西:
$ git cat-file -p master^{tree}
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob b042a60ef7dff760008df33cee372b945b6e884e repo.rb
100644 blob e3f094f522629ae358806b17daf78246c27c007b test.txt
- repo.rb 对应一个与之前完全不同的数据对象,这意味着,虽然只是在一个 400 行的文件后面加入一行新内容,Git 也会用一个全新的对象来存储新的文件内容:
$ git cat-file -s b042a60ef7dff760008df33cee372b945b6e884e
22054
- 磁盘上现在有两个几乎完全相同、大小均为 22K 的对象(每个都被压缩到大约 7K),如果 Git 只完整保存其中一个,再保存另一个对象与之前版本的差异内容,岂不更好?
- 事实上 Git 可以那样做,最初向磁盘中存储对象时所使用的格式被称为“松散(loose)”对象格式。但是,Git 会时不时地将多个这些对象打包成一个称为“包文件(packfile)”的二进制文件,以节省空间和提高效率,当版本库中有太多的松散对象,或者手动执行 git gc 命令,或者向远程服务器执行推送时,Git 都会这样做。要看到打包过程,可以手动执行 git gc 命令让 Git 对对象进行打包:
$ git gc
Counting objects: 18, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (14/14), done.
Writing objects: 100% (18/18), done.
Total 18 (delta 3), reused 0 (delta 0)
- 这个时候再查看 objects 目录,会发现大部分的对象都不见了,与此同时出现了一对新文件:
$ find .git/objects -type f
.git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
.git/objects/info/packs
.git/objects/pack/pack-978e03944f5c581011e6998cd0e9e30000905586.idx
.git/objects/pack/pack-978e03944f5c581011e6998cd0e9e30000905586.pack
- 仍保留着的几个对象是未被任何提交记录引用的数据对象,在此例中是之前创建的 “what is up, doc?” 和 “test content” 这两个示例数据对象,因为从没将它们添加至任何提交记录中,所以 Git 认为它们是悬空(dangling)的,不会将它们打包进新生成的包文件中。
- 剩下的文件是新创建的包文件和一个索引,包文件包含了刚才从文件系统中移除的所有对象的内容,索引文件包含了包文件的偏移信息,我们通过索引文件就可以快速定位任意一个指定对象。有意思的是运行 gc 命令前磁盘上的对象大小约为 15K,而这个新生成的包文件大小仅有 7K,通过打包对象减少了一半的磁盘占用空间。
- Git 是如何做到这点的呢? Git 打包对象时,会查找命名及大小相近的文件,并只保存文件不同版本之间的差异内容。可以查看包文件,观察它是如何节省空间的,git verify-pack 这个底层命令可以就可以查看已打包的内容:
$ git verify-pack -v .git/objects/pack/pack-978e03944f5c581011e6998cd0e9e30000905586.idx
2431da676938450a4d72e260db3bf7b0f587bbc1 commit 223 155 12
69bcdaff5328278ab1c0812ce0e07fa7d26a96d7 commit 214 152 167
80d02664cb23ed55b226516648c7ad5d0a3deb90 commit 214 145 319
43168a18b7613d1281e5560855a83eb8fde3d687 commit 213 146 464
092917823486a802e94d727c820a9024e14a1fc2 commit 214 146 610
702470739ce72005e2edff522fde85d52a65df9b commit 165 118 756
d368d0ac0678cbe6cce505be58126d3526706e54 tag 130 122 874
fe879577cb8cffcdf25441725141e310dd7d239b tree 136 136 996
d8329fc1cc938780ffdd9f94e0d364e0ea74f579 tree 36 46 1132
deef2e1b793907545e50a2ea2ddb5ba6c58c4506 tree 136 136 1178
d982c7cb2c2a972ee391a85da481fc1f9127a01d tree 6 17 1314 1 \
deef2e1b793907545e50a2ea2ddb5ba6c58c4506
3c4e9cd789d88d8d89c1073707c3585e41b0e614 tree 8 19 1331 1 \
deef2e1b793907545e50a2ea2ddb5ba6c58c4506
0155eb4229851634a0f03eb265b69f5a2d56f341 tree 71 76 1350
83baae61804e65cc73a7201a7252750c76066a30 blob 10 19 1426
fa49b077972391ad58037050f2a75f74e3671e92 blob 9 18 1445
b042a60ef7dff760008df33cee372b945b6e884e blob 22054 5799 1463
033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5 blob 9 20 7262 1 \
b042a60ef7dff760008df33cee372b945b6e884e
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a blob 10 19 7282
non delta: 15 objects
chain length = 1: 3 objects
.git/objects/pack/pack-978e03944f5c581011e6998cd0e9e30000905586.pack: ok
- 此处,033b4 这个数据对象(即 repo.rb 文件的第一个版本)引用了数据对象 b042a,即该文件的第二个版本,命令输出内容的第三列显示的是各个对象在包文件中的大小,可以看到 b042a 占用了 22K 空间,而 033b4 仅占用 9 字节。同样有趣的地方在于,第二个版本完整保存了文件内容,而原始的版本反而是以差异方式保存的,这是因为大部分情况下需要快速访问文件的最新版本。
- 最妙之处是可以随时重新打包,Git 时常会自动对仓库进行重新打包以节省空间。当然也可以随时手动执行 git gc 命令来这么做。