0%

Git内部原理

我们不生产文章,我们只是 Pro Git Book 的搬运工。
本文通过例子,来阐述 Git 是如何对「版本控制」这个命题进行抽象和实践的,以及是如何用「底层命令」去实现「上层命令」的。


git 的本质是一个内容寻址(content-addressable)系统,并在此基础上提供了一个版本控制系统的用户界面。早期的 git 侧重于文件系统而不是一个被打磨过的版本控制系统,现在则已经成为了一个优秀的版本控制系统。

git 分为两层,一层是我们日常使用的上层命令(procelain);一层是更加低抽象层次的底层命令(plumbing)。通过底层命令,可以很好地窥探 git 内部的工作机制。

git 将版本控制相关内容放在了 .git 目录下,它包含了几乎所有 git 存储和操作的东西,它的典型结构如下:

1
2
3
4
5
6
7
8
9
10
11
$ ls -F1
config # 包含项目特有的配置选项
description # 仅供 GitWeb 程序使用
hooks/ # 包含客户端服务端的钩子脚本(hook script)
info/ # 包含全局性排除文件,即没有放在 .gitignore 里的忽略模式(ignored pattern)

# git 核心组成成分
objects/ # 存储所有数据内容
refs/ # 存储指向数据(分支、远程仓库、标签等)的提交对象的指针
HEAD # 指向当前被检出的分支
index # 保存暂存区信息

下面就用例子来按顺序讲解这四个部分。

git 对象

git 核心是内容寻址系统,一个简单的 key-value 数据库。不管插入任何类型的内容,它都会返回唯一的键,通过它可以进行读取。对一个仓库来说,所有的对象都存储在它的 .git/objects 目录下。

1
2
3
4
5
6
7
$ git init test
Initialized empty Git repository in /tmp/test/.git/
$ cd test
$ find ./git/objects
.git/objects
.git/objects/info
.git/objects/pack

目前我们初始化了一个仓库,git 对 objects 目录进行了初始化,创建了 infopack 子目录,不过也都是空的。下面我们分别介绍不同的对象类型以及相应操作。

文件对象 blob

先来创建一个新的对象并把它手动存入这个 git 数据库中。

创建数据对象: git hash-object

1
2
3
4
5
# -w 表示该命令不止要返回键,还要将该对象写入数据库
# --stdin 表示该命令从标准输出读取输入,如果不指定则需要在命令尾部给出待存储文件的路径

$ echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4

这里返回了一个长度 40 字符的 key,它是待存储数据和头部信息做 SHA-1 校验运算得到的校验和。现在,我们看一下 git是怎么存储文件的:

1
2
$ find .git/objects -type f
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4

这里,校验和前 2 位作为目录,余下的 38 位作为文件名。要注意的是,文件内容被保存了,但是文件名并没有被保存。我们把内容存进了对象数据库后,就可以通过 key 来从 git 取回数据、查看文件内容了,如下:

查看数据对象:git cat-file

1
2
3
4
# -p 表示自动判断内容类型,并显示大致内容

$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content

这种类型的对象被称为数据对象(blob object),可以通过下面的命令查看对象类型:

1
2
$ git cat-file -t d670460b4b4aece5915caf5c68d12f560a9fe3e4
blob

下面我们再往数据库里写一些内容,演示一下,同时作为后面操作 .git 内容的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# ---------- 存入 ----------
# 创建新文件并存入数据库
$ echo 'version 1' > test.txt
$ git hash-object -w test.txt
83baae61804e65cc73a7201a7252750c76066a30

# 修改文件并存入数据库
$ echo 'version 2' > test.txt
$ git hash-object -w test.txt
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a

# ---------- 查看 ----------
# 现在,对象数据库记录了该文件的两个不同版本
$ find .git/objects -type f
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a # 之前存的 'test content'
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 # 'version 1'
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 'version 2'

# ---------- 读取 ----------
$ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test.txt
$ cat test.txt
version 1

$ git cat-file -p 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a > test.txt
$ cat test.txt
version 2

树对象 tree

树对象可以解决文件名保存的问题,允许我们将多个文件组织到一起。和 UNIX 系统对应的话,树对象类似目录项,数据对象类似 inodes 或文件内容。我们尝试读取一个树对象(不是我们的仓库):

1
2
3
4
5
6
7
8
# master^{tree} 表示 master 分支上最新的提交所指向的树对象
# PS: 不同系统里的 ^ 和 {} 可能有特殊用途,需要转义或者用引号括起来
# CMD: master^^{tree}, PowerShell: 'master^{tree}', ZSH: "master^{tree}"

$ git cat-file -p master^{tree}
100644 blob a906cb2a4a904a152e80877d4088654daad0c859 README
100644 blob 8f94139338f9404f26296befa88755fc2598c289 Rakefile
040000 tree 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0 lib

可以看到它里面有 blob 也有 tree。

在 git 中,树对象通常被用来保存某一时刻暂存区(index 区域)所表示的状态。过程是这样的,一是先创建暂存区,二是把指定文件添加到暂存区,下面我们存一下之前创建的第一个版本的 test.txt

将文件存入暂存区:git update-index

1
2
3
4
5
6
7
8
# --add 表示将文件添加到暂存区
# --cacheinfo 表示要添加的文件在 git 数据库中而不是当前目录下
# 100644 表示要添加的文件模式为普通文件(100755 表示可执行文件,1200000 表示符号链接)
# SHA-1 表示要添加的文件对应的 key
# test.txt 表示要添加的文件对应的文件名

$ git update-index --add --cacheinfo 100644 \
83baae61804e65cc73a7201a7252750c76066a30 test.txt

将暂存区写入树对象:git write-tree

1
2
3
4
# 树对象此前并不存在则不需要 -w 选项,这里根据暂存区状态自动创建了新的树对象

$ git write-tree
d8329fc1cc938780ffdd9f94e0d364e0ea74f579

那我们来看看这个树对象:

1
2
3
4
$ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579
100644 blob 83baae61804e65cc73a7201a7252750c76066a30 test.txt
$ git cat-file -t d8329fc1cc938780ffdd9f94e0d364e0ea74f579
tree

为了好玩,我们拿第二个版本的 text.txt 并添加一个 new.txt,再存一个新的树对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# ---------- 文件存入暂存区 ----------
$ echo 'new file' > new.txt
$ git update-index --add --cacheinfo 100644 \
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt
$ git update-index --add new.txt

# ---------- 暂存区存成新的树对象 ----------
$ git write-tree
0155eb4229851634a0f03eb265b69f5a2d56f341

# ---------- 读取新的树对象 ----------
$ git cat-file -p 0155eb4229851634a0f03eb265b69f5a2d56f341
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt

还是为了好玩,我们把第一个树对象加入第二个树对象,创建第三个树对象:

将树对象存入暂存区:git read-tree

1
2
3
4
5
6
7
8
9
10
11
12
# ---------- 树对象存入暂存区 ----------
$ git read-tree --prefix=bak d8329fc1cc938780ffdd9f94e0d364e0ea74f579

# ---------- 暂存区存成新的树对象 ----------
$ git write-tree
3c4e9cd789d88d8d89c1073707c3585e41b0e614

# ---------- 读取新的树对象 ----------
$ git cat-file -p 3c4e9cd789d88d8d89c1073707c3585e41b0e614
040000 tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 bak
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt

可以看到这里的 bak 并不是数据对象,而是树对象,是一个指向另一个树对象的指针,读一下看:

1
2
$ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579
100644 blob 83baae61804e65cc73a7201a7252750c76066a30 test.txt

第三个树对象的结构大概如下:

提交对象 commit

现在我们有三个树对象了,分别代表项目的快照。目前我们只能用复杂的 SHA-1 来记住他们,而且还不知道快照的保存时刻、保存者、保存原因等,而这就引出了提交对象 commit。我们先用第一个树对象创建一个提交对象:

