原文地址 https://zhaohuabing.com/post/2019-01-21-git/

TOC

Git 是程序员工作中使用频率非常高的工具,要提高日常的工作效率,就需要熟练掌握 Git 的使用方法。相对于传统的版本控制系统而言,Git 更为强大和灵活,其各种命令和命令参数也非常多,如果不了解 Git 的内部原理,要把 Git 使用得顺手的话非常困难。本文将用一个具体的例子来帮助理解 Git 的内部存储原理, 加深对 Git 的理解,从掌握各种 Git 命令,以在使用 Git 进行工作时得心应手。

Git 的本质是一个文件系统,其工作目录中的所有文件的历史版本以及提交记录 (Commit) 都是以文件对象的方式保存在. git 目录中的。

首先创建一个 work 目录,并采用 git init 命令初始化 git 仓库。该命令会在工作目录下生成一个. git 目录,该目录将用于保存工作区中所有的文件历史的历史版本,提交记录,branch,tag 等信息。

1
2
3
$ mkdir work
$ cd work
$ git init

其目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
├── branches             不这么重要,暂不用管                    
├── config git配置信息,包括用户名,email,remote repository的地址,本地branch和remote
| branch的follow关系
├── description 该git库的描述信息,如果使用了GitWeb的话,该描述信息将会被显示在该repo的页面上
├── HEAD 工作目录当前状态对应的commit,一般来说是当前branch的head,HEAD也可以通过git checkout 命令被直接设置到一个特定的commit上,这种情况被称之为 detached HEAD
├── hooks 钩子程序,可以被用于在执行git命令时自动执行一些特定操作,例如加入changeid
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── prepare-commit-msg.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   └── update.sample
├── info 不这么重要,暂不用管
│   └── exclude
├── objects 保存git对象的目录,包括三类对象commit,tag, tree和blob
│   ├── info
│   └── pack
└── refs 保存branch和tag对应的commit
├── heads branch对应的commit
└── tags tag对应的commit

Git Object 存储方式

目前 objects 目录中还没有任何内容,我们创建一个文件并提交。

1
2
3
4
5
6
7
8
$ echo "my project" > README
$ echo "hello world" > src/file1.txt
$ git add .
$ git commit -sm "init commit"
[master (root-commit) b767d71] init commit
2 files changed, 2 insertions(+)
create mode 100644 README
create mode 100644 src/file1.txt

从打印输出可以看到,上面的命令创建了一个 commit 对象,该 commit 包含两个文件。 查看. git/objects 目录,可以看到该目录下增加了 5 个子目录 06,3b, 82, b7, ca,每个子目录下有一个以一长串字母数字命令的文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
.git/objects
├── 06
│   └── 5bcad11008c5e958ff743f2445551e05561f59
├── 3b
│   └── 18e512dba79e4c8300dd08aeb37f8e728b8dad
├── 82
│   └── 424451ac502bd69712561a524e2d97fd932c69
├── b7
│   └── 67d7115ef57666c9d279c7acc955f86f298a8d
├── ca
│   └── 964f37599d41e285d1a71d11495ddc486b6c3b
├── info
└── pack

说明:Git Object 目录中存储了三种对象:Commit, tree 和 blob。Git 为对象生成一个文件,并根据文件信息生成一个 SHA-1 哈希值作为文件内容的校验和,创建以该校验和前两个字符为名称的子目录,并以 (校验和) 剩下 38 个字符为文件命名 ,将该文件保存至子目录下。

查看 Git Object 存储内容

通过 git cat-file命令可以查看 Git Object 中存储的内容及对象类型,命令参数为 Git Object 的 SHA-1 哈希值,即目录名 + 文件名。在没有歧义的情况下,不用输入整个 Hash,输入前几位即可。

当前分支的对象引用保存在 HEAD 文件中,可以查看该文件得到当前 HEAD 对应的 branch,并通过 branch 查到对应的 commit 对象。

1
2
3
4
$ cat .git/HEAD
ref: refs/heads/master
cat .git/refs/heads/master
b767d7115ef57666c9d279c7acc955f86f298a8d

使用 -t 参数查看文件类型:

1
2
$ git cat-file -t b767d7
commit

使用 -p 参数可以查看文件内容:

1
2
3
4
5
6
7
8
$ git cat-file -p b767d7
tree ca964f37599d41e285d1a71d11495ddc486b6c3b
author Huabing Zhao <zhaohuabing@gmail.com> 1548055516 +0800
committer Huabing Zhao <zhaohuabing@gmail.com> 1548055516 +0800

