引言
天天用 git add、git commit,却说不清"提交到底是什么"——这导致很多操作只能背命令、不能推理。Git 本质上是一个内容寻址的键值存储:你往里放内容,它给你一个哈希;分支不过是"指向某个提交的可移动指针";rebase 不是"重写历史"的魔法,而是"复制提交再移动指针"。理解这层,detached HEAD、reflog 救援、reset 三模式就都成了推理题。本文从 SHA 与四种对象讲起,一路讲到 packfile、merge/rebase 的本质。
前置:diff/patch 算法与工具:Myers 差异算法、补丁格式与版本控制、哈希表与一致性哈希:哈希的两副面孔。字节层面见 二进制与编码工具。
目录
- 1. 内容寻址:SHA 与四种对象
- 2. blob、tree、commit、tag 的结构
- 3. 引用与 refs:分支、标签与 HEAD
- 4. 索引与暂存区:三步状态
- 5. packfile 与 delta 压缩
- 6. plumbing 命令:cat-file、hash-object、rev-parse
- 7. merge 与 rebase 的本质
- 8. 引用日志与 reflog 救援
- 9. 垃圾回收与对象生命周期
- 10. 速查表与一句话记忆
- 延伸阅读
1. 内容寻址:SHA 与四种对象
Git 是一个键值数据库:键是内容的 SHA-1 哈希(新版本可选 SHA-256),值是对象内容。同样的内容永远得到同样的键——这是"内容寻址"的核心。
键 = SHA1(类型 + 空格 + 长度 + \0 + 内容)
值 = 对象内容(zlib 压缩后存于 .git/objects/)
四种对象类型:
| 类型 | 存什么 | 例 |
|---|---|---|
| blob | 文件内容(不含文件名) | 一个文件的字节 |
| tree | 目录(文件名 → blob/tree) | 一个目录 |
| commit | 一次提交(指向 tree + 父提交) | 快照 |
| tag | 附注标签(指向对象 + 说明) | 版本发布 |
关键洞察:blob 只存内容,不存文件名——文件名在 tree 里。所以两个内容相同的文件(不同名)共享同一个 blob,去重是免费的。
echo "hello" > a.txt
echo "hello" > b.txt
git add a.txt b.txt
git ls-files -s # 两个文件指向同一个 blob 哈希
哈希的两个作用:寻址(内容即地址)与完整性校验(任何字节改动都会改变哈希,篡改可被发现)。
记忆:Git 是内容寻址的 KV 存储——键是 SHA,blob 只存内容不存文件名,所以同名内容自动去重。
2. blob、tree、commit、tag 的结构
blob:纯内容,无元数据。
git cat-file -p <blob-hash>
# hello
tree:目录列表,每行是 模式 类型 哈希\t文件名。
git cat-file -p HEAD^{tree}
# 100644 blob a1b2c3... README.md
# 100755 blob d4e5f6... run.sh
# 040000 tree 789abc... src
模式含义:100644 普通文件、100755 可执行、040000 目录、120000 符号链接。
commit:指向一个 tree(该时刻的完整快照)+ 父提交 + 作者/提交者 + 消息。
git cat-file -p HEAD
# tree 789abc...
# parent 456def... ← 根提交没有 parent
# author Leeting Yan <x@y.z> 1759600000 +0800
# committer Leeting Yan <x@y.z> 1759600000 +0800
#
# feat: add something
注意:commit 不存 diff,存的是完整快照的 tree 引用——diff 是按需计算的(见 diff/patch 算法)。“Git 存差异"是最常见的误解。
tag:附注标签是一个对象,含指向对象(object 456def...)、类型(type commit)、标签名、tagger、说明与签名(可选)。轻量标签 vs 附注标签:轻量标签只是"一个 ref 指向 commit”,附注标签是独立对象(含元数据、可签名)。
记忆:commit 存快照(tree 引用)不存 diff——diff 是算出来的;tree 里才有文件名,blob 只有内容;附注 tag 是独立对象,轻量 tag 只是 ref。
3. 引用与 refs:分支、标签与 HEAD
ref(引用)就是一个文件,内容是 40 位 SHA:
cat .git/refs/heads/main
# 456def789... ← main 分支指向这个 commit
cat .git/HEAD
# ref: refs/heads/main ← HEAD 指向 main(符号引用)
分支 = 指向 commit 的可移动指针——这句话的物理含义就是:.git/refs/heads/<name> 文件里存着一个 SHA。
HEAD 的两种形态:
符号引用:ref: refs/heads/main → HEAD 跟随分支
游离头 :456def789... → HEAD 直接指向 commit(detached HEAD)
detached HEAD 不是错误:git checkout <commit> 会进入游离态——HEAD 直接指向提交,此时新提交不属于任何分支(切走就可能丢,靠 reflog 救)。
refs 的层级:
refs/heads/* → 本地分支
refs/remotes/* → 远程跟踪分支(origin/main)
refs/tags/* → 标签
HEAD → 当前所在(符号引用或直接 SHA)
查看引用:git show-ref 列出所有 ref;git rev-parse HEAD 看 HEAD 的 SHA;git rev-parse --abbrev-ref HEAD 看当前分支名(detached 时是 HEAD);git symbolic-ref HEAD 看 HEAD 指向的 ref。
记忆:分支就是 .git/refs/heads/ 里的一个文件(内容为 SHA)——HEAD 是"指向分支的符号引用",直接指 SHA 就是 detached。
4. 索引与暂存区:三步状态
Git 的三棵树是理解 add/commit/reset 的钥匙:
工作区(Working Directory) ← 你编辑的文件
暂存区(Index / Staging) ← git add 后的快照
HEAD(最后一次提交) ← git commit 后的快照
| 命令 | 效果 |
|---|---|
git add f | 工作区 → 暂存区 |
git commit | 暂存区 → HEAD(新提交) |
git reset --soft | HEAD 移动,暂存区/工作区不变 |
git reset --mixed | HEAD 移动 + 暂存区重置(默认) |
git reset --hard | HEAD 移动 + 暂存区 + 工作区全重置 |
git checkout f | 暂存区/HEAD → 工作区 |
Index 是二进制文件 .git/index,记录每个文件的路径、blob 哈希、stat 信息(mtime/size)——stat 信息让 git status 不必重算所有文件哈希(快)。
git ls-files -s # 看 index 内容(模式 blob 哈希 路径)
git status --short # 工作区 vs 暂存区 vs HEAD 的差异
为什么"暂存区"重要:它让"提交"成为显式选择——你可以只提交部分改动(git add -p 交互式选择 hunk),这是 Git 区别于早期版本控制的杀手锏。
git diff 的两个版本常被混淆:git diff 是"工作区 vs 暂存区",git diff --staged 是"暂存区 vs HEAD"——git commit 提交的正是 --staged 看到的。
记忆:三棵树——工作区/暂存区/HEAD;add 是工作区→暂存区、commit 是暂存区→HEAD;
git diff看工作区 vs 暂存区、git diff --staged看暂存区 vs HEAD。
5. packfile 与 delta 压缩
松散对象(.git/objects/ab/cdef...)每个文件一个 zlib 压缩对象——数量一多,磁盘与 IO 都不划算。
packfile 把大量对象打进一个文件,并做delta 压缩:相似的 blob 只存"基准 + 差异"。
git gc # 触发打包
ls .git/objects/pack/
# pack-xxx.pack ← 对象数据
# pack-xxx.idx ← 索引(按哈希查偏移)
delta 压缩 vs 通用压缩:
通用压缩(zlib):对单个对象内部压缩
delta 压缩 :对"对象之间"压缩——存新对象与旧对象的差异
delta 链:一个对象可能基于另一个(被 delta 化),后者又基于更早的——形成delta 链。Git 会限制链长(默认 depth 50),避免读取一个对象要解压一长串。
.idx 文件:packfile 的索引,支持按哈希 O(log n) 查找对象偏移——没有它就得线性扫描。
git verify-pack -v .git/objects/pack/pack-xxx.idx | head
# 输出每对象的类型、大小、delta 基准、深度
重新打包:git repack -adf --depth=50 --window=250 或 git gc --aggressive(更激进但慢)。
为什么 clone 比 .git 目录小:服务端把对象打包 + delta 压缩 + 只传可达对象——网络传输的是 packfile。
记忆:packfile 把对象打包并对"对象之间"做 delta 压缩(存基准 + 差异),.idx 提供按哈希的快速查找——clone 传的就是 packfile。
6. plumbing 命令:cat-file、hash-object、rev-parse
plumbing(底层)vs porcelain(高层):git status/commit 是 porcelain;cat-file/hash-object 是 plumbing——porcelain 是 plumbing 的组合。
# hash-object:算内容的哈希(不写库用 -n,写库不加)
echo "hello" | git hash-object --stdin
# ce013625030ba8dba906f756967f9e9ca394464a
# 写进对象库
echo "hello" | git hash-object -w --stdin
# cat-file:看对象
git cat-file -t <hash> # 类型(blob/tree/commit/tag)
git cat-file -s <hash> # 大小
git cat-file -p <hash> # 内容(美化)
手动造一个提交(理解 Git 的最好练习):
blob=$(echo "hello" | git hash-object -w --stdin)
tree=$(printf '100644 blob %s\thello.txt\n' "$blob" | git mktree)
commit=$(git commit-tree "$tree" -m "manual commit")
git update-ref refs/heads/manual "$commit" # 造一个分支指向它
rev-parse:解析引用为 SHA——git rev-parse HEAD 当前提交 SHA;main~3 往前 3 个提交;main^{tree} 取 tree;--verify main 验证 ref 存在。
修订语法(revision syntax)速查:
| 语法 | 含义 |
|---|---|
HEAD~2 | 往前 2 个提交(第一父) |
HEAD^2 | 合并提交的第 2 个父 |
HEAD@{1} | reflog 的上一个位置 |
main..feature | feature 有而 main 没有的提交 |
main...feature | 两者分叉后的差异 |
git log --oneline main..feature # feature 领先的提交
git diff main...feature # 分叉点以来的差异
记忆:porcelain 是 plumbing 的组合——hash-object 算/写对象、cat-file 看对象、mktree/commit-tree 造对象、rev-parse 解析引用;
..是"差集"、...是"分叉点以来"。
7. merge 与 rebase 的本质
merge:创建一个新的合并提交,有两个父。
A---B---C main
\
D---E feature
merge 后:
A---B---C---M main(M 有父 C 和 E)
\ /
D---E
git merge feature # 产生合并提交(若非快进)
git merge --no-ff feature # 强制产生合并提交
快进合并(fast-forward):若 main 是 feature 的祖先,直接移动指针即可——不产生新提交。
rebase:把 feature 的提交"复制"到 main 上,再移动指针。
rebase 前:
A---B---C main
\
D---E feature
rebase 后(git rebase main):
A---B---C---D'---E' feature(D' E' 是新提交,SHA 变了)
关键:D'、E' 是全新的提交(内容相似但父不同 → SHA 不同)——“rebase 重写历史"的本质就是"造新提交 + 移动指针”。
git rebase main # 把当前分支变基到 main
git rebase -i HEAD~3 # 交互式:合并/重排/编辑/删除
merge vs rebase 的取舍:
| 维度 | merge | rebase |
|---|---|---|
| 历史 | 保留分叉(真实) | 线性(整洁) |
| SHA | 原提交不变 | 提交被复制(新 SHA) |
| 冲突 | 一次解决 | 逐提交解决 |
| 共享分支 | 安全 | 危险(重写已推送历史) |
黄金法则:只对"未推送的本地提交"rebase——对已推送的公共分支 rebase 会让别人拉取时冲突。“Never rebase public history”。
cherry-pick 是"复制单个提交":
git cherry-pick <commit> # 把某提交的改动复制到当前分支
记忆:merge 造合并提交(两父)、rebase 复制提交(新 SHA)——本质都是"造对象 + 移指针";只对未推送的提交 rebase,公共历史绝不重写。
8. 引用日志与 reflog 救援
reflog 记录 HEAD 与分支的每次移动——是"后悔药"。
git reflog
# 456def7 HEAD@{0}: commit: feat
# 123abc4 HEAD@{1}: rebase: moving to main
# 789ghi0 HEAD@{2}: checkout: moving from main to feature
reflog 能救什么:reset --hard 后找回提交(git reset --hard HEAD@{1});rebase 搞乱后回到之前(HEAD@{2});误删分支(git branch recovered <sha>);detached HEAD 提交丢失(reflog 找 SHA 再建分支)。
reflog 的局限:只记录本地操作(不记录远程)、默认保留 90 天(可达对象 30 天)、不记录"未提交"的内容——没 add 的文件丢了 reflog 救不了(要靠编辑器/IDE)。
分支的 reflog:git reflog show main 看 main 的移动历史,物理文件在 .git/logs/refs/heads/main。
reflog 是引用日志不是历史:它记录"ref 曾指向哪",而 git log 走的是提交图的父链——两者不同。
记忆:reflog 是本地 ref 移动的后悔药——reset/rebase/删分支都能救(
HEAD@{n}),但它不记录远程、默认 90 天、救不了未 add 的内容。
9. 垃圾回收与对象生命周期
对象何时被删:不可达 + 超过宽限期。
可达性:从 refs(分支/标签)、HEAD、reflog、index 出发能走到的对象 = 可达
不可达对象:rebase 后的旧提交、reset 丢的提交
宽限期:默认 2 周(gc.pruneExpire)——给 reflog 留救援窗口
gc 做什么:git gc 打包松散对象 + 清理不可达 + 压缩 reflog;git gc --prune=now 立即清理不可达(危险,失去救援窗口);git count-objects -v 看对象统计(松散/打包/垃圾)。
悬空对象(dangling):git fsck --lost-found 找悬空对象(输出 dangling commit 456def...、dangling blob a1b2c3...)。
找回"未提交但 add 过"的内容:git fsck --lost-found 能列出悬空 blob,配合 git cat-file -p 查看内容。
大文件问题:一旦提交,大文件永久留在历史(除非重写历史)。git filter-repo / BFG 可清理:
git filter-repo --path big.bin --invert-paths # 从历史彻底移除
submodule 与 LFS:大二进制用 Git LFS(指针入库、内容存远端),避免仓库膨胀。
记忆:对象被删 = 不可达 + 过宽限期(默认 2 周);git gc 打包并清理,fsck 找悬空对象救援;大文件一旦提交就永久留在历史,要用 filter-repo 或 LFS。
10. 速查表与一句话记忆
全篇速查:
| 主题 | 结论 |
|---|---|
| 寻址 | 内容寻址,键 = SHA(类型 + 长度 + 内容) |
| 对象 | blob(内容)、tree(目录)、commit(快照)、tag(附注) |
| commit | 存快照 tree 引用,不存 diff |
| 分支 | refs/heads 里存 SHA 的文件 |
| HEAD | 符号引用(跟分支)或直接 SHA(detached) |
| 三棵树 | 工作区 / 暂存区 / HEAD |
| diff | git diff = 工作区 vs 暂存区,--staged = 暂存区 vs HEAD |
| packfile | 打包 + delta(对象间差异)+ .idx 索引 |
| plumbing | hash-object / cat-file / mktree / commit-tree / rev-parse |
| merge/rebase | merge 造合并提交、rebase 复制提交移指针 |
| reflog | 本地 ref 移动的后悔药,默认 90 天 |
| gc | 不可达 + 过宽限期才删,默认 2 周 |
一句话记忆:Git 是内容寻址的 KV 存储——键是 SHA,blob 只存内容(文件名在 tree 里,所以同名内容自动去重),commit 存的是完整快照的 tree 引用而非 diff;分支就是 refs/heads 里存 SHA 的一个文件,HEAD 是"指向分支的符号引用"、直接指 SHA 就是 detached;三棵树(工作区/暂存区/HEAD)解释了 add/commit/reset 的一切,git diff 看工作区 vs 暂存区、--staged 看暂存区 vs HEAD;packfile 对"对象之间"做 delta 压缩 + .idx 提供快速查找;merge 造合并提交(两父)、rebase 复制提交(新 SHA)——本质都是"造对象 + 移指针",只对未推送的提交 rebase;reflog 是本地 ref 移动的后悔药(默认 90 天),对象要"不可达 + 过 2 周宽限期"才被 gc 删;porcelain 全是 plumbing 的组合——用 cat-file/hash-object/rev-parse 亲手造一个提交,你就真正懂 Git 了。
延伸阅读
- diff/patch 算法与工具:Myers 差异算法、补丁格式与版本控制
- 哈希表与一致性哈希:哈希的两副面孔
- 二进制与编码工具:Base64、Hex、xxd 与字节调试
- DevOps 专题 — CI/CD 与版本控制实践
- GitHub Actions 专题 — 基于 Git 的自动化工作流
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。