9 Git 内部原理
- 底层命令 (Plumbing) 和高层命令 (Porcelain)
- 9.2 Git 对象
- 9.3 Git References
- 9.4 Packfiles
- 9.5 The Refspec
- 9.6 传输协议
- 9.7 维护及数据恢复
- 9.8 总结
9.1 底层命令 (Plumbing) 和高层命令 (Porcelain)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sebUrxFu-1586850656647)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200414101854974.png)]
config
文件包含了项目特有的配置选项,info
目录保存了一份不希望在 .gitignore 文件中管理的忽略模式 (ignored patterns) 的全局可执行文件。hooks
目录保存了第七章详细介绍了的客户端或服务端钩子脚本。
另外还有四个重要的文件或目录:HEAD
及 index
文件,objects
及 refs
目录。这些是 Git 的核心部分。objects
目录存储所有数据内容,refs
目录存储指向数据 (分支) 的提交对象的指针,HEAD
文件指向当前分支,index
文件保存了暂存区域信息。马上你将详细了解 Git 是如何操纵这些内容的。
9.2 Git 对象
Git 是一套内容寻址文件系统。很不错。不过这是什么意思呢? 这种说法的意思是,Git 从核心上来看不过是简单地存储键值对(key-value)。它允许插入任意类型的内容,并会返回一个键值,通过该键值可以在任何时候再取出该内容。可以通过底层命令 hash-object
来示范这点,传一些数据给该命令,它会将数据保存在 .git
目录并返回表示这些数据的键值。首先初使化一个 Git 仓库并确认 objects
目录是空的:
Git 初始化了 objects
目录,同时在该目录下创建了 pack
和 info
子目录,但是该目录下没有其他常规文件。我们往这个 Git 数据库里存储一些文本:
$ echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5HxyG8bn-1586850656649)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200414110840101.png)]
参数 -w
指示 hash-object
命令存储 (数据) 对象,若不指定这个参数该命令仅仅返回键值。--stdin
指定从标准输入设备 (stdin) 来读取内容,若不指定这个参数则需指定一个要存储的文件的路径。该命令输出长度为 40 个字符的校验和。这是个 SHA-1 哈希值──其值为要存储的数据加上你马上会了解到的一种头信息的校验和。现在可以查看到 Git 已经存储了数据:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5MB1ZpaI-1586850656650)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200414111010854.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bh37jlcA-1586850656651)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200414111026790.png)]
可以在 objects
目录下看到一个文件。这便是 Git 存储数据内容的方式──为每份内容生成一个文件,取得该内容与头信息的 SHA-1 校验和,创建以该校验和前两个字符为名称的子目录,并以 (校验和) 剩下 38 个字符为文件命名 (保存至子目录下)。
通过 cat-file
命令可以将数据内容取回。该命令是查看 Git 对象的瑞士军刀。传入 -p
参数可以让该命令输出数据内容的类型:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4fephll9-1586850656652)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200414111239539.png)]
可以往 Git 中添加更多内容并取回了。也可以直接添加文件。比方说可以对一个文件进行简单的版本控制。首先,创建一个新文件,并把文件内容存储到数据库中:
$ echo 'version 1' > test.txt
$ git hash-object -w test.txt
接着往该文件中写入一些新内容并再次保存:
$ echo 'version 2' > test.txt
$ git hash-object -w test.txt
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lVEmaiv9-1586850656652)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200414111449974.png)]
数据库中已经将文件的两个新版本连同一开始的内容保存下来了:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gTnK0GXZ-1586850656653)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200414112211505.png)]
再将文件恢复到第一个版本:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qpjjXsjo-1586850656654)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200414112325190.png)]
或恢复到第二个版本
需要记住的是几个版本的文件 SHA-1 值可能与实际的值不同,其次,存储的并不是文件名而仅仅是文件内容。这种对象类型称为 blob 。通过传递 SHA-1 值给 cat-file -t
命令可以让 Git 返回任何对象的类型:
$ git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
blob
tree (树) 对象
接下去来看 tree 对象,tree 对象可以存储文件名,同时也允许存储一组文件。Git 以一种类似 UNIX 文件系统但更简单的方式来存储内容。所有内容以 tree 或 blob 对象存储,其中 tree 对象对应于 UNIX 中的目录,blob 对象则大致对应于 inodes 或文件内容。一个单独的 tree 对象包含一条或多条 tree 记录,每一条记录含有一个指向 blob 或子 tree 对象的 SHA-1 指针,并附有该对象的权限模式 (mode)、类型和文件名信息。以 simplegit 项目为例,最新的 tree 可能是这个样子:
从概念上来讲,Git 保存的数据如图 9-1 所示。
你可以自己创建 tree 。通常 Git 根据你的暂存区域或 index 来创建并写入一个 tree 。因此要创建一个 tree 对象的话首先要通过将一些文件暂存从而创建一个 index 。可以使用 plumbing 命令 update-index
为一个单独文件 ── test.txt 文件的第一个版本 ── 创建一个 index 。通过该命令人为的将 test.txt 文件的首个版本加入到了一个新的暂存区域中。由于该文件原先并不在暂存区域中 (甚至就连暂存区域也还没被创建出来呢) ,必须传入 --add
参数;由于要添加的文件并不在当前目录下而是在数据库中,必须传入 --cacheinfo
参数。同时指定了文件模式,SHA-1 值和文件名:
在本例中,指定了文件模式为 100644
,表明这是一个普通文件。其他可用的模式有:100755
表示可执行文件,120000
表示符号链接。文件模式是从常规的 UNIX 文件模式中参考来的,但是没有那么灵活 ── 上述三种模式仅对 Git 中的文件 (blobs) 有效 (虽然也有其他模式用于目录和子模块)。
现在可以用 write-tree
命令将暂存区域的内容写到一个 tree 对象了。无需 -w
参数 ── 如果目标 tree 不存在,调用 write-tree
会自动根据 index 状态创建一个 tree 对象。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nTFIOXVb-1586850656655)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200414113352245.png)]
再根据 test.txt 的第二个版本以及一个新文件创建一个新 tree 对象:
$ echo 'new file' > new.txt
$ git update-index test.txt
$ git update-index --add new.txt
这时暂存区域中包含了 test.txt 的新版本及一个新文件 new.txt 。创建 (写) 该 tree 对象 (将暂存区域或 index 状态写入到一个 tree 对象),然后瞧瞧它的样子:
$ git write-tree
0155eb4229851634a0f03eb265b69f5a2d56f341
$ git cat-file -p 0155eb4229851634a0f03eb265b69f5a2d56f341
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-odRbAWjj-1586850656656)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200414113739018.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-w8DEqsd2-1586850656657)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200414113755579.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bpzU7LVT-1586850656657)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200414113810433.png)]
请注意该 tree 对象包含了两个文件记录,且 test.txt 的 SHA 值是早先值的 “第二版” (1f7a7a
)。来点更有趣的,你将把第一个 tree 对象作为一个子目录加进该 tree 中。可以用 read-tree
命令将 tree 对象读到暂存区域中去。在这时,通过传一个 --prefix
参数给 read-tree
,将一个已有的 tree 对象作为一个子 tree 读到暂存区域中:
$ 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
如果从刚写入的新 tree 对象创建一个工作目录,将得到位于工作目录顶级的两个文件和一个名为 bak
的子目录,该子目录包含了 test.txt 文件的第一个版本。可以将 Git 用来包含这些内容的数据想象成如图 9-2 所示的样子。
commit (提交) 对象
你现在有三个 tree 对象,它们指向了你要跟踪的项目的不同快照,可是先前的问题依然存在:必须记往三个 SHA-1 值以获得这些快照。你也没有关于谁、何时以及为何保存了这些快照的信息。commit 对象为你保存了这些基本信息。
要创建一个 commit 对象,使用 commit-tree
命令,指定一个 tree 的 SHA-1,如果有任何前继提交对象,也可以指定。从你写的第一个 tree 开始:
C:\Users\Administrator\Documents\GitHub\playground\test>echo 'first commit' | git commit-tree 2ad71179c01caad8793a4d0eabc2d851b9050269
965eb252160c7f8579af6a7b263031bce3a9e5d4
通过 cat-file
查看这个新 commit 对象:
C:\Users\Administrator\Documents\GitHub\playground\test>git cat-file -p 965e
tree 2ad71179c01caad8793a4d0eabc2d851b9050269
author Harrish He <harrish@wicresoft.com> 1586841657 +0800
committer Harrish He <harrish@wicresoft.com> 1586841657 +0800
'first commit'
commit 对象有格式很简单:指明了该时间点项目快照的顶层树对象、作者/提交者信息(从 Git 设置的 user.name
和 user.email
中获得)以及当前时间戳、一个空行,以及提交注释信息。
接着再写入另外两个 commit 对象,每一个都指定其之前的那个 commit 对象:
每一个 commit 对象都指向了你创建的树对象快照。出乎意料的是,现在已经有了真实的 Git 历史了,所以如果运行 git log
命令并指定最后那个 commit 对象的 SHA-1 便可以查看历史:
C:\Users\Administrator\Documents\GitHub\playground\test>git log --stat 678b3
commit 678b391244ff71197797c7edd9aa5c0de736eee5
Author: Harrish He <harrish@wicresoft.com>
Date: Tue Apr 14 13:59:43 2020 +0800
'third commit'
bak/test.txt | 1 +
1 file changed, 1 insertion(+)
commit 934336b905b6f408ffbc742f77b1aa262aa3585e
Author: Harrish He <harrish@wicresoft.com>
Date: Tue Apr 14 13:57:59 2020 +0800
'second commit'
new.txt | 1 +
test.txt | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
commit 7e5b4203c5995d277777d26ef62f7f5aa21c4586
Author: Harrish He <harrish@wicresoft.com>
Date: Tue Apr 14 13:54:48 2020 +0800
'first commit'
test.txt | 1 +
1 file changed, 1 insertion(+)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YyiSxQiU-1586850656659)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200414140330132.png)]
这基本上就是运行 git add
和 git commit
命令时 Git 进行的工作 ──保存修改了的文件的 blob,更新索引,创建 tree 对象,最后创建 commit 对象
对象存储
之前我提到当存储数据内容时,同时会有一个文件头被存储起来。我们花些时间来看看 Git 是如何存储对象的。你将看来如何通过 Ruby 脚本语言存储一个 blob 对象 (这里以字符串 “what is up, doc?” 为例) 。使用 irb
命令进入 Ruby 交互式模式:
9.3 Git References
你可以执行像 git log 1a410e
这样的命令来查看完整的历史,但是这样你就要记得 1a410e
是你最后一次提交,这样才能在提交历史中找到这些对象。你需要一个文件来用一个简单的名字来记录这些 SHA-1 值,这样你就可以用这些指针而不是原来的 SHA-1 值去检索了。
每当你执行 git branch (分支名称)
这样的命令,Git 基本上就是执行 update-ref
命令,把你现在所在分支中最后一次提交的 SHA-1 值,添加到你要创建的分支的引用。
HEAD 标记
现在的问题是,当你执行 git branch (分支名称)
这条命令的时候,Git 怎么知道最后一次提交的 SHA-1 值呢?答案就是 HEAD 文件。HEAD 文件是一个指向你当前所在分支的引用标识符。这样的引用标识符——它看起来并不像一个普通的引用——其实并不包含 SHA-1 值,而是一个指向另外一个引用的指针。如果你看一下这个文件,通常你将会看到这样的内容:
当你创建一个新的分支时:
.git/HEAD 文件的路径发生变化,并且 .git/refs 目录下出现一个新的文件记录master分支上最近一次提交的SHA值。
Tags
你刚刚已经重温过了 Git 的三个主要对象类型,现在这是第四种。Tag 对象非常像一个 commit 对象——包含一个标签,一组数据,一个消息和一个指针。最主要的区别就是 Tag 对象指向一个 commit 而不是一个 tree。它就像是一个分支引用,但是不会变化——永远指向同一个 commit,仅仅是提供一个更加友好的名字。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-c1Ilpsfx-1586850656660)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200414151316884.png)]
Remotes
你将会看到的第四种 reference 是 remote reference
9.4 Packfiles
事实上 Git 可以那样做。Git 往磁盘保存对象时默认使用的格式叫松散对象 (loose object) 格式。Git 时不时地将这些对象打包至一个叫 packfile 的二进制文件以节省空间并提高效率。当仓库中有太多的松散对象,或是手工调用 git gc
命令,或推送至远程服务器时,Git 都会这样做。
9.5 The Refspec
推送 Refspec
采用命名空间的方式确实很棒,但QA组成员第1次是如何将他们的分支推送到 qa/
空间里面的呢?答案是你可以使用 refspec 来推送。
如果QA组成员想把他们的 master
分支推送到远程的 qa/master
分支上,可以这样运行:
$ git push origin master:refs/heads/qa/master
如果他们想让 Git 每次运行 git push origin
时都这样自动推送,他们可以在配置文件中添加 push
值:
[remote "origin"]
url = git@github.com:schacon/simplegit-progit.git
fetch = +refs/heads/*:refs/remotes/origin/*
push = refs/heads/master:refs/heads/qa/master
这样,就会让 git push origin
缺省就把本地的 master
分支推送到远程的 qa/master
分支上。
删除引用
你也可以使用 refspec 来删除远程的引用,是通过运行这样的命令:
$ git push origin :topic
因为 refspec 的格式是 :
, 通过把 `` 部分留空的方式,这个意思是是把远程的 topic
分支变成空,也就是删除它。
9.6 传输协议
Git 可以以两种主要的方式跨越两个仓库传输数据:基于HTTP协议之上,和 file://
, ssh://
, 和 git://
等智能传输协议。这一节带你快速浏览这两种主要的协议操作过程。
哑协议
Git 基于HTTP之上传输通常被称为哑协议,这是因为它在服务端不需要有针对 Git 特有的代码。这个获取过程仅仅是一系列GET请求,客户端可以假定服务端的Git仓库中的布局。
智能协议
这个HTTP方法是很简单但效率不是很高。使用智能协议是传送数据的更常用的方法。这些协议在远端都有Git智能型进程在服务 - 它可以读出本地数据并计算出客户端所需要的,并生成合适的数据给它,这有两类传输数据的进程:一对用于上传数据和一对用于下载。
上传数据
为了上传数据至远端, Git 使用 send-pack
和 receive-pack
进程。这个 send-pack
进程运行在客户端上,它连接至远端运行的 receive-pack
进程。
下载数据
当你在下载数据时, fetch-pack
和 upload-pack
进程就起作用了。客户端启动 fetch-pack
进程,连接至远端的 upload-pack
进程,以协商后续数据传输过程。
9.7 维护及数据恢复
你时不时的需要进行一些清理工作 ── 如减小一个仓库的大小,清理导入的库,或是恢复丢失的数据。本节将描述这类使用场景。
维护
Git 会不定时地自动运行称为 “auto gc” 的命令。大部分情况下该命令什么都不处理。不过要是存在太多松散对象 (loose object, 不在 packfile 中的对象) 或 packfile,Git 会进行调用 git gc
命令。 gc
指垃圾收集 (garbage collect),此命令会做很多工作:收集所有松散对象并将它们存入 packfile,合并这些 packfile 进一个大的 packfile,然后将不被任何 commit 引用并且已存在一段时间 (数月) 的对象删除。
可以手工运行 auto gc 命令:
$ git gc --auto
数据恢复
在使用 Git 的过程中,有时会不小心丢失 commit 信息。这一般出现在以下情况下:强制删除了一个分支而后又想重新使用这个分支,hard-reset 了一个分支从而丢弃了分支的部分 commit。如果这真的发生了,有什么办法把丢失的 commit 找回来呢?
下面的示例演示了对 test 仓库主分支进行 hard-reset 到一个老版本的 commit 的操作,然后恢复丢失的 commit 。首先查看一下当前的仓库状态:
$ git log --pretty=oneline
ab1afef80fac8e34258ff41fc1b867c702daa24b modified repo a bit
484a59275031909e19aadb7c92262719cfcdf19a added repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit
接着将 master
分支移回至中间的一个 commit:
$ git reset --hard 1a410efbd13591db07496601ebc7a059dd55cfe9
HEAD is now at 1a410ef third commit
$ git log --pretty=oneline
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit
这样就丢弃了最新的两个 commit ── 包含这两个 commit 的分支不存在了。
移除对象
对仓库进行 gc
操作,并查看占用了空间:
$ git gc
Counting objects: 21, done.
Delta compression using 2 threads.
Compressing objects: 100% (16/16), done.
Writing objects: 100% (21/21), done.
Total 21 (delta 3), reused 15 (delta 1)
可以运行 count-objects
以查看使用了多少空间:
$ git count-objects -v
count: 4
size: 16
in-pack: 21
packs: 1
size-pack: 2016
prune-packable: 0
garbage: 0
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZxwdVb9u-1586850656661)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200414154923333.png)]
size-pack
是以千字节为单位表示的 packfiles 的大小,因此已经使用了 2MB 。而在这次提交之前仅用了 2K 左右 ── 显然在这次提交时删除文件并没有真正将其从历史记录中删除。
sed 15 (delta 1)
可以运行 `count-objects` 以查看使用了多少空间:
$ git count-objects -v
count: 4
size: 16
in-pack: 21
packs: 1
size-pack: 2016
prune-packable: 0
garbage: 0
[外链图片转存中...(img-ZxwdVb9u-1586850656661)]
`size-pack` 是以千字节为单位表示的 packfiles 的大小,因此已经使用了 2MB 。而在这次提交之前仅用了 2K 左右 ── 显然在这次提交时删除文件并没有真正将其从历史记录中删除。
运行另一个底层命令 `git verify-pack` 以识别出大对象,对输出的第三列信息即文件大小进行排序,还可以将输出定向到 `tail` 命令,因为你只关心排在最后的那几个最大的文件: