Git 内部原理
本章要点
.git目录结构- 四大对象类型:blob / tree / commit / tag
- 对象寻址:SHA-1 哈希与内容寻址
- 引用(refs):分支、HEAD、tag 的本质
- 索引(index):暂存区的真身
- 一次
git commit的底层完整流程 - 钩子(hooks)与常用扩展机制
- GC、pack 文件与性能
💡 本章讲的是 Git 的模型(model)而非具体实现细节。理解模型后,前面所有命令都变得"理所当然"。
.git 目录结构
text
.git/
├── HEAD # 指向当前分支的文件(如 ref: refs/heads/main)
├── config # 本仓库的 local 配置
├── description # 仓库描述(仅供 GitWeb 使用)
├── index # 暂存区(索引)的二进制文件
├── objects/ # 对象数据库(所有内容的存储地)
│ ├── info/
│ └── pack/ # 打包后的对象文件
├── refs/ # 引用(指针)
│ ├── heads/ # 本地分支(refs/heads/main)
│ ├── tags/ # 标签
│ └── remotes/ # 远程跟踪分支(refs/remotes/origin/main)
├── logs/ # reflog(HEAD 移动历史)
├── hooks/ # 钩子脚本
└── info/exclude # 仓库级忽略(类似 .gitignore)四大对象类型
Git 的一切数据都是对象,共四种:
| 对象 | 存储内容 | 类比 |
|---|---|---|
| blob | 一个文件的内容(不含文件名) | 文件的内容本身 |
| tree | 一个目录的清单:文件名 → blob/tree | 目录/文件夹 |
| commit | 快照元信息:指向 tree、父提交、作者、提交信息 | 快照的"存档说明" |
| tag(附注标签) | 指向某提交的固定引用及说明 | 里程碑 |
对象之间的关系
text
commit(提交) ← 只记录"我基于谁、指向哪个快照、谁写的、写了什么"
│ 指向
▼
tree(目录快照)
├── "README.md" → blob abc123... ← blob 存文件内容
├── "src/" → tree def456... ← 子目录是另一个 tree
└── "config.js" → blob 7890ab...
blob(文件内容,不含文件名)💡 关键认知 1:文件名存在 tree 里,内容存在 blob 里。两个不同名字的文件内容相同,会共享同一个 blob。 💡 关键认知 2:commit 只存元信息(tree 指针 + 父指针 + 作者 + 信息),所以"提交"本身极轻。
内容寻址:SHA-1 哈希
每个对象以内容本身计算 SHA-1 哈希作为文件名(前 2 位作目录名,后 38 位作文件名):
text
$ echo "hello" | git hash-object --stdin
# b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0- 相同内容 → 相同哈希 → 同一对象(这就是"快照去重"的原理)。
- 内容一变 → 哈希大变 → 必然生成新对象。
- 由此保证数据完整性:任何篡改都会导致哈希不匹配,Git 立即发现。
bash
$ git cat-file -t <哈希> # 查看对象类型
$ git cat-file -p <哈希> # 查看对象内容引用(refs):分支的真相
分支 = 一个指向提交的指针(前面说过),它存储在 refs/heads/ 下:
text
refs/heads/main → 8f4a2c9...(一个 40 位十六进制哈希)bash
$ cat .git/refs/heads/main # 直接查看分支指向的哈希
$ git rev-parse main # 等价命令
$ git symbolic-ref HEAD # 查看 HEAD 指向哪个分支HEAD:当前在哪
HEAD是一个特殊引用,通常指向一个分支(间接),如ref: refs/heads/main。- 检出某次提交时 HEAD 直接指向提交 → 这就是 detached HEAD(第 6 章讲过)。
常见引用速查
| 引用 | 含义 |
|---|---|
HEAD | 当前所在位置 |
HEAD~1 | HEAD 的父提交(~ 往前找父链) |
HEAD~3 | 往前第 3 个祖先 |
HEAD^2 | 合并提交的第二个父提交(^ 按父编号取) |
origin/main | 远程跟踪分支(上次 fetch 时 origin 上 main 的位置) |
main@{1} | main 的上一个位置(reflog 语法) |
索引(index):暂存区的真身
- 暂存区在磁盘上就是
.git/index这个二进制文件。 - 它记录:每个被跟踪文件的路径、blob 哈希、文件模式、时间戳等信息。
git add= 更新 index 中该文件对应的条目;git commit= 把 index 的内容固化成 tree + commit。
一次 git commit 的完整旅程
text
1. 你修改 file.txt
2. git add file.txt
→ 把新内容写成一个 blob 对象(objects/)
→ 更新 .git/index 中 file.txt 的条目指向新 blob
3. git commit -m "说明"
a. 根据 index 生成 tree 对象(目录快照)
b. 创建 commit 对象:
{ tree: <刚生成的tree哈希>,
parent: <当前 HEAD 指向的提交>,
author / committer: 你的身份+时间,
message: "说明" }
c. 把 commit 的哈希写入 refs/heads/main(分支指针前移)
d. HEAD 仍然指向 main(main 指向新 commit)看懂了吗?"提交"的本质就是:写对象 + 移动分支指针。 所有"撤销/回滚/重写历史"操作,底层都是在移动指针、生成/废弃对象。
钩子(hooks):自动化扩展
钩子是 .git/hooks/ 下的可执行脚本,在特定时机自动触发:
bash
$ ls .git/hooks/
# applypatch-msg.sample pre-commit.sample pre-push.sample ...
# (.sample 后缀 = 未启用模板)常用钩子
| 钩子 | 触发时机 | 典型用途 |
|---|---|---|
pre-commit | commit 前 | 运行 lint、格式化检查、禁止提交密钥 |
commit-msg | 提交信息写好之后 | 校验提交信息是否符合规范 |
pre-push | push 前 | 运行测试,失败则阻止推送 |
post-commit | commit 后 | 通知、CI 触发 |
bash
# 启用钩子:去掉 .sample 后缀并设为可执行
$ mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
$ chmod +x .git/hooks/pre-commit⚠️ 钩子存于
.git/hooks/(本地,不随仓库共享)。团队共享钩子可用 Husky(npm)等工具或提交到仓库后通过脚本安装。
GC 与 pack 文件:Git 如何省空间
- 对象先以"松散(loose)"形式逐个存储。
- 随着对象增多,
git gc(自动触发)会把对象打包(pack):- 相同/相似内容做增量压缩(delta compression),只存差异。
- 生成
.pack二进制文件 +.idx索引。
git gc还会清理:reflog 过期的条目、悬空(dangling)对象、未引用的 pack。
bash
$ git gc # 手动执行垃圾回收与打包
$ git count-objects -v # 查看对象统计
$ git fsck # 检查仓库完整性(找悬空对象等)💡 理解了"对象不可变 + 引用可移动"这两个铁律,你就能理解 Git 几乎全部行为:
- 为什么 commit 哈希会变(内容变了 / 父指针变了 → 新哈希)
- 为什么 rebase 是"复制提交"而不是"修改提交"(旧提交对象还在,只是没人引用了)
- 为什么"删掉的分支"还能找回(引用删了,对象还在,直到 gc)
理解"三个铁律"总结
- 对象不可变:内容一经写入,永不被修改(只能产生新对象)。
- 引用可移动:分支、HEAD、标签(轻量)都只是指针。
- 一切皆可找回(在 gc 之前):reflog + 悬空对象是后悔药的底层保障。
