git 学习笔记 5
start at 2023/01/07.

SHA-1 的计算方法

SHA-1 (Secure Hash Algorithm 1) 是一种杂凑算法(哈希算法),计算结果会用40个十六进制数字呈现,该算法的特点是输入相同的输入就会有一样的输出结果

因为是哈希的输出固定为40的十六进制数字,所以理论上不同的两个输入其实是有完全相同输出的可能的,这种情况就被称为是碰撞(Collision),但是碰撞的概率非常低,再加上 git 再额外加了点 salt,所以碰撞的概率会更低,所以一般相同的 SHA-1 就是说明输入是相同的

计算公式

在 git 中,不同种类对象的 SHA-1 的计算方法会略微不同,例如,Blob 对象的 SHA-1 组成模式如下:

  1. “blob”
  2. 空白
  3. 输入内容的长度
  4. Null 结束符号
  5. 输入内容

而如果是 Tree 对象,第一项则为 “tree”,如果是 Commit 对象,则会改成 “commit”,以此类推

我们从上面的组成模式中可以看出,Blob 的这几项都没有和时间有关的内容(COmmit 对象和 Tag 对象的内容包括时间),所以以 Blob 对象来说,不管在什么时间或设备上(注意,Blob是不包含文件名的),一样的文本内容都会有一样的文本输出,作为验证可以在自己的终端下尝试

$ echo "hello, 5xRuby"|git hash-object --stdin
30ab28d3acb37f96ad61ad8be82c8da46d0a7307