创建提交对象:git commit-tree

1
2
$ echo 'first commit' | git commit-tree d8329f
fdf4fc3344e67ab068f836878b6c4951e3b15f3d

读取一下这个提交对象看看:

1
2
3
4
5
6
7
$ git cat-file -p fdf4fc3
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 # 项目快照的顶层树对象
# (此提交无) # 父提交
author Scott Chacon <schacon@gmail.com> 1243040974 -0700 # user.name + 时间戳
committer Scott Chacon <schacon@gmail.com> 1243040974 -0700 # user.email + 时间戳
# 留空一行
first commit # 提交注释

我们再拿第二个和第三个树对象创建两个提交对象,它们分别引用各自的上一个提交作为父提交:

1
2
3
4
$ echo 'second commit' | git commit-tree 0155eb -p fdf4fc3
cac0cab538b970a37ea1e769cbbde608743bc96d
$ echo 'third commit' | git commit-tree 3c4e9c -p cac0cab
1a410efbd13591db07496601ebc7a059dd55cfe9

进行完毕后,我们对最后一个提交的 SHA-1 进行 log,就可以看到真实的一个 git 提交历史了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$ git log --stat 1a410e
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Author: Scott Chacon <schacon@gmail.com>
Date: Fri May 22 18:15:24 2009 -0700

third commit

bak/test.txt | 1 +
1 file changed, 1 insertion(+)

commit cac0cab538b970a37ea1e769cbbde608743bc96d
Author: Scott Chacon <schacon@gmail.com>
Date: Fri May 22 18:14:29 2009 -0700

second commit

new.txt | 1 +
test.txt | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)

commit fdf4fc3344e67ab068f836878b6c4951e3b15f3d
Author: Scott Chacon <schacon@gmail.com>
Date: Fri May 22 18:09:34 2009 -0700

first commit

test.txt | 1 +
1 file changed, 1 insertion(+)

上面的操作,正是我们运行 git addgit commit 时,git 所做的实质工作:将被修改的文件保存为数据对象、更新暂存区、记录树对象、创建(指明了顶层树对象和父提交对象的)提交对象。对于涉及到的的数据对象、树对象、提交对象,最初都存在 .git/objects 中:

1
2
3
4
5
6
7
8
9
10
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/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

当前所有数据的结构如下:

对象存储

之前我们讲返回的 40 个字符串是“待存储数据和头部信息做 SHA-1 校验运算得到的校验和”,现在我们就举存一个字符串的例子,看看这个头部信息和待存储数据是怎么存的,下面用了 Ruby。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 进入 ruby 交互命令行
$ irb

# 待存储数据
>> content = "what is up, doc?"
=> "what is up, doc?"

# 头部信息
# git 根据识别出的对象类型构造头部信息,对于字符串,就是`对象类型 + 内容字节数 + 空字节`
>> header = "blob #{content.length}\0"
=> "blob 16\u0000"

# 拼接二者
>> store = header + content
=> "blob 16\u0000what is up, doc?"

# 计算校验和
>> require 'digest/sha1'
=> true
>> sha1 = Digest::SHA1.hexdigest(store)
=> "bd9dbf5aae1a3862dd1526723246b20206e5fc37"

# 确定要写入的路径
>> path = '.git/objects/' + sha1[0,2] + '/' + sha1[2,38]
=> ".git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37"

# 写入文件
>> require 'fileutils'
=> true
>> FileUtils.mkdir_p(File.dirname(path))
=> ".git/objects/bd"
>> File.open(path, 'w') { |f| f.write zlib_content }
=> 32

我们比较一下 git 里返回的校验和,二者是一样的:

1
2
$ echo -n "what is up, doc?" | git hash-object --stdin
bd9dbf5aae1a3862dd1526723246b20206e5fc37

用 git 读取一下这个对象:

1
2
$ git cat-file -p bd9dbf5aae1a3862dd1526723246b20206e5fc37
what is up, doc?

上面演示了整个过程,不同类型对象的区别仅在于头部信息的 blobtreecommit,以及前者内容可以是任何东西,后两者内容有固定的格式。

git 引用

对 commit 来说,目前我们只能用复杂的 SHA-1 值来记住它们。git 中提供了一种便捷途径:用一个文件存储 SHA-1 值,而该文件有个简单的名字。这个简单的名字,就是引用,它存储在 .git/refs 目录下。在目前项目中,它还是空的:

1
2
3
4
$ find .git/refs
.git/refs
.git/refs/heads
.git/refs/tags

修改它有两种办法,一是直接硬写(不建议):

1
$ echo 1a410efbd13591db07496601ebc7a059dd55cfe9 > .git/refs/heads/master

二是通过 git 底层命令(建议):

更新引用:git update-ref

1
2
3
4
5
6
7
8
9
10
11
# 先获取一下提交
$ git log --pretty=oneline master
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

# 创建 third commit 的引用
$ git update-ref refs/heads/master 1a410efbd13591db07496601ebc7a059dd55cfe9

# 创建 second commit 的引用
$ git update-ref refs/heads/test cac0ca

现在的 git 数据库长这样了:

这也是 git 分支的本质:一个指向某一系列提交之首的指针或引用。当运行 git branch <branch> 时,实际上就是将最新的提交的 SHA-1 用 update-ref 存入新创建的引用中。

HEAD 引用

接上面的话题,怎么知道最新的提交的 SHA-1 值呢?答案是 HEAD 文件,它是符号引用(symbolic reference,指向其它引用的指针),指向目前所在分支。在特殊情况下它不再指向其它引用,而是包含一个 git 对象的 SHA-1 值,比如当正在检出标签、提交或远程分支时,此时仓库处于 dateched head 状态。

1
2
3
4
5
6
7
8
9
10
# 查看 HEAD
$ cat .git/HEAD
ref: refs/heads/master

# 假如执行 git checkout test
$ cat .git/HEAD
ref: refs/heads/test

# 假如执行 git commit
# 会创建一个提交对象,并用 HEAD 文件中那个引用指向的 SHA-1 值设置其父提交字段

手动操作这个文件是可行的,但是容易 typo 而造成错误,建议是用下面的命令。

操作 HEAD:git symbolic-ref

1
2
3
4
5
6
7
8
9
10
11
12
# 读取 HEAD 引用
$ git symbolic-ref HEAD
refs/heads/master

# 设置 HEAD 引用
$ git symbolic-ref HEAD refs/heads/test
$ cat .git/HEAD
ref: refs/heads/test

# 错误地设置 HEAD 引用会报错
$ git symbolic-ref HEAD test
fatal: Refusing to point HEAD outside of refs/

标签引用

其实除了 blob、tree、commit,还有第四种主要对象类型:标签对象(tag object)。它含有标签创建时间、创建者、注释信息、一个指向提交对象的指针。它像一个永不移动的分支引用——永远指向同一个提交对象,只不过给这个提交对象加了个更友好的名字。创建标签的过程,实际就是更新标签引用。

我们知道有两种类型的标签,那自然有两种标签引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 创建轻量标签(很简单,一个固定的引用)
$ git update-ref refs/tags/v1.0 cac0cab538b970a37ea1e769cbbde608743bc96d

# 创建附注标签(要先创建一个标签对象,用引用记录该标签对象而非直接指向提交对象)
# 958519 是“引用”记录的“标签对象”
# 1a410e 是“标签对象”记录的“提交对象”
$ git tag -a v1.1 1a410efbd13591db07496601ebc7a059dd55cfe9 -m 'test tag'

$ cat .git/refs/tags/v1.1
9585191f37f7b0fb9444f35a9bf50de191beadc2

$ 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

不过标签对象并不是必须指向 commit object,可以对任何类型的 git 对象打标签。比如 git 源码里给 GPG 公钥创建了一个数据对象,给这个数据对象打了个标签;linux 源码里首个被创建的标签是给首个树对象打的。

远程引用

如果我们添加了某个 remote 并且 push 过,那么远程引用会记录了最近一次 push 时每个分支对应的值,并保存在 refs/remotes 目录下。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 假如推送到某一个远程仓库的某一个分支
$ 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

# 查看远程引用,可以发现 SHA-1 值和上面是一样的
$ cat .git/refs/remotes/origin/master
ca82a6dff817ec66f44342007202690a93763949

远程引用 refs/remotes 和分支 refs/heads 的区别在于前者是只读的。虽然可以 git checkout 到某个远程引用,但 git 并不会将 HEAD 指向它,因此永远不能通过 git commit 来更新远程引用,它只是作为记录远程各分支最后已知位置状态的书签。

包文件

到目前为止,我们仓库里共有 11 个对象:4 个 blob、3 个 tree、3 个 commit、1 个 tag,共占用 925 bytes。

1
2
3
4
5
6
7
8
9
10
11
12
$ 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 的存储策略有哪些缺点,以及 git 自身有哪些解决办法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# ---------- 版本 1 ----------
$ curl https://raw.githubusercontent.com/mojombo/grit/master/lib/grit/repo.rb > repo.rb
$ 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%)

# 对这个大文件,生成了一个 blob
$ git cat-file -p master^{tree}
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5 repo.rb
100644 blob e3f094f522629ae358806b17daf78246c27c007b test.txt

# 查看这个 blob 大小
$ git cat-file -s 033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5
22044

# ---------- 版本 2 ----------
# 简单修改
$ echo '# testing' >> repo.rb
$ git commit -am 'modified repo.rb a bit'
[master 2431da6] modified repo.rb a bit
1 file changed, 1 insertion(+)

# 对这个大文件,又生成了一个 blob,即使只是加了一行注释
$ git cat-file -p master^{tree}
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob b042a60ef7dff760008df33cee372b945b6e884e repo.rb
100644 blob e3f094f522629ae358806b17daf78246c27c007b test.txt

# 查看这个 blob 大小
$ git cat-file -s b042a60ef7dff760008df33cee372b945b6e884e
22054

现在磁盘上有两个几乎一模一样的 22K 大小文件(实际上压缩到大约 7K),如果只保存其中一个再加上差异内容,岂不是更好?

实际上 git 虽然是以松散模式存储对象,但它也会不时地将多个对象打包成叫做包文件(packfile)的二进制文件,以节省空间和提高效率。比如当仓库里有太多松散对象,或者要向服务器推送时,都会进行打包过程,当然,我们也可以随时手动执行命令来打包。

打包: git gc

1
2
3
4
5
6
7
# 打包
$ 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 目录看看:

1
2
3
4
5
6
$ find .git/objects -type f
.git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37 # 之前的 "what is up, doc?"
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 之前的 "test content"
.git/objects/info/packs
.git/objects/pack/pack-978e03944f5c581011e6998cd0e9e30000905586.idx
.git/objects/pack/pack-978e03944f5c581011e6998cd0e9e30000905586.pack

这里我们可以发现,大部分对象不见了,剩下两个没被添加到任何提交记录里的、悬空(dangling)的数据对象,同时新增了一对新文件:索引 .idx,记录包文件的偏移信息,可以用来快速定位想要的对象;包文件 .pack,包含刚从文件系统移除的所有对象的内容。现在,磁盘占用从之前的 15k 变成了 7k,gc 是怎么做到的呢?我们不妨看看这个 .pack 文件:

查看打包文件:git verify-pack

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
$ 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
# b042a6 就是 repo.rb 文件的第二个版本
# b042a6 占用 22k 空间
b042a60ef7dff760008df33cee372b945b6e884e blob 22054 5799 1463
# 033b44 就是 repo.rb 文件的第一个版本,注意这里引用了数据对象 b042a6
# 033b44 占用 9 bytes 空间
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

值得注意的是,第二个版本保存了文件完整内容,而原始版本反而是以差异方式保存的——因为大部分情况下我们需要快速访问的是新版本而非原始版本呀!

引用规范

引用规范(refspec),指的是引用的映射关系。之前有写远程和本地引用的简单映射方式,不过其实还有更加复杂的映射,取决于想达到什么样的效果。

引用规范的格式为:一个可选的 + 号,以及紧随其后的 <src>:<dst>+ 表示即使不能快进的情况下也要强制更新引用;src 是一个 pattern,表示远程版本库中的引用;dst 表示本地跟踪的远程引用的位置。

比如当我们想添加一个远程仓库,这个时候就会在 .git/config 添加一节:

1
2
3
4
5
6
7
8
9
10
11
12
$ git remote add origin https://github.com/schacon/simplegit-progit

# .git/config 会新增这些
# 表示 git 获取服务器中 refs/heads/ 下所有引用,将其写入本地的 refs/remotes/origin/
[remote "origin"]
url = https://github.com/schacon/simplegit-progit
fetch = +refs/heads/*:refs/remotes/origin/*

# 下面的三个命令都是等效的,因为都会被拓展成 refs/remotes/origin/master
$ git log origin/master
$ git log remotes/origin/master
$ git log refs/remotes/origin/master

引用规范可以存到 .git/config 中作为默认选项,也可以在命令末尾使用来只执行一次:

1
2
3
4
5
6
7
# 将远程的 master 分支拉到本地的 origin/mymaster

# 永久(将下行写入 .git/config)
fetch = +refs/heads/master:refs/remotes/origin/master

# 一次性
$ git fetch origin master:refs/remotes/origin/mymaster

还可以一次指定多个引用规范,这样就会只拉取指定的分支,不管其它的:

1
2
3
4
5
6
7
8
9
10
11
12
# 永久(将下行写入 .git/config)
[remote "origin"]
url = https://github.com/schacon/simplegit-progit
fetch = +refs/heads/master:refs/remotes/origin/master
fetch = +refs/heads/experiment:refs/remotes/origin/experiment

# 一次性
$ git fetch origin master:refs/remotes/origin/mymaster \
topic:refs/remotes/origin/topic
From git@github.com:schacon/simplegit
! [rejected] master -> origin/mymaster (non fast forward)
* [new branch] topic -> origin/topic

模式中不能使用部分通配符,下面的引用规范是不合法的:

1
fetch = +refs/heads/qa*:refs/remotes/origin/qa*

不过可以通过创建命名空间来达到类似目的。比如有个 QA 团队,我们只关心 master 分支和 QA 团队推送的分支,怎么办呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
# 对于 QA 团队方,设置引用规范
# 这样,当 QA 团队推送 whatever 分支时,就会推到远程服务器的 qa/whatever 上
[remote "origin"]
url = https://github.com/schacon/simplegit-progit
fetch = +refs/heads/*:refs/remotes/origin/*
push = refs/heads/*:refs/heads/qa/*

# 对于我们,设置引用规范
# 这样,我们就可以只关心 master 和 qa 团队的分支了
[remote "origin"]
url = https://github.com/schacon/simplegit-progit
fetch = +refs/heads/master:refs/remotes/origin/master
fetch = +refs/heads/qa/*:refs/remotes/origin/qa/*

根据引用规范的格式(src:dst),我们也很容易想到删除引用的方法:

1
2
3
4
$ git push origin :topic

# 不过其实还有个新语法啦
$ git push origin --delete topic

* 后记

写的时候感觉:哇,我都懂了,好有条理!
读的时候感觉:叙述不够行云流水,这篇讲得不好啊…
而且还发现我目前使用的版本的 hexo-next-theme 所依赖的 highlight.js 并没有给 shell 代码块高亮,好丑。
在搜索解决方案过程中还在 某个 issue 里发现了高中同学的评论,神奇。

不过我们还是暂时按兵不动,先这样吧QAQ