Skip to content
Git 核心概念:仓库与提交对象
仓库的物理结构
git init 创建新仓库后,项目根目录下会多出一个名为 .git 的隐藏目录。Git 不需要独立的服务进程,所有历史记录、分支、暂存区元数据都以文件形式存放在该目录中。
bash
$ git init demo
$ cd demo
$ ls -l .git
drwxr-xr-x 2 user staff 64 Feb 3 10:00 hooks
drwxr-xr-x 3 user staff 96 Feb 3 10:00 info
drwxr-xr-x 4 user staff 128 Feb 3 10:00 objects
drwxr-xr-x 4 user staff 128 Feb 3 10:00 refs
-rw-r--r-- 1 user staff 23 Feb 3 10:00 HEAD
-rw-r--r-- 1 user staff 137 Feb 3 10:00 config
-rw-r--r-- 1 user staff 73 Feb 3 10:00 description理解核心机制只需关注四个部分:objects、refs、HEAD 和 index。
objects 目录
objects 目录存储 Git 内部所有以内容寻址的对象——包括 blob、tree、commit 和 tag。每个对象以自身内容的 SHA-1 哈希值命名,并按“前两位 / 剩余三十八位”的两级目录结构存放。
刚初始化的仓库 objects 下只有 pack 和 info 两个空子目录,没有任何数据对象:
bash
$ find .git/objects -type f
# 无输出创建文件并提交后,目录下会出现两个哈希对象:
bash
$ echo "Hello" > README.md
$ git add README.md
$ git commit -m "first commit"
$ find .git/objects -type f
.git/objects/cf/08dd3b5d8e6d6c8f6022b96f0c2a3f3f1d55a6e2
.git/objects/5e/1c309d2a9eb7e8da16c0a72a5f50f23b0c54c8这两个对象分别是 blob(文件内容)和 commit。commit 内部引用了一棵 tree 对象,tree 再指向 blob。所有对象都存储在 objects 目录中。
refs 目录
哈希值不便于直接使用,refs 目录存放的是具名指针,将分支、标签等映射到具体的提交哈希。
refs/heads/<branch>:分支引用,内容为该分支最新提交的 SHA-1。refs/tags/<tag>:标签引用,可直接指向提交,也可指向 tag 对象。refs/remotes/<remote>/<branch>:远程跟踪分支。
上述提交完成后,refs/heads/master 文件中保存的就是最新提交的哈希:
bash
$ cat .git/refs/heads/master
cf08dd3b5d8e6d6c8f6022b96f0c2a3f3f1d55a6e2文件内容仅为一个哈希字符串,没有其他格式或换行。
HEAD 文件
HEAD 通常是一个符号引用,指向当前检出的分支:
bash
$ cat .git/HEAD
ref: refs/heads/master当使用 git checkout <commit> 进入“分离头指针”状态时,HEAD 会直接存放一个提交哈希,不再引用任何分支。
index 文件
.git/index 是一个二进制文件,记录了暂存区的所有条目:文件名、权限、时间戳以及对应的 blob 哈希。git add 在将文件内容写入 blob 的同时,把(路径,blob)的映射写进 index。后续的 git commit 则基于 index 生成 tree 对象,进而创建新的 commit。
对象模型
Git 的核心存储模型由四类对象构成:blob、tree、commit 和 tag(轻量标签仅为引用,附注标签才会产生 tag 对象)。日常最常接触的是前三种。
- blob:存储文件内容,不包含文件名或路径。相同内容无论出现在多少文件或多少次提交中,都只存储一次。
- tree:对应目录结构,记录一组名字、模式(如
100644普通文件、100755可执行文件、040000子目录)以及指向 blob 或子 tree 的哈希。 - commit:保存一个版本快照的元数据。包含指向顶层 tree 的引用、作者、提交者、时间戳、提交说明,以及零个或多个父提交。
三者的关系可以表示为:commit → tree → (tree) → blob,每一层都通过哈希引用下一层。
从一个空仓库开始创建文件并提交:
bash
$ mkdir example && cd example && git init
$ echo "Hello Git" > hello.txt
$ git add hello.txt
$ git commit -m "first commit"
[master (root-commit) 7e5a7f4] first commit
1 file changed, 1 insertion(+)
create mode 100644 hello.txt查看最新提交对象的原始内容:
bash
$ git cat-file -p HEAD
tree 4b8c9e0d2e6a9f8c7d6e5f4a3b2c1d0e9f8a7b6c
author Alice <alice@example.com> 1706928000 +0800
committer Alice <alice@example.com> 1706928000 +0800
first commit该 commit 引用了一棵 tree 对象(不是空树的 4b825dc642cb6eb9a060e54bf8dc8a69a2d4a8b2),进一步查看这棵树:
bash
$ git ls-tree HEAD
100644 blob a5c8d6f7b8e9d4c3a2b1f0e9d8c7b6a5f4e3d2c1 hello.txt顶层 tree 仅包含一个条目,指向表示 hello.txt 内容的 blob。commit 本身不直接保存任何文件内容。
提交对象的内部结构
每次执行 git commit,Git 会按以下顺序完成操作:
- 将
index中记录的目录结构序列化为一棵(或多棵)tree 对象。 - 创建 commit 对象,写入以下字段:
- tree:上一步产生的顶层 tree 哈希。
- parent(s):上一个提交的哈希(多个
parent行代表合并提交)。 - author:原始修改者与时间戳。
- committer:实际创建提交的人与时间戳(cherry-pick 或 rebase 等操作会导致两者不同)。
- message:提交说明。
- 将 commit 对象写入
objects数据库,并更新当前分支引用。
例如,一个根提交没有 parent 行:
bash
$ git cat-file -p 7e5a7f4
tree 4b8c9e0d2e6a9f8c7d6e5f4a3b2c1d0e9f8a7b6c
author Alice <alice@example.com> 1706928000 +0800
committer Alice <alice@example.com> 1706928000 +0800
first commit当仓库有多次提交时,后续提交会带有一个 parent 字段指向其父提交:
bash
$ git log --oneline
7e5a7f4 first commit
$ echo "second line" >> hello.txt && git add hello.txt && git commit -m "second"
$ git cat-file -p HEAD
tree 1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f
parent 7e5a7f4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f
author Alice <alice@example.com> 1706928100 +0800
committer Alice <alice@example.com> 1706928100 +0800
second提交历史链就是通过 parent 字段串联而成的。交互式变基、压缩合并等操作会改写该字段,但机制本身不变。
SHA-1:内容即地址
Git 以对象内容的 SHA-1 哈希作为寻址标识,即“内容寻址”存储。计算方法:先构造头部字符串 "{type} {length}\0",拼接对象内容,再计算 SHA-1 哈希。
以前面 hello.txt 的 blob 为例,手动验证:
bash
$ printf "blob 10\0Hello Git\n" | shasum -a 1
a5c8d6f7b8e9d4c3a2b1f0e9d8c7b6a5f4e3d2c1 -git hash-object 也能直接计算:
bash
$ echo -n "Hello Git\n" | git hash-object --stdin
a5c8d6f7b8e9d4c3a2b1f0e9d8c7b6a5f4e3d2c1其中的 10 是 "Hello Git\n" 的字节数。若内容为 "Hello Git"(无换行),长度则为 9。
对象名称由内容决定,内容不变则哈希不变,这带来两个基础保证:
- 完整性校验:读取对象后重新计算哈希并与文件名比对,任何数据损坏都会被立即发现。
- 自动去重:相同内容的文件在仓库中只保留一份 blob 对象,不论出现在多少路径或提交中。
SHA-1 的密码学强度已受到挑战,但 Git 在版本控制场景中的安全更多地依赖其抗碰撞性。社区正在推进向 SHA-256 的过渡,不过现有仓库仍以 SHA-1 为主。
HEAD 与引用:当前状态的锚点
HEAD 告诉 Git “当前所在位置”。它的取值有两种形式:
- 符号引用:
ref: refs/heads/main,表示当前检出了分支。执行git commit时,新提交会更新该分支引用,HEAD仍然指向分支。 - 直接引用:直接存放一个提交哈希,即分离头指针(detached HEAD)状态。此时
HEAD文件内容不含ref:前缀。
进入分离头指针状态:
bash
$ git checkout 7e5a7f4
You are in 'detached HEAD' state...
$ cat .git/HEAD
7e5a7f4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f在分离头指针状态下创建的提交不会被任何分支引用,一旦切换到其他分支,这些提交只能通过 git reflog 找回,垃圾回收后可能永久丢失。因此实验性修改建议新建临时分支,而非长期处于 detached HEAD 状态。
refs/heads/ 下的分支文件构成了引用的另一核心用途。git branch <name> 的原理就是在 refs/heads/ 下创建一个包含当前 HEAD 提交哈希的文件;git branch -d 则删除该文件。整个分支的创建和删除不涉及任何额外的数据库操作。
暂存区:从工作树到提交的桥梁
暂存区(index)承担两个职责:
- 界定哪些修改将进入下一次提交。
- 充当即将生成的 tree 对象的草案。
git add 将文件内容生成 blob 存入 objects,同时把 (路径, blob) 的记录更新到 index。git commit 读取 index,创建 tree 对象来描述那一刻的目录结构快照,然后创建 commit 并更新分支引用。
查看 index 内容:
bash
$ git ls-files --stage
100644 a5c8d6f7b8e9d4c3a2b1f0e9d8c7b6a5f4e3d2c1 0 hello.txt每一行包含模式、blob 哈希、阶段编号(用于合并冲突)和文件名。这些正是生成 tree 对象的原始数据——tree 对象只是将这些条目以树形结构组织起来。
当工作区文件状态与 index 不一致时,Git 能同时感知到“待暂存”和“待提交”的变更。git status 就是通过比较 HEAD 的 tree、index 和工作目录来输出结果的。在这个视角下,git add 的“暂存”概念就变得清晰:它将工作区的变动复制到下一次提交的草案中,并不直接影响提交历史。
查看对象与引用的底层命令
常用的 git log、git branch 等命令都是对底层命令的封装。直接使用底层命令可以更清楚地观察 Git 如何组织数据。
git cat-file 查看对象类型、大小和内容:
bash
$ git cat-file -t HEAD # 输出对象类型,如 commit
$ git cat-file -p HEAD # 格式化输出对象内容
$ git cat-file -s HEAD # 对象大小(字节)git ls-tree 列出树对象的内容:
bash
$ git ls-tree HEAD # 当前提交的顶层树
$ git ls-tree -r HEAD # 递归列出所有子树和 blobgit rev-parse 将符号引用解析为提交哈希:
bash
$ git rev-parse HEAD
7e5a7f4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f
$ git rev-parse master
7e5a7f4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0fgit show-ref 列出所有本地引用及哈希:
bash
$ git show-ref
7e5a7f4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f refs/heads/mastergit ls-files --stage 是查看 index 的直接入口。index 为二进制文件,不能直接用文本编辑器阅读。
这些命令不会改变仓库状态,适合在探索和调试时使用。当怀疑提交内容或分支指向错误时,它们比图形界面更精确。
注意点
.git/index损坏会导致暂存区丢失。虽然可以尝试通过git reset或手动编辑恢复,但更好的习惯是定期使用git stash临时保存未提交的改动。- 仓库变大后,
git gc会自动将objects目录下的多个 loose 对象打包进.git/objects/pack中的 pack 文件。此时通过哈希直接查找对象仍然有效,但直接遍历目录看到的文件数会大幅减少。 - 手动修改
.git/refs/heads/下的文件可以强行移动分支指针,但这可能导致提交链悬空,最终被垃圾回收清理。 - 分离头指针状态下创建的提交若无引用指向,会在离开该状态后的一段时间被垃圾回收。可临时通过
git reflog找回,但不应长期依赖此机制。 - SHA-1 的碰撞风险在恶意攻击场景下有所上升,但在正常的提交冲突场景下概率极低。在完全切换到 SHA-256 之前,使用 GPG 签名提交是增强可信度的有效补充手段。
