本文将深入剖析 Git 的内部工作原理,通过实际示例和命令详细讲解 Git 对象数据库的核心数据结构。文章从最基础的 blob 对象开始,演示了如何使用底层命令如 git hash-object 和 git cat-file 来理解 Git 如何存储文件内容,并逐步解析 Git 的内容寻址文件系统原理。通过亲手操作和分析 Git 对象的压缩存储格式,读者可以全面理解 Git 的对象模型,掌握 Git 命令背后的工作机制,从而更高效地使用 Git 进行版本控制。
普通 Git 仓库分为:
- 工作区 (workspace/working copy)
- 暂存区 (stage/index)
- 仓库区 (local repository/objects database)
裸仓就是不包含工作区的仓库,一般服务端的仓库 (即远程库) 就是裸仓。
仓库名结尾一般使用 .git 为了与正常仓库区分,但不是强制。
创建裸仓的方式有两种:
$ git init --bare <bare-repo>$ git clone --bare source-repo <bare-repo>我们新建一个空的裸仓,命名为 remote.git:
$ git init --bare remote.git然后再创建两个副本,后续的实验就是在 repo_a, repo_b 中进行:
$ git clone remote.git repo_a$ git clone remote.git repo_b进入刚克隆的一个仓库 repo_a 中,只有 .git 目录,.git 目录中的所有文件,即是裸仓中的所有文件,但作为一个正常仓库,目前它工作区还是为空。好在我们可以手动创建一些内容。
认识 blob object#
git hash-object:将数据保存到对象数据库中
$ echo -n 'Hello, Git' > README$ git hash-object -w README6fe402b35d6e80a187adc393f36ce10e4fdd259f选项-n 为了避免 echo 在输出字符串时自动添加换行符。
选项
-w表示写入,如果不加这个选项,则仅是计算文件的 hash 值,不会保存。
这里将内容 Hello, Git 写入文件 README,再对该文件运行了 git hash-objects 命令,并返回了一串值。这串值会以某种形式下保存下来。
用 git count-objects 命令得知,此时仓库已经有了第一个对象:
$ git count-objects$ 1 objects, 4 kilobytes可以通过 tree 命令查看下 .git/objects 目录:
$ tree .git/objects.git/objects├── 6f│ └── e402b35d6e80a187adc393f36ce10e4fdd259f├── info└── pack
3 directories, 1 file我们把 .git/objects 目录称作对象数据库 (object database),新增的对象都会存到这个目录下。
里面还是熟悉的那串数字,我们对 Git 的认识就从这一串数字开始。
git cat-file :剖析 git 数据对象
通过 git cat-file 命令查看新生成的对象:
$ git cat-file -p 6fe402b35d6e80a187adc393f36ce10e4fdd259fHello, Git顺便还可以看一下对象的类型:
$git cat-file -t 6fe402b35d6e80a187adc393f36ce10e4fdd259fblob这里,我们认识到了 git 对象中的第一种类型 blob object, 称之为数据对象。
我们创建了一个文件,内容是 Hello, Git, 通过 git hash-object 命令将内容写入到 .git/objects 目录中,并且返回指向该数据对象的唯一的键,它是一个 40-bit 的 hash 值,通过这串值即可寻找出它对应的文件的内容,因此 blob 数据对象可表示如下:

这也是 Git 的内容可寻址的文件系统 (Content-Addressable Filesystem) 的含义。
同时我们也知道了 Git 会将这 40-bit 的 hash 值的前 2 位作为 .git/objects 目录下的子目录,后 38 位作为子目录下的一个文件的文件名。
通过 file 命令我们可以知道这是一个 zlib 压缩文件:
$ file .git/objects/6f/e402b35d6e80a187adc393f36ce10e4fdd259f.git/objects/6f/e402b35d6e80a187adc393f36ce10e4fdd259f: zlib compressed data既然是被压缩过的,那么里面的内容就是不可读的。
为了查看里面的内容,先安装一个解压缩工具:
$ apt-get update$ apt-get install pigz然后运行:
$ pigz -d < .git/objects/6f/e402b35d6e80a187adc393f36ce10e4fdd259fblob 10Hello, Git-d 表示解压
可以看到在进行 hash 时,git 内部是按照特定格式进行的:

切到 repo_b,我们可以手动验证这个过程:
$ echo -ne 'blob 10\0Hello, Git' | sha1sum6fe402b35d6e80a187adc393f36ce10e4fdd259f -选项-e 为了让 echo 能够识别反斜杠转义符(即字符串中的”)
再次使用 git hash-objects 验证这条数据:
$ echo -n 'Hello, Git' | git hash-object --stdin6fe402b35d6e80a187adc393f36ce10e4fdd259f结果是一致的,说明 git hash-objects 在生成 hash 时,是严格按照特定格式进行的。
此时并没有创建 .git/objects/6f 目录,因为没有加 -w 选项,我们可以手动完成。
我们知道 Git 会对数据使用 zlib 的 deflate 算法进行压缩,我们也手动验证一下:
创建一个目录和文件:
$ mkdir .git/objects/6f$ touch .git/objects/6f/e402b35d6e80a187adc393f36ce10e4fdd259f接着,使用 pigz 工具压缩数据,存放到 e402b35d6e80a187adc393f36ce10e4fdd259f 文件中:
$ echo -ne 'blob 10\0Hello, Git' | pigz -cz > .git/objects/6f/e402b35d6e80a187adc393f36ce10e4fdd259f-c 表示输出,-z 表示采用 zlib 的 deflate 算法
此时我们才可以用 git cat-file 查看一下里面的内容:
$ git cat-file -p 6fe402b35d6e80a187adc393f36ce10e4fdd259fHello, Git同样我们可以 解压,看看压缩文件里面的内容:
$ pigz -d < .git/objects/6f/e402b35d6e80a187adc393f36ce10e4fdd259fblob 10Hello, Git结果是与前面一致的。
回到 repo_a 仓库继续实验其它内容。
再对 README 文件进行修改:
改写文件$ echo -n "Hello, Gitee" > README# 写入对象数据库$ git hash-object -w README216ef921a90b782fed1ca37223c3141ed7d5de32# 查看内容$ git cat-file -p 216ef921a90b782fed1ca37223c3141ed7d5de32Hello, Gitee再次查看一下 .git/object 目录:
$ tree .git/objects.git/objects├── 21│ └── 6ef921a90b782fed1ca37223c3141ed7d5de32├── 6f│ └── e402b35d6e80a187adc393f36ce10e4fdd259f├── info└── pack
4 directories, 2 files里面有两个 Git 对象了,它们分别代表 README 的两个版本。
我们写入了 blob 对象,通过 git status 查看工作区状态,可以看到 README 仍是 Untracked files 状态,也就是暂存区还是空的,此时 .git/ 目录中还不存在 index 文件。
我们可以先更新一下暂存区,需要用到新的命令:
git update-index: 将工作区的文件内容更新到 index 区
$ git update-index --add --cacheinfo 100644 6fe402b35d6e80a187adc393f36ce10e4fdd259f README—add 表示加到 Index 中
—cacheinfo 表示是从 git 数据库.git/object 中添加文件
100644 表示普通文件
再次 git status 查看状态,就不一样了:
$ git statusOn branch master
No commits yet
Changes to be committed: (use "git rm --cached <file>..." to unstage) new file: README #version 1 index 区
Changes not staged for commit: (use "git add <file>..." to update what will be committed) (use "git restore <file>..." to discard changes in working directory) modified: README #version 2 工作区同时我们也可以看到生成了文件 .git/index。
以上过程相当于 git add REAME
所以 git add file 就是将工作区的文件内容更新到暂存区。
认识 tree object#
前面认识的 blob 对象只是文件的内容本身,以及它的 hash 值,并没有涉及到文件名,要保存文件名需要 tree 对象。
git write-tree: 从当前 index 区创建树对象
$ git write-tree16ab25f42fdb4563f1acb0ff8b978493bfd2bc1c # tree 1又生成了一个 hash 值。查看一下:
$ git cat-file -t 16ab25f42fdb4563f1acb0ff8b978493bfd2bc1ctree
$ git cat-file -p 16ab25f42fdb4563f1acb0ff8b978493bfd2bc1c100644 blob 6fe402b35d6e80a187adc393f36ce10e4fdd259f README内容与之前暂存区的内容一致,说明确实是从暂存区到了数据库。
这样我们也就认识到了 git 对象中的第二种类型 tree object,即树对象。
tree 对象不仅可以保存文件名,还可以保存多个文件的内容及其唯一键,而且它还允许嵌套子树,这相当于文件目录。
我们先再建一棵树:
$ echo '1.0' > VERSION$ git update-index --add VERSION$ git update-index --add README # version 2
$ git statusOn branch master
No commits yet
Changes to be committed: (use "git rm --cached <file>..." to unstage) new file: README new file: VERSION
$ git write-tree33ad99f76f295411d5c198cb58c5c95e5d0b3c91 # tree 2
$ git cat-file -p 33ad99f76f295411d5c198cb58c5c95e5d0b3c91100644 blob 216ef921a90b782fed1ca37223c3141ed7d5de32 README100644 blob d3827e75a5cadb9fe4a27e1cb9b6d192e7323120 VERSION这个目录树包含两个文件对象。
其实还可以包含子树,我们可以利用 git read-tree 把第一棵树整个读入暂存区,然后再写入 Git 的数据库。
git read-tree: 将树信息读到暂存区
第一棵树读到 index 区,并放到 bak 下面$ git read-tree --prefix=bak 16ab25f42fdb4563f1acb0ff8b978493bfd2bc1c# 将当前整个状态写入新的树,放回数据库$ git write-tree77e9ad8de018dab58d76e0667507378b3cfe4808 # tree 3但是此时并没有创建正真的 bak 目录,至少在工作区是没有的 (ls 查看不到),它目前只存在于暂存区中
$ git statusOn branch master
No commits yet
Changes to be committed: (use "git rm --cached <file>..." to unstage) new file: README new file: VERSION new file: bak/README
Changes not staged for commit: (use "git add/rm <file>..." to update what will be committed) (use "git restore <file>..." to discard changes in working directory) deleted: bak/README此时只需要使用 git checkout 命令将它从暂存区恢复到工作区就行:
$ git checkout -- bak/README$ git status# 此时可以观察到确实有这个目录了$ lsREADME VERSION bak同时别忘了 git cat-file 查看新树:
$ git cat-file -p 77e9ad8de018dab58d76e0667507378b3cfe4808100644 blob 216ef921a90b782fed1ca37223c3141ed7d5de32 README100644 blob d3827e75a5cadb9fe4a27e1cb9b6d192e7323120 VERSION040000 tree 16ab25f42fdb4563f1acb0ff8b978493bfd2bc1c bak100644 表示普通文件,040000 表示目录
用一张图表示:

认识 commit object#
git commit-tree:创建 commit 对象
目前我们有三棵树:16ab2,33ad9,77e9a
现在可以根据树来创建 commit:
$ echo 'first commit' | git commit-tree 16ab23aa1317953001375c744a8a12f59a37cc1640fdb$ git log 3aa13commit 3aa1317953001375c744a8a12f59a37cc1640fdbAuthor: Li Linchao <lilinchao@oschina.cn>Date: Mon Aug 16 16:16:31 2021 +0800
first commit(END)$ git cat-file -t 3aa1317953001375c744a8a12f59a37cc1640fdbcommit$ git cat-file -p 3aa1317953001375c744a8a12f59a37cc1640fdbtree 16ab25f42fdb4563f1acb0ff8b978493bfd2bc1cauthor Li Linchao <lilinchao@oschina.cn> 1629101791 +0800committer Li Linchao <lilinchao@oschina.cn> 1629101791 +0800
first commit这个过程相当于 git commit -m "message"。
以上就创建了 git 对象中的第三种类型 commit object,即提交对象
其它的 commit 时按照 tree 生成的顺序来:
$ echo 'second commit' | git commit-tree 33ad9 -p 3aa132aa80fc99a89a808fc0342972c5a3514d41fa5f7$ git log 2aa8commit 2aa80fc99a89a808fc0342972c5a3514d41fa5f7Author: Li Linchao <lilinchao@oschina.cn>Date: Mon Aug 16 16:21:42 2021 +0800
second commit
commit 3aa1317953001375c744a8a12f59a37cc1640fdbAuthor: Li Linchao <lilinchao@oschina.cn>Date: Mon Aug 16 16:16:31 2021 +0800
first commit(END)
$ echo 'third commit' | git commit-tree 77e9a -p 2aa80bdc5642cd9e8a62767710d1d9761b056f91f094c$ git log bdc56commit bdc5642cd9e8a62767710d1d9761b056f91f094cAuthor: Li Linchao <lilinchao@oschina.cn>Date: Mon Aug 16 16:23:00 2021 +0800
third commit
commit 2aa80fc99a89a808fc0342972c5a3514d41fa5f7Author: Li Linchao <lilinchao@oschina.cn>Date: Mon Aug 16 16:21:42 2021 +0800
second commit
commit 3aa1317953001375c744a8a12f59a37cc1640fdbAuthor: Li Linchao <lilinchao@oschina.cn>Date: Mon Aug 16 16:16:31 2021 +0800
first commit(END)不管是数据对象 (blob object), 树对象 (tree object), 提交对象 (commit object),所有的对象都会放到对象数据库中:
$ tree .git/objects.git/objects├── 16│ └── ab25f42fdb4563f1acb0ff8b978493bfd2bc1c├── 21│ └── 6ef921a90b782fed1ca37223c3141ed7d5de32├── 2a│ └── a80fc99a89a808fc0342972c5a3514d41fa5f7├── 33│ └── ad99f76f295411d5c198cb58c5c95e5d0b3c91├── 3a│ └── a1317953001375c744a8a12f59a37cc1640fdb├── 6f│ └── e402b35d6e80a187adc393f36ce10e4fdd259f├── 77│ └── e9ad8de018dab58d76e0667507378b3cfe4808├── bd│ └── c5642cd9e8a62767710d1d9761b056f91f094c├── d3│ └── 827e75a5cadb9fe4a27e1cb9b6d192e7323120├── info└── pack
11 directories, 9 files创建成功 commit 对象后,很快我们就会发现一个问题:
$ git logfatal: your current branch 'master' does not have any commits yet前面查看 log 时指定了 commit ID 才有结果,但是如果不指定 commit,系统就提示当前分支没有 commit,这是怎么回事?我们平时都是没指定也能正常使用对吧。
原因是还有些工作没有完成,这就涉及到 Git 引用 (Git Reference)。
在 local_a 仓库中,目前我们还没有创建任何引用,所以 .git/refs 下面还是空的:
$ find .git/refs.git/refs.git/refs/tags.git/refs/heads$ find .git/refs -type f我们还是继续手动创建。
首先我们需要一个指向当前分支(默认 master)最新提交 (bdc56) 的引用:
$ echo bdc5642cd9e8a62767710d1d9761b056f91f094c > .git/refs/heads/master此时,再次运行 git log,不需要指定 commit ID 就能看到全部提交历史了。
实际上 git 有专门的命令完成引用设置:git-update-ref
$ git update-ref refs/heads/master bdc5642cd9e8a62767710d1d9761b056f91f094c基于引用,于是就有了分支的实现。实际上每个分支都有一个 head 指针,指向该分支的最新提交。
假设我们在第二次提交 (2aa80fc99a89a808fc0342972c5a3514d41fa5f7) 上切出一个分支,实际上就是加一个引用名,如 dev:
$ git update-ref refs/heads/dev 2aa80git log 可以看到第二次提交上有个 dev 的标签,运行 git branch 也可以看到有 dev 分支。
通过 git log 可以看到有个特殊的指针 HEAD,它是指向引用的引用,它永远指向当前分支。
当前分支在 master 分支上,所以 HEAD 指向 master,当使用 git checkout dev 后可以看到 HEAD 指向 dev, 表示当前切换到了 dev 分支。
HEAD 是一种符号引用 (symbolic reference)
git 有个专门的命令用来读取,修改,删除符号引用:git symbolic-ref
$ git symbolic-ref HEADrefs/heads/master表示当前分支是 master
$ git symbolic-ref HEAD refs/heads/dev改变 HEAD 的指向,也意味着切换分支
认识 tag object#
其实还有一种比较少用到的 Git 数据对象是 tag object,它和 commit 对象有点类似,创建方式是:
$ git tag -a tag-name -m "tag message"比如我们创建一个 v1.0 的 tag:
$ git tag -a v1.0 -m "version 1.0"
$ cat .git/refs/tags/v1.005f749dc5667010dbe07ee181b3607143b84b14f$ git cat-file -t 05f749dc5667010dbe07ee181b3607143b84b14ftag$ git cat-file -p 05f749dc5667010dbe07ee181b3607143b84b14fobject bdc5642cd9e8a62767710d1d9761b056f91f094ctype committag v1.0tagger Li Linchao <lilinchao@oschina.cn> 1629103432 +0800
version 1.0
# 或者$ git cat-file -t v1.0tag
$ git cat-file -p v1.0object bdc5642cd9e8a62767710d1d9761b056f91f094ctype committag v1.0tagger Li Linchao <lilinchao@oschina.cn> 1629103432 +0800
version 1.0git tag 的 hash 值在 .git/refs/tags/v1.0 文件里面。v1.0 是文件名,也是 tag 名称,文件内容是 tag 的 hash 值,它们一一对应,v1.0 就是 db1cc3710e8b284f44286400b61974a8f1e633d4 的指针。
我们可以借助 git-draw 工具生成一张图来说明此时的仓库各个数据之间的关系:
$ ../git-draw -i --hide-index --hide-legend --hide-reflogs --hide-refs --image-filename output.png或者手动绘制各种数据的关系,如下:

http://assets.processon.com/chart_image/60a12b2fe0b34d34ca5ef5e7.png