看看你得到的输出是否和我相同,上面用到的是 git 内置的 hash-object 命令计算,当然你也可以用其他语言的 SHA-1 库对比计算结果是不是一样,但是这时候输入不算直接输入字符串,而是需要改成上面的格式(blob 长度\0输入内容

书上没有讲到 SHA-1 的具体算法,不过这些哈希加密的算法其实为了不碰撞都很复杂玄学,但是我们只需要知道 git 的 SHA-1 就是这样算出来的就好了

下面一小节不想看可以直接跳过或者看小结,但是跟着看一遍可以加深对 git 的理解

探索 .git 目录

想要我的财宝吗?想要的话可以全部给你,去找吧!我把所有的财宝都放在那里了。

--《航海王》哥尔罗杰

对于 git 来说,.git 目录扮演的就是宝藏这样的角色,所有的记录、备份都放在其中了,所以想要真正地学号 git,建议花一些时间来摸清这个目录中到底藏了什么东西,这样就更能理解 git 的运行原理,操作起来更加得心应手

在开始之前,我们要先知道在 git 中有4种很重要的对象,分别是 Blob、Tree、Commit、Tag 对象

接下来让我们通过实际操作 git 命令,详细介绍这些对象的关联性

创建文件

$ echo "hello, 5xRuby" > 1.tmp
# 只有 git add 之后文件才会被 git 管控
$ git add 1.tmp

上面我们已经测试过这句话用 git 的 SHA-1 计算公式算出来的值是 30ab28d3acb37f96ad61ad8be82c8da46d0a7307 ,我们这时候来看 .git/objects

image-20230107155925672

发现这个文件夹的名字的两个字母,加上后面那个文件的38字母的文件名正是我们之前算出来的 SHA-1 值,因为文件的内容已经经过了压缩,所以用一般的文本编辑器是看不出内容的,但是我们可以用 git cat-file 命令来查看

$ git cat-file -t 30ab28d3acb37f96ad61ad8be82c8da46d0a7307
blob

-t 参数表示要查看的 SHA-1 值所代表的对象的形态,根据结果,git 说这个 SHA-1 值对应了一个 blob 对象,如果用 -p 参数

$ git cat-file -p 30ab28d3acb37f96ad61ad8be82c8da46d0a7307
hello, 5xRuby

就可以看到那个对象的内容了,由此过程我们可以得到以下信息

  1. 当使用 git add 命令把文件加入暂存区时,git 会根据这个对象的内容计算出 SHA-1 值
  2. git 接着会用 SHA-1 的值的前2个字节作为目录名称,后38个字节作为文件名,创建文件并存放在 .git/objects 目录下
  3. 文件的内容则是 git 使用压缩算法把原本的内容压缩之后的结果(压缩算法 和 SHA-1 是两码事)

💡 在某些操作系统下,一个目录中如果放了非常多的文件,该目录的读取效率就会变得非常低,所以 git 就抽出了前两位数作为目录名,这是为了避免 .git/objects 目录因为文件过多而导致效率变低

创建文件夹

$ mkdir dir
$ git status
On branch master

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
        new file:   1.tmp

为啥没反应呢?那是因为 git 只会对文件的内容进行 SHA-1 计算,因为 dir/ 是一个空的文件夹,根本没有内容,所以 git 感应不到

⚠ git 不会管控的文件夹

空文件夹里创建文件

$ touch dir/new
$ git status
On branch master

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
        new file:   1.tmp

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        dir/

新建了文件之后,这个文件夹就能被 git 看到了

💡 就算是一个空的文件,也是有文件内容的

我们把它添加到暂存区

$ git add dir/new

根据文件的内容我们来计算以下 dir/new 的 SHA-1,

$ cat dir/new | git hash-object --stdin
e69de29bb2d1d6434b8b29ae775ad8c2e48c5391

按照之前的结论,应该会在 .git/objects 文件夹里建立 e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391

image-20230107200534820

果然如此,到这里大概对 git 的 blob 对象有一个大致的认识了吧

你可能会好奇,为什么 git 只在意文件的内容,难道目录就不重要吗,文件的名称就不重要吗?

其实只是因为他们不属于 blob 对象的范围,而是之后要说的 Tree 对象要处理的

进行提交

文件已经加入暂存区了,接下来我们来看看 commit 操作会对 .git/objects 产生什么影响

$ git commit -m "init commit"
[master (root-commit) 275c688] init commit
 2 files changed, 1 insertion(+)
 create mode 100644 1.tmp
 create mode 100644 dir/new

查看 .git/objects 文件夹

image-20230107201413549

发现除了原本的 30e6 文件夹之外,又多出了好几个目录,我们来看看它们分别都是什么

先来看 27

$ git cat-file -t 275c688fee8cab4a417dbb4efd03e7fc5e8298a3
commit

git 告诉我们这是一个 commit 对象

来看看它的内容是什么

$ git cat-file -p 275c688fee8cab4a417dbb4efd03e7fc5e8298a3
tree 54963fdf9b71ab2e8712cc84e1c61dbf8c3bfad6
author lol <233@qq.com> 1673122354 +0800
committer lol <233@qq.com> 1673122354 +0800

init commit

这个 commit 对象包括以下信息

  1. 某个 tree 对象
  2. 作者和修改完成的时间
  3. 提交者和提交的时间,通常作者和提交者是同一个人,但也会有其他情况
  4. 本次提交的信息

我们来看看这个 commit 对象里放的 tree 对象是什么吧

$ git cat-file -t 54963fdf9b71ab2e8712cc84e1c61dbf8c3bfad6
tree

不出意料它是一个 tree 对象,来看看内容

$ git cat-file -p 54963fdf9b71ab2e8712cc84e1c61dbf8c3bfad6
100644 blob 30ab28d3acb37f96ad61ad8be82c8da46d0a7307    1.tmp
040000 tree fb94905aafbdcb5da3091bba933cdb2e391e88a7    dir

我们看到它包含了一个 tree 对象和一个 blob 对象,而这个 blob 对象正是我们第一个新建的文件 1.tmp ,而另一个 tree 对象则有说明是 dir,这样看的话,这个 tree 对象应该就是根目录,我们最后来看以下 fb9490 的内容,如果是 dir 文件夹的话,应该有 new 空文件的信息

$ git cat-file -p fb94905aafbdcb5da3091bba933cdb2e391e88a7
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    new

果然它指向了我们刚刚创建的空文件 new,看到这里大概也对 tree 对象有一个了解了吧

结果上面的结果,我们可以得出以下结论:

  1. 文件在 git 中会以 blob 对象的形式存放,如 1.tmpnew

  2. blob 中会存放压缩过的文件内容

  3. 目录和文件则是会以 tree 对象的形式存放,如 54963f 是根目录,fb9490dir 目录

  4. tree 中会指向另一个 tree 对象或者 blob 对象,并含有他们的名称信息

  5. commit 对象会指向某个 tree 对象,包含 commit 的各种信息

用程序呈现出来可能有点模糊,用图片的形式整理出来就是这样的

2023172

你看到这幅图可能觉得他们的关系和目录和子目录之间的关系相同,但其实并不相同,它有一个专有名词 —— 有向无环图(DAG, Directed Acyclic Graph),这些对象只有指来指去,是平行的,并没有像目录和子目录之间的层级关系,而上面的文件或者文件夹名并不是指 指向的文件或者文件夹就是这个对象,而是 这个对象的内容来自于这个文件或文件夹,听起来可能很绕,你可以觉得他们并不是图,而是一颗树,我们来看下面的例子

$ mkdir try
$ cd try
$ git init .
Initialized empty Git repository in /home/paradox/Code/learngit/try/.git/
$ touch tmp1
$ touch tmp2
$ git add .
$ git commit -m "first commit"
[master (root-commit) f8993a0] first commit
 2 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 tmp1
 create mode 100644 tmp2
$ cd .git/objects/
$ ls
87  e6  f8  info  pack
$ git cat-file -p f8993a02952879d158d2c520c8e96cf5324d1d4c
tree 87cc6b1d469b5fa6bd1eaa5147caaa04867eb2dc
author lol <233@qq.com> 1673126388 +0800
committer lol <233@qq.com> 1673126388 +0800

first commit
$ git cat-file -p 87cc6b1d469b5fa6bd1eaa5147caaa04867eb2dc
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    tmp1
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    tmp2

解释一下,我们创建了两个空文件 tmp1tmp2,然后查看 commit 指向的 tree 对象,里面有两个一模一样的 SHA-1,后面还有我们创建的空文件名,这是因为这两个文件都是相同的内容,根据 SHA-1 算法,相同内容计算出的结果是一样的,因此他们都指向了 e69de2 ,你应该明白他们并不是层级关系,也不是树形关系,而是 DAG的关系,和 e69de2 代表的是空内容,而不是那个文件了吧!

202317exp

到目前为止,git 的四大对象中的出现了三种已经出现了,还剩一个 tag 对象

加上分支

git 中的分支其实就是跟贴纸一样,它会贴在某个 commit 上,并且会随着 commit 移动。之前也对 HEAD 的介绍中指出,HEAD 是指向某个分支的指针,那个分支一般就指当前所在的分支,所以在上面的图上加上这条信息就是

2023172v2

提交一次修改

这次我们来修改一下 1.tmp

$ cat 1.tmp
hello, world
- hello, 5xRuby -
hello, git

我们先来计算一下这个文件对应的 SHA-1

$ cat 1.tmp | git hash-object --stdin
1f169b152ea986dfa8f171ece502788674ac5334

添加到暂存区

$ git add 1.tmp

这时候先别急着提交,我们来看看 .git/objects 有没有新发现

image-20230107220201799

不出意外的添加了这个文件内容对应的 SHA-1 ,现在的 .git/objects 文件夹的状态是下面这样

2023172v3

接下来我们提交这次修改

$ git commit -m "3rd commit"
[master 7ee420d] 3rd commit
 1 file changed, 3 insertions(+), 1 deletion(-)

现在再来看看 .git/objects

image-20230107221536041

和之前的进行对比会发现多了 7e71 这两个文件夹,我们一个个来看

$ git cat-file -t 712598bd0ec8b76460f154bc2c4090184ef628ee
tree
$ git cat-file -p 712598bd0ec8b76460f154bc2c4090184ef628ee
100644 blob 1f169b152ea986dfa8f171ece502788674ac5334    1.tmp
040000 tree fb94905aafbdcb5da3091bba933cdb2e391e88a7    dir

这个 tree 对象看上去和上一个 commit 中代表根目录的 tree 很相似,只是内容有点不太一样,对比一下会发现指向 dir 的文件夹那行没有改变,只有 1.tmp 那行发生了变化,因为文件内容改变了,所以指向了新的 blob 对象

我们再来看另一个

$ git cat-file -t 7ee420da8e453e54832bb7914f03e23f6f4f8302
commit
$ git cat-file -p 7ee420da8e453e54832bb7914f03e23f6f4f8302
tree 712598bd0ec8b76460f154bc2c4090184ef628ee
parent 275c688fee8cab4a417dbb4efd03e7fc5e8298a3
author lol <233@qq.com> 1673129549 +0800
committer lol <233@qq.com> 1673129549 +0800

3rd commit

7ee420 是一个 commit 对象,从给出的信息中可以看出它指向了刚刚的 tree 对象 712598 ,除此之外还多出了一个 parent 信息,表示指向上一次 commit,当前的状态可以表示成如下图

2023172v4

分支和 HEAD 也是随着 commit 的变化而调整位置,把前后两次提交之后的图放一起对比会形象不少

在上面得出的结论基础上,我们还可以再加一条

  • 除了第一个 commit 对象,所有的 commit 对象都会指向其前一次 commit 对象

tag 对象

tag 对象不会再 commit 的时候出现,必须手动把 tag 贴在某个 commit 上,关于 tag 的具体介绍会放在后面,现在我们只需要知道这个对象在 .git 文件夹里的状态就可以了

我们来打上一个 tag

$ git tag -a aTag -m "I am a Tag in here"

image-20230107232036725

此时 .git/objects 中多出了 17 文件夹,我们来查看一下 tag 对象的内容会是什么样的

$ git cat-file -t 17862cfb31505c0114ea4e40fa4ae481558559e3
tag
$ git cat-file -p 17862cfb31505c0114ea4e40fa4ae481558559e3
object 7ee420da8e453e54832bb7914f03e23f6f4f8302
type commit
tag aTag
tagger lol <233@qq.com> 1673133569 +0800

I am a Tag in here

它会指向标记的提交,还有记录是谁在什么时候做了这个 tag 以及这个 tag 的相关信息

所以最终的状态如下

2023172v6

checkout

虽然 .git/objects 目录中保留了所有的文件和文件夹,但是在工作目录中还是会根据当前的 commit 把各种对象给提取出来,所以工作目录的状态如下图不透明部分所示

202317ls

当我们 checkout 到另一个 commit 时,就会出现下面的说明

$ git log --oneline
7ee420d (HEAD -> master, tag: aTag) 3rd commit
275c688 init commit
$ git checkout 275c688
Note: switching to '275c688'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:

  git switch -c <new-branch-name>

Or undo this operation with:

  git switch -

Turn off this advice by setting config variable advice.detachedHead to false

HEAD is now at 275c688 init commit

因为 275c688 这个 commit 没有分支指着,而我们的 HEAD 如果前往,就会发生之前提到的 detached HEAD,关于 detached HEAD 会之后再具体介绍,当我们切换到这个节点的时候,git 会根据当前这条 commit 的信息计算出哪些文件需要被提取到工作目录中,所以现在的 1.tmp 的文本内容是这样的

$ cat 1.tmp
hello, 5xRuby

202317ls2

checkout 的过程有点像拎葡萄,还是很好理解的(?哪有颗葡萄连着两个枝的

疑问

看到这里你应该也会和我一样惊讶于 git 的原理,之前我以为 git 是采取只会保留每次修改了哪些内容的差异备份机制,结果看完发现 git 并不是这么设计的

从前面的流程看,我们每次 git add 一个文件,哪怕只修改了一个字,由于计算出来的 SHA-1 的值不同,git 都会为它新建一个 blob 对象,而不是去记录文件的前后变化差异

这样做有一个好处,那就是 checkout 的时候不用一步步去拼凑历史记录,checkout 的效率非常高,有些人把这个过程称为 Snapshot(快照)

但是这样做的代价不就是需要大量的空间来存放文件,试想一下,如果有一个 100KB 的文件,因为改了一行代码就必须再做出一个 100KB 的文件放在 .git/objects 里,虽然 git 会对文件内容进行压缩,但是只是因为一行代码就备份整个文件这个行为未免太浪费了

而对此,git 也是给出了它的方案,git 提供『资源回收机制』,当启动这种机制的时候,git 就会非常高效地压缩对象和制作下标,下面我们来演示一下这个机制

$ git ls-files -s
100644 1f169b152ea986dfa8f171ece502788674ac5334 0       1.tmp
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0       dir/new
$ git cat-file -s 1f169b152ea986dfa8f171ece502788674ac5334
42

git ls-files 能够查询当前文件在 git 中的样子,git cat-file 后面加上 -s 参数则会输出指定文件的 blob 对象的文件大小,1.tmp 的 blob 对象有 42 bytes 那么大

接下来我们来增加一行做一次提交

$ cat 1.tmp
hello, world
- hello, 5xRuby -
hello, git
does git need to stage all
$ git add .
$ git commit -m "modify 1.tmp"
[master 39886fe] modify 1.tmp
 1 file changed, 1 insertion(+)

我们增加了一行 does git need to stage all ,按照 git 的机制,肯定会新建一个 blob 对象,我们来看看这个 blob 对象有多大

$ git ls-files -s
100644 82b26dc0fa6931b634fcf196ca8076213f46ed12 0       1.tmp
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0       dir/new
$ git cat-file -s 82b26dc0fa6931b634fcf196ca8076213f46ed12
69

有 69bytes !!只为了一行字,git创建了一个 69bytes 的 blob 对象,这就是前面说的浪费

但此时 git 并没有启用『资源回收机制』,我们来看看启用之后的效果,git 的『资源回收机制』通常会在它认为对象太多时候启用,但也可以用 git gc 手动触发

$ git gc
Enumerating objects: 12, done.
Counting objects: 100% (12/12), done.
Compressing objects: 100% (8/8), done.
Writing objects: 100% (12/12), done.
Total 12 (delta 0), reused 0 (delta 0), pack-reused 0

这条命令会把原本在 .git/objects 目录下的对象全部打包到 .git/objects/pack 目录下成 .idx.pack 文件

image-20230108004007680

git 还有一个底层的命令 git verify-pack 可以用于查看打包的情况

$ git verify-pack -v pack-37a72782bbddf38577f096e432a1622bff6d27ea.idx
39886fe807a8eaf236ca3a1edf5d13228b4c8639 commit 193 142 12
7ee420da8e453e54832bb7914f03e23f6f4f8302 commit 191 138 154
17862cfb31505c0114ea4e40fa4ae481558559e3 tag    130 124 292
275c688fee8cab4a417dbb4efd03e7fc5e8298a3 commit 144 111 416
c1078872df94c18b353dc779fb60a55d7534b7c5 tree   63 74 527
fb94905aafbdcb5da3091bba933cdb2e391e88a7 tree   31 42 601
712598bd0ec8b76460f154bc2c4090184ef628ee tree   63 74 643
54963fdf9b71ab2e8712cc84e1c61dbf8c3bfad6 tree   63 74 717
82b26dc0fa6931b634fcf196ca8076213f46ed12 blob   69 67 791
e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 blob   0 9 858
1f169b152ea986dfa8f171ece502788674ac5334 blob   42 42 867
30ab28d3acb37f96ad61ad8be82c8da46d0a7307 blob   14 23 909
non delta: 12 objects
pack-37a72782bbddf38577f096e432a1622bff6d27ea.pack: ok

上面的信息第1栏是对象的 SHA-1 值,第2栏是对象的形态,第3栏是文件的大小,我这里看下来好像文件的大小并没有改变…

可能是因为文件都还太小,git 懒得压缩,但是书上的例子确实会让 blob 对象文件变小,说是 git 在资源回收打包的时候使用了类似差异备份的方式,有效的缩小了对象的体积

git 启用『资源回收机制』的时机

  1. .git/objects 目录的对象或者打包过的 packfile 数量过多时会自动触发
  2. 当执行 git push 命令推送到远端服务器的时候

💡 其实 git 并不在意空间的浪费,能够快速操作才是 git 关注的重点

小结

git 中的4中对象的关系如下图所示

202317cong

  • 把文件加入 git 后,文件的内容会以 blob 对象的形式存储
  • 目录及文件名会存放在 tree 对象内,tree 对象会指向 blob 对象或者其他的 tree 对象
  • commit 对象会指向某个 tree 对象。除了第一个 commit,其他的 commit 会指向前一次 commit 对象
  • tag 对象会指向某个 commit 对象
  • 分支虽然不属于对象,但是它会指向某个 commit 对象
  • 往 git 服务器 push 之后,在 .git/refs 下就会多出一个 remote 目录,里面放的是远端的分支,和本地的分支类似,会指向某个 commit 对象
  • HEAD 也不属于对象,会指向某个分支
  • git 的 checkout 很快是因为 git 没有采用差异备份方式,而是用空间换时间

这一篇真的花了我好长时间,其实是容易摸鱼

不过真的学到了好多东西,git 居然不是用差异备份,这真的震撼到我了😦

2023/01/08
> CLICK TO back <