init commit

Signed-off-by: Huabing Zhao <zhaohuabing@gmail.com>

可以看出这是一个 commit 对象,commit 对象中保存了 commit 的作者,commit 的描述信息,签名信息以及该 commit 中包含哪些 tree 对象和 blob 对象。

b767d7 这个 commit 中保存了一个 tree 对象,可以把该 tree 对象看成这次提交相关的所有文件的根目录。让我们来看看该 tree 对象中的内容。

1
2
3
$ git cat-file -p ca964f
100644 blob 065bcad11008c5e958ff743f2445551e05561f59 README
040000 tree 82424451ac502bd69712561a524e2d97fd932c69 src

可以看到该 tree 对象中包含了一个 blob 对象,即 README 文件;和一个 tree 对象,即 src 目录。 分别查看该 blob 对象和 tree 对象,其内容如下:

1
2
3
4
$ git cat-file -p 065bca
my project
$ git cat-file -p 824244
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad file1.txt

查看 file1.txt 的内容。

1
2
$ git cat-file -p 3b18e51
hello world

从上面的实验我们可以得知,git 中存储了三种类型的对象,commit,tree 和 blob。分别对应 git commit,此 commit 中的目录和文件。这些对象之间的关系如下图所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
HEAD---> refs/heads/master--> b767d7(commit)
+
|
v
ca964f(tree)
+
|
+---------+----------+
| |
v v
065bca(blob) 824244(tree)
README src
+
|
v
3b18e5(blob)
file1.txt

Git branch 和 tag

从 refs/heads/master 的内容可以看到,branch 是一个指向 commit 的指针,master branch 实际是指向了 b767d7 这个 commit。

1
2
3
4
5
6
7
8
9
10
11
$ git checkout -b work
Switched to a new branch 'work'
$ tree .git/refs/
.git/refs/
├── heads
│   ├── master
│   └── work
└── tags
$ cat .git/refs/heads/work .git/refs/heads/master
b767d7115ef57666c9d279c7acc955f86f298a8d
b767d7115ef57666c9d279c7acc955f86f298a8d

上面的命令创建了一个 work branch。从其内容可以看到,该 branch 并没有创建任何新的版本文件,和 master 一样指向了 b767d7 这个 commit。

从上面的实验可以看出,一个 branch 其实只是一个 commit 对象的应用,Git 并不会为每个 branch 存储一份拷贝,因此在 git 中创建 branch 几乎没有任何代价。

在 work branch 上进行一些修改,然后提交。

1
2
3
4
5
6
$ echo "new line" >> src/file1.txt
$ echo "do nothing" >> Makefile
$ git commit -sm "some change"
[work 4f73993] some change
2 files changed, 2 insertions(+)
create mode 100644 Makefile

查看当前的 HEAD 和 branch 内容。

1
2
3
4
5
$ cat .git/HEAD
ref: refs/heads/work
huabing@huabing-xubuntu:~/work$ cat .git/refs/heads/work .git/refs/heads/master
4f73993cf81931bc15375f0a23d82c40b3ae6789
b767d7115ef57666c9d279c7acc955f86f298a8d

可以看到 HEAD 指向了 work branch, 而 work branch 则指向了 4f73993 这个 commit,master branch 指向的 commit 未变化,还是 b767d7。

查看 4f73993 这个 commit 对象的内容。

1
2
3
4
5
6
7
8
9
$ git cat-file -p 4f73993
tree 082b6d87eeddb15526b7c920e21f09f950f78b54
parent b767d7115ef57666c9d279c7acc955f86f298a8d
author Huabing Zhao <zhaohuabing@gmail.com> 1548069325 +0800
committer Huabing Zhao <zhaohuabing@gmail.com> 1548069325 +0800

some change

Signed-off-by: Huabing Zhao <zhaohuabing@gmail.com>

可以看到 commit 有一个 parent 字段,指向了前一个 commi b767d7。该 commit 也包含了一个 tree 对象,让我们看看其中的内容。

1
2
3
4
5
6
7
$  git cat-file -p  082b6d
100644 blob 8cc95f278445722c59d08bbd798fbaf60da8ca14 Makefile
100644 blob 065bcad11008c5e958ff743f2445551e05561f59 README
040000 tree 9aeacd1fa832ca167b0f72fb1d0c744a9ee1902f src

$ git cat-file -p 9aeacd
100644 blob 79ee69e841a5fd382faef2be2f2eb6e836cc980a file1.txt

可以看到该 tree 对象中包含了该版本的所有文件和目录,由于 README 没有变化,还是指向的 065bca 这个 blob 对象。Makefile 是一个新建的 blob 对象,src 和 file1.txt 则指向了新版本的对象。

增加了这次 commit 后,git 中各个对象的关系如下图所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(parent)
HEAD--> refs/heads/work--> 4f7399(commit) +-------> b767d7(commit)<---refs/heads/master
+ +
| |
v v
082b6d(tree) ca964f(tree)
+ +
| |
+-----------------------------+ +--------+-----------+
| | | | |
v v v v v
9aeacd(tree) 8cc95f(blob) 065bca(blob) 824244(tree)
src (version 2) Makefile README src (version 1)
+ +
| |
v v
79ee69(blob) 3b18e5(blob)
file1.txt (version 2) file1.txt (version 1)

从上图可以看到,Git 会为每次 commit 时修改的目录 / 文件生成一个新的版本的 tree/blob 对象,如果文件没有修改,则会指向老版本的 tree/blob 对象。而 branch 则只是指向某一个 commit 的一个指针。即 Git 中整个工作目录的 version 是以 commit 对象的形式存在的,可以认为一个 commit 就是一个 version,而不同 version 可以指向相同或者不同的 tree 和 blob 对象,对应到不同版本的子目录和文件。如果某一个子目录 / 文件在版本间没有变化,则不会为该子目录 / 文件生成新的 tree/blob 对象,不同 version 的 commit 对象会指向同一个 tree/object 对象。

Tag 和 branch 类似,也是指向某个 commit 的指针。不同的是 tag 创建后其指向的 commit 不能变化,而 branch 创建后,其指针会在提交新的 commit 后向前移动。

1
2
3
4
$ git tag v1.0
$ cat .git/refs/tags/v1.0 .git/refs/heads/work
4f73993cf81931bc15375f0a23d82c40b3ae6789
4f73993cf81931bc15375f0a23d82c40b3ae6789

可以看到新创建的 v1.0 tag 和 work branch 都是指向了 4f7399 这个 commit。

Git Stash 实现原理

Git stash 的功能说明:经常有这样的事情发生,当你正在进行项目中某一部分的工作,里面的东西处于一个比较杂乱的状态,而你想转到其他分支上进行一些工作。问题是,你不想提交进行了一半的工作,否则以后你无法回到这个工作点。解决这个问题的办法就是 git stash 命令。

“‘储藏”“可以获取你工作目录的中间状态——也就是你修改过的被追踪的文件和暂存的变更——并将它保存到一个未完结变更的堆栈中,随时可以重新应用。

Git 是如何实现 Stash 的呢?理解了 Commit, Tree, Blog 这三种 Git 存储对象,我们就可以很容易理解 Git Stash 的实现原理。因为和 bransh 及 tag 类似,Git Stash 其实也是通过 Commit 来实现的。

通过实验来测试一下:

1
2
$ echo "another line" >> src/file1.txt
$ git stash

通过上面的命令,我们在 file1.txt 中增加了一行,然后通过 git stash 命令将这些改动 “暂存” 在了一个 “堆栈” 中,让我们来看看. 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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
$ tree .git/
.git/
├── branches
├── COMMIT_EDITMSG
├── config
├── description
├── HEAD
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── prepare-commit-msg.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   └── update.sample
├── index
├── info
│   └── exclude
├── logs
│   ├── HEAD
│   └── refs
│   ├── heads
│   │   ├── master
│   │   └── work
│   └── stash
├── objects
│   ├── 06
│   │   └── 5bcad11008c5e958ff743f2445551e05561f59
│   ├── 08
│   │   └── 2b6d87eeddb15526b7c920e21f09f950f78b54
│   ├── 11
│   │   └── a6d1031e4fa2d4da0b6303dd74ed8e85c54057
│   ├── 33
│   │   └── f98923002cd224dabf32222c808611badd6d48
│   ├── 3b
│   │   └── 18e512dba79e4c8300dd08aeb37f8e728b8dad
│   ├── 4f
│   │   └── 73993cf81931bc15375f0a23d82c40b3ae6789
│   ├── 6a
│   │   ├── 1474c4da0653af0245970997b6fab0a0a7c1df
│   │   └── d88760c3be94d8cb582bf2d06b99083d034428
│   ├── 75
│   │   └── e170cc1d928ae5a28547b4a3f2f3394a675b9a
│   ├── 79
│   │   └── ee69e841a5fd382faef2be2f2eb6e836cc980a
│   ├── 82
│   │   └── 424451ac502bd69712561a524e2d97fd932c69
│   ├── 8c
│   │   └── c95f278445722c59d08bbd798fbaf60da8ca14
│   ├── 90
│   │   └── c43dbb1e71c271510994d6b147c425cbffa673
│   ├── 9a
│   │   └── eacd1fa832ca167b0f72fb1d0c744a9ee1902f
│   ├── b7
│   │   └── 67d7115ef57666c9d279c7acc955f86f298a8d
│   ├── ca
│   │   └── 964f37599d41e285d1a71d11495ddc486b6c3b
│   ├── e8
│   │   └── 83e779eb08e2d9bca1fc1ee722fc80addac312
│   ├── info
│   └── pack
├── ORIG_HEAD
└── refs
├── heads
│   ├── master
│   └── work
├── stash
└── tags
└── v1.0

可以看到 objects 目录中增加了一些对象文件,refs 中增加了一个 stash 文件。通过命令查看该文件内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ cat .git/refs/stash
11a6d1031e4fa2d4da0b6303dd74ed8e85c54057
$ git cat-file -p 11a6
tree 90c43dbb1e71c271510994d6b147c425cbffa673
parent 4f73993cf81931bc15375f0a23d82c40b3ae6789
parent 6a1474c4da0653af0245970997b6fab0a0a7c1df
author Huabing Zhao <zhaohuabing@gmail.com> 1548326421 +0800
committer Huabing Zhao <zhaohuabing@gmail.com> 1548326421 +0800

WIP on work: 4f73993 some change
$ git cat-file -p 90c4
100644 blob 8cc95f278445722c59d08bbd798fbaf60da8ca14 Makefile
100644 blob 065bcad11008c5e958ff743f2445551e05561f59 README
040000 tree 33f98923002cd224dabf32222c808611badd6d48 src
$ git cat-file -p 33f9
100644 blob 75e170cc1d928ae5a28547b4a3f2f3394a675b9a file1.txt
$ git cat-file -p 75e1
hello world
new line
another line

从命令行输出可以看到, git stash 实际上创建了一个新的 commit 对象 11a6d1, 该 commit 对象的父节点为 4f7399。commit 对象中包含了修改后的 file1.txt blob 对象 75e170。通过 git log 可以查看:

1
2
3
4
5
6
7
$ git log --oneline --graph stash@{0}
* f566001 WIP on work: 4f73993 some change
|\
| * 0796ced index on work: 4f73993 some change
|/
* 4f73993 some change
* b767d71 init commit

备注:git stash 生成的 commit 对象有两个 parent,一个是前面一次 git commit 命令生成的 commit,另一个对应于保存到 stage 中的 commit。

从该试验可以得知,git stash 也是以 commit,tree 和 object 对象实现的。Git stash 保存到 “堆栈 “ 中的修改其实一个 commit 对象。

Git reset 实现原理

在进行一些改动以后并通过 git commit 将改动的代码提交到本地的 repo 后,如果你测试发现刚才的改动不合理,希望回退刚才的改动,应该如何处理?

我们先提交一个错误的改动:

1
2
3
4
5
$ echo "I did something wrong" >> src/file1.txt
$ git add .
$ git commit -sm "This commit should not be there"
[work ccbc363] This commit should not be there
1 file changed, 1 insertion(+)

你可以通过 git revert 回退刚才的改动,或者修改代码后再次提交,但这样的话你的提交 log 会显得非常凌乱;如果不想把中间过程的 commit push 到远程仓库,可以通过 git reset 回退刚才的改动。

先查看目前的 log

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ git log
commit ccbc3638142191bd68454d47a0f67fd12519806b
Author: Huabing Zhao <zhaohuabing@gmail.com>
Date: Fri Jan 25 12:35:31 2019 +0800

This commit should not be there

Signed-off-by: Huabing Zhao <zhaohuabing@gmail.com>

commit 4f73993cf81931bc15375f0a23d82c40b3ae6789
Author: Huabing Zhao <zhaohuabing@gmail.com>
Date: Mon Jan 21 19:15:25 2019 +0800

some change

Signed-off-by: Huabing Zhao <zhaohuabing@gmail.com>

commit b767d7115ef57666c9d279c7acc955f86f298a8d
Author: Huabing Zhao <zhaohuabing@gmail.com>
Date: Mon Jan 21 15:25:16 2019 +0800

init commit

Signed-off-by: Huabing Zhao <zhaohuabing@gmail.com>

通过 git reset 回退到上一个 commit。注意这里 HEAD 是一个指向当前 branch 最后一个 commit 指针,因此 HEAD~1 表示之前的一个 commit。git reset 命令也可以直接使用 commit 号作为命令参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ git reset HEAD~1
Unstaged changes after reset:
M src/file1.txt

$ git log
commit 4f73993cf81931bc15375f0a23d82c40b3ae6789
Author: Huabing Zhao <zhaohuabing@gmail.com>
Date: Mon Jan 21 19:15:25 2019 +0800

some change

Signed-off-by: Huabing Zhao <zhaohuabing@gmail.com>

commit b767d7115ef57666c9d279c7acc955f86f298a8d
Author: Huabing Zhao <zhaohuabing@gmail.com>
Date: Mon Jan 21 15:25:16 2019 +0800

init commit

Signed-off-by: Huabing Zhao <zhaohuabing@gmail.com>

可以看到刚才的 commit 被回退了,但修改的文件还存在,处于 Unstaged 状态,你可以对这些文件进行改动后再次提交。

如果你不想保留修改的文件,可以使用–hard 参数直接回退到指定的 commit,该参数会将 HEAD 指向该 commit,并且工作区中的文件也会和该 comit 保持一致,该 commit 后的修改会被直接丢弃。

1
2
3
4
5
$ git reset HEAD --hard
HEAD is now at 4f73993 some change
$ git status
On branch work
nothing to commit, working directory clean

Git object 存储方式

Git object 是通过下面的方式处理并存储在 git 内部的文件系统中的:

  1. 首先创建一个 header,header 的值为 “对象类型 内容长度 \ 0”
  2. 将 header 和文件内容连接起来,计算得到其 SHA-1 hash 值
  3. 将连接得到的内容采用 zlib 压缩
  4. 将压缩后的内容写入到以 “hash 值前两位命令的目录 / hash 值后 38 位命令的文件” 中

可以通过 Ruby 手工创建一个 Git object 来验证上面的步骤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ irb
irb(main):001:0> content = "what is up, doc?" //文件内容
=> "what is up, doc?"
irb(main):002:0> header = "blob #{content.length}\0" //创建header
=> "blob 16\u0000"
irb(main):003:0> store = header + content //拼接header和文件内容
=> "blob 16\u0000what is up, doc?"
irb(main):004:0> require 'digest/sha1'
=> true
irb(main):005:0> sha1 = Digest::SHA1.hexdigest(store)
=> "bd9dbf5aae1a3862dd1526723246b20206e5fc37" //计算得到hash值
irb(main):006:0> require 'zlib'
=> true
irb(main):007:0> zlib_content = Zlib::Deflate.deflate(store) //压缩header+文件内容
=> "x\x9CK\xCA\xC9OR04c(\xCFH,Q\xC8,V(-\xD0QH\xC9O\xB6\a\x00_\x1C\a\x9D"
irb(main):008:0> path = '.git/objects/' + sha1[0,2] + '/' + sha1[2,38]
=> ".git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37" //通过hash值计算文件存储路径
irb(main):009:0> require 'fileutils'
=> true
irb(main):010:0> FileUtils.mkdir_p(File.dirname(path)) //写文件
=> [".git/objects/bd"]
irb(main):011:0> File.open(path, 'w') { |f| f.write zlib_content }
=> 32
irb(main):012:0>

文件以及写入到 Git 的内部存储中,我们尝试通过 git cat-file 验证并读取该文件内容:

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

可以看到,可以通过 git cat-file 文件读取该文件内容,因此该文件是一个合法的 git object,和通过 git 命令写入的文件格式相同。

总结

Git 围绕三种 Object 来实现了版本控制以及 Branch,Tag 等机制。

  • Commit: Commit 可以看作 Git 中一个 Version 的所有目录和文件的 Snapshot,可以通过 git checkout 查看任意一个 commit 中的内容。
  • Tree: 目录对象,内部包含目录和文件
  • Blob: 文件对象,对应一个文件

理解了 Git object 的存储机制,就可以理解 Git 的各个命令的实现原理,更好地使用 Git 来实现源代码管理。