对于大多数程序员,Git 可以说是最常用的开发工具了。但是,我们大部分人其实对 Git 的工作原理都不了解。这篇文章就将介绍 Git 的工作原理。这里用于测试的 Git 版本是 2.44。
.git 目录
每一个使用 git 进行版本控制的项目都有一个 .git 目录,Git的核心功能都在 .git 目录里面。.git 目录的组成部分如下图所示:
.git 目录里面的目录/文件的作用如下表所示:
| 目录/文件 | 作用 |
|---|---|
hooks/ | 存储的是包含客户端和服务端的钩子脚本 |
info/ | 包含一个全局性排除文件 |
logs/ | 保存的是 commit 相关的日志信息 |
objects/ | 这个目录下就是 Git 的键值对数据库,它存储了 Git 中所有文件及其历史版本 |
refs/ | 存储的是指向数据(分支、远程仓库和标签等)的提交对象的指针 |
COMMIT_EDITMSG | 保存最近一次 commit 的提交信息 |
config | 存储的是项目的一些配置信息 |
description | 仅供 GitWeb 程序使用,我们不需要关心 |
HEAD | 存储的是目前被检出的分支 |
index | 保存的是暂存区相关的信息(首次 git add 时创建) |
packed-refs | 用于存储和管理 Git 引用(refs) |
git 如何存储文件
在 Git 中,数据存储的基本单元 Git 对象,Git 对象类型有以下 4 种:
- 二进制对象(Blob Object)
- 树对象(Tree Object)
- 提交对象(Commit Object)
- 标签对象(Tag Object)
所有的git对象均存储在 .git/objects/ 目录下。Git 会根据根据文件内容、文件类型等信息(不包含文件名)来计算出 40 位 SHA-1 值(前 2 为作为子目录,后 38 位作为文件名),并把它作为快照文件的唯一标识,并对它们进行 zlib 压缩,然后将压缩后的结果作为快照文件的实际内容进行存储。如下图所示:
一般情况下,我们会先使用 git add 把文件添加到暂存区,然后使用 git commit 把修改的文件提交到 git 本地仓库中去。下面我们就从 git add 和 git commit 这两步来介绍 git 是如何存储文件的。
为了方便理解,我们先把之前 gitdemo 目录下的 .git 目录删除,然后在该目录下执行 git init 命令,来重新生成 .git 目录。
git add
我们先创建一个 file1.txt 文件,并写入 file1 content 的内容,然后执行 git add 命令。执行完后,你就可以在 .git 目录下看到新生成的 index 文件,并在 objects 目录中看到 89/62b20c597314f78bad3ad83411828d2ecbd858 文件,该文件就存储了写入的内容。
我们可以使用 git ls-files --stage 命令查看 index 文件的内容,结果如下:
PS F:\gitdemo> git ls-files --stage
100644 8962b20c597314f78bad3ad83411828d2ecbd858 0 file1.txt
该命令的输出包含四个字段,每个字段之间用空格分隔,具体含义如下:
- 文件模式(Mode):表示文件的权限和类型,例如 100644 表示普通文件,100755 表示可执行文件,120000 表示符号链接等。
- 对象哈希值(Object):是文件内容的 SHA - 1 哈希值,用于唯一标识文件的内容。在 Git 中,每个文件的内容都会被计算一个哈希值,即使文件只有一个字节的变化,其哈希值也会不同。
- 阶段号(Stage):用于表示文件在合并冲突时的不同版本。阶段号通常有 0、1、2 和 3 四种取值:
- 0:表示正常的文件状态,没有合并冲突。
- 1:表示冲突文件的共同祖先版本。
- 2:表示冲突文件的 “我们”(当前分支)版本。
- 3:表示冲突文件的 “他们”(合并进来的分支)版本。
- 文件名(Path):表示文件在仓库中的路径。
.git/index文件会记录我们使用git add添加修改到暂存区的文件信息。因此可以把.git/index文件看成是暂存区
前面通过 git ls-files 命令获取到了 git add 的 Git 对象的哈希值。这时我们可以使用 git cat-file 命令来查看 Git 对象的信息,其中 -p 表示查看对象内容、-t 表示查看对象类型、-s 表示查看对象内容长度。除此之外 git show 哈希值 命令也可以查看Git 对象的内容。
PS F:\gitdemo> git cat-file -p 8962b20c597314f78bad3ad83411828d2ecbd858
file1 content
PS F:\gitdemo> git cat-file -t 8962b20c597314f78bad3ad83411828d2ecbd858
blob
PS F:\gitdemo> git cat-file -s 8962b20c597314f78bad3ad83411828d2ecbd858
32
从上面的内容,我们了解到了 git add 会创建 blob 的 git 对象,并把这个对象的相关信息存储在 index 文件中。大致流程清晰了,还有几个小问题需要确定一下:
问题1:修改文件名是否会改变 blob 对象的哈希值?
答案是不会,前面说到过 blob 对象哈希值和文件名无关,blob 对象内部也不保存文件名。示例如下
PS F:\gitdemo> git ls-files --stage
100644 8962b20c597314f78bad3ad83411828d2ecbd858 0 file1.txt
// 修改file1.txt的文件名为file1-change.txt,可以看到哈希值没有变化
PS F:\gitdemo> git ls-files --stage
100644 8962b20c597314f78bad3ad83411828d2ecbd858 0 file1-change.txt
问题2:往 file1-change.txt 文件增加新的内容后再执行 git add,是否会创建新的 git 对象?
答案是会创建新的git对象。在 Git 中,如果我们对任意一个文件进行修改,Git 就会创建一个新的 Blob Object,并将该文件的所有内容存储到里面。示例如下:
PS F:\gitdemo> git ls-files --stage
100644 8962b20c597314f78bad3ad83411828d2ecbd858 0 file1-change.txt
PS F:\gitdemo> git cat-file -p 8962b20c597314f78bad3ad83411828d2ecbd858
file1 content
// 往file1-change.txt增加新的内容 “new append content”
PS F:\gitdemo> git add .
PS F:\gitdemo> git ls-files --stage
100644 e167e9ff8dd851ae3d19f9ad20149b95f06772dd 0 file1-change.txt // index文件内容已经更新为新的 git 对象
PS F:\gitdemo> git cat-file -p e167e9ff8dd851ae3d19f9ad20149b95f06772dd // 查询新的 git 对象的内容
file1 content
new append content
PS F:\gitdemo> git cat-file -p 8962b20c597314f78bad3ad83411828d2ecbd858 // 查询旧的 git 对象的内容
file1 content
问题3:上面说到 Git每次修改文件都创建了新的 blob 对象,而不是在之前的对象上新增内容。如果一个文件非常大,而每次我们只修改其中极小一部分内容,这时会创建新的git对象,这时会不会导致磁盘浪费?
答案是不会。事实上,Git 会不定时地自动对仓库中的对象进行打包并移除,并最终采用 原始内容 + 增量内容 的形式进存储,从而节省存储空间。
问题4:文件会被创建为 blob 对象,而文件夹会被创建成 tree 对象吗?
答案是在 git add 时,只有文件会生成 git 对象。示例如下:
PS F:\gitdemo> git ls-files --stage
100644 e167e9ff8dd851ae3d19f9ad20149b95f06772dd 0 file1-change.txt
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 newFolder/file2.txt
PS F:\gitdemo> git cat-file -t e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
blob
git commit
接着上面的操作,我们先创建 newFolder/file2.txt文件,再执行 git add 命令 ,最后执行 git commit 命令。提交完成后,执行 git log 命令就可以看到提交信息了,如下所示:
PS F:\gitdemo> git log
commit bfdc4df8ffe25653751bcd03530cba4647b627d9 (HEAD -> master)
Author: Your Name <you@example.com>
Date: Mon Feb 3 15:12:25 2025 +0800
commit1
可以看到此时创建了一个提交对象,它的哈希值为 bfdc4df8ffe25653751bcd03530cba4647b627d9,我们可以使用git cat-file 查看提交对象的内容,结果如下:
PS F:\gitdemo> git cat-file -p 924ff1009a89daf1a7b489ab9dbd9b322d185490
100644 blob e167e9ff8dd851ae3d19f9ad20149b95f06772dd file1-change.txt
040000 tree 82022e5bf06cf44aff7e10f2876f0acc43dcf401 newFolder
PS F:\gitdemo> git cat-file -p 82022e5bf06cf44aff7e10f2876f0acc43dcf401
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 file2.txt
PS F:\gitdemo>
执行完提交命令后,Git 对象之间的关系如下图所示。可以看到 commit 对象保存了引用的 tree 对象信息;而tree对象内则保存 blob对象信息或者tree对象信息,以及它们所在的文件或者目录名;blob对象则保存了文件的内容。最终通过这种方式组成了如下一个树形结构。
当我们修改 newFolder/file2.txt 文件内容,并再次执行 git commit 后。git 提交对象的关系如下所示:
可以看到,即使文件未修改,新创建的commit对象内部也会有引用指向未修改的 blob 对象,来实现数据的复用。这样做的目的就是为了节省磁盘的空间。
问题5:上面提到了新创建的commit对象内部也会有引用指向未修改的 blob 对象,那这个对象的哈希值是从哪里获取到的呢?
答案是 index 文件。当我们 commit 后,index 文件中还保存着之前创建过的对象的哈希值信息。
// 第一次 commit 后
PS F:\gitdemo> git ls-files --stage
100644 blob e167e9ff8dd851ae3d19f9ad20149b95f06772dd file1-change.txt
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 newFolder/file2.txt
// 修改 `newFolder/file2.txt` 文件内容,第二次commit 后
PS F:\gitdemo> git ls-files --stage
100644 blob e167e9ff8dd851ae3d19f9ad20149b95f06772dd file1-change.txt
100644 blob 79cd8cc836eece298bbcac9fe8a22174a5ffcdfc newFolder/file2.txt
问题6:之前问题3提到了Git 会不定时地自动对仓库中的对象进行打包并移除,我们可以主动触发吗?
我们可以通过 git gc 主动触发打包,除此之外当我们执行将代码推送至远程仓库或者从远程仓库更新时,Git 也会进行打包,比如执行 git push 或 git pull 命令。
问题7:Git打包后是怎么存储的?
当我们执行 git gc 命令主动触发打包后,可以看到在 .git/objects 目录下的 Git 对象文件都不见了。但是如果之后执行 git cat-file -p 哈希值 命令,还是能够看到对应的对象内容。这是因为相关的数据都被打包并存放在 .git/objects/pack 目录下。如下图所示:
打包会最终生成三个文件:
- 包文件(Pack File) :采用 原始内容 + 增量内容 的形式进存储,从而节省存储空间。
- 索引文件(Index File):存储了各个包文件中各个对象的大小、偏移、类型等数据,从而便于重建文件快照和对象关系。
- 反向索引文件:主要用于优化某些操作,如对象遍历和查找
存储结构如下图所示,图片来源深入理解 Git 底层实现原理
我们可以使用 git verify-pack -v 命令来查看索引文件,结果如下所示:
PS F:\gitdemo> git verify-pack -v .git\objects\pack\pack-634c8b5195581e9f75392b8f784377396ad3a779.pack
586dfd0ed96418999d351abf3d004c77c6e89d3d commit 210 148 12
041740224d7301b443b05d3f6e7e655e48187280 commit 210 147 160
bfdc4df8ffe25653751bcd03530cba4647b627d9 commit 162 117 307
e167e9ff8dd851ae3d19f9ad20149b95f06772dd blob 68 53 424
ad3a6c0aa18a3b34a32dc31d64f068a332e92468 blob 33 37 477
a5de65265f7a27200dfdc89fd5fb162d4defec48 tree 80 91 514
9b9ad8d0c11b58c00d1124ca4f4816314e2d0b96 tree 37 48 605
1126c8764b76b24d806a1846df3781b593ae62ef tree 80 90 653
f5f01ab65007006433589e3711e45c4507c47a47 tree 37 48 743
79cd8cc836eece298bbcac9fe8a22174a5ffcdfc blob 13 22 791 // 第二次commit 时newFolder/file2.txt 对象数据
924ff1009a89daf1a7b489ab9dbd9b322d185490 tree 80 90 813
82022e5bf06cf44aff7e10f2876f0acc43dcf401 tree 37 48 903
e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 blob 0 9 951 // 第一次commit 时 newFolder/file2.txt 对象数据
non delta: 13 objects
.git\objects\pack\pack-634c8b5195581e9f75392b8f784377396ad3a779.pack: ok
git verify-pack -v 命令显示的各列数据分别是:
- SHA-1:对象的 SHA-1 值
- type:对象的类型
- size:对象的大小
- size-in-packfile:对象在包文件中的大小
- offset-in-packfile:对象在包文件中的偏移
Git 引用
在 Git 中如果使用哈希值来查找某一提交或者历史版本非常麻烦。对此,Git 提供了容易记忆的 “别名” 来代替哈希值 ,这就是 “引用(referrences,简称 refs)”。Git 支持三种引用类型,分别是 HEAD 引用、标签引用、远程引用。不同的引用类型对应的引用文件各自存储在 .git/refs/ 下的不同子目录中。
HEAD 引用
HEAD 引用是一个特殊的引用,它指向当前所在的分支或特定的提交,我们可以使用 cat .git/HEAD 命令查看HEAD 的指向,结果示例如下所示:
PS F:\gitdemo> cat .git/HEAD
ref: refs/heads/master // 表示 HEAD 指向 `master` 分支
标签引用
标签引用是为某个特定的提交对象赋予一个有意义的名字,用于标记项目的重要版本或里程碑。标签通常用于标记发布版本,如 v1.0、v2.1 等。标签引用有两种,分别是轻量标签、附注标签
- 轻量标签(Lightweight Tag):轻量标签实际上就是一个指向特定提交的引用,它只是一个简单的名字,不包含任何额外的元数据。创建轻量标签的命令如下:
git tag v1.0 # 创建一个名为 v1.0 的轻量标签,指向当前提交
- 附注标签(Annotated Tag):附注标签是一个完整的对象,它包含了标签的名称、标签的创建者、创建时间、标签信息等元数据。创建附注标签的命令如下:
git tag -a v1.0 -m "Release version 1.0" # 创建一个名为 v1.0 的附注标签,并添加标签信息
远程引用
远程引用(Remote Reference)主要用于远程仓库与本地仓库进行映射和对比。如果我们添加了一个远程仓库并对其执行过推送操作,Git 会记录下最近一次推送操作时每一个分支所对应的值,并保存在 .git/refs/remotes/ 目录下。
远程引用和分支(位于 .git/refs/heads/ 目录下的引用)之间的最主要区别在于:远程引用是只读的。虽然我们可以 git checkout 到某个远程引用,但是 Git 并不会将 HEAD 引用指向该远程引用。因此,我们永远不能通过 git commit 命令来更新远程引用。Git 将这些远程引用作为记录远程服务器上各个分支最后已知位置状态的书签来管理。