03-Git 核心机制
会用 git add git commit 不等于懂 Git。真正能驾驭 Git 的人——遇到冲突知道为什么、误删分支能找回、合并策略能选对——背后靠的都是对 Git 内部模型的理解。本章把 Git 拆成三个层次讲透:三区模型、对象存储、引用(ref)系统。
三区模型#
Git 的"工作区 / 暂存区 / 仓库"三区是它区别于 SVN 等老一代 VCS 的关键设计。SVN 只有"工作区"和"仓库"两区,改了文件直接 commit;Git 在中间塞了一个"暂存区",让开发者可以选择性提交。
┌──────────────┐ git add ┌──────────────┐ git commit ┌──────────────┐
│ 工作区 │ ──────────► │ 暂存区 │ ───────────► │ 仓库 │
│ Working Tree │ │ Index/Stage │ │ Repository │
│ │ ◄────────── │ │ ◄─────────── │ │
└──────────────┘ git checkout └──────────────┘ git reset └──────────────┘
│ │
│ git fetch / git pull / git push │
└──────────────────────── 远程仓库 ◄───────────────────────────┘类比:工作区是"草稿纸",暂存区是"待交作业的篮子",仓库是"已交作业存档"。你可以把草稿纸上的一部分内容放进篮子,剩下的继续改;篮子里的东西才是下次 commit 会存档的内容。
三个区的实际命令#
# 工作区 → 暂存区:选择性 stage
git add file1 file2 # 整文件 stage
git add -p # 按 hunk 选择性 stage(Git 的杀手锏)
# 暂存区 → 仓库:创建 commit
git commit -m "feat: add login"
git commit --amend # 修改最近一次 commit(未 push 时安全)
# 仓库 → 暂存区:把 commit 撤回暂存区(保留改动)
git reset --soft HEAD~1
# 仓库 → 工作区:彻底丢弃最近一次 commit(改动也丢)
git reset --hard HEAD~1
# 暂存区 → 工作区:把暂存区的某文件恢复到工作区
git restore --staged file # 新命令(Git 2.23+),等价于 git reset HEAD filegit add -p(patch mode)是 Git 区别于其他 VCS 的杀手锏——它让你按代码块(hunk)选择性 stage。改了 5 个文件、10 个 hunk,你可以只把相关的 3 个 hunk 放进暂存区,组成一个语义清晰的 commit。这种粒度是 SVN 时代没有的。
为什么需要暂存区#
没有暂存区,"改了什么"和"提交了什么"是同一件事。这导致两个问题:
- commit 颗粒度等于文件颗粒度——一个文件里混了两类改动(bug 修复 + 重构),没法只提交其中一类
- 无法"先改一部分验证,再改另一部分"——所有改动都在工作区,一 commit 就全进去了
暂存区解决了这两个问题,代价是多了一步 git add。这个代价是值得的——它把"提交"从一个机械动作变成了一次有意识的编排。
对象存储:四类 object#
Git 仓库的所有数据都存在 .git/objects/ 目录下,形式是内容寻址的不可变对象。对象类型只有四类,理解了这四类就理解了 Git 的数据模型。
| 对象类型 | 存储内容 | 类比 |
|---|---|---|
| blob | 文件内容(不包含文件名) | 一个文件的字节流 |
| tree | 目录结构(文件名 + blob/tree 的引用) | 一个目录的 listing |
| commit | 一棵 tree + 父 commit + 作者/时间/消息 | 一次快照 |
| tag | 指向某个 commit 的"命名书签"(带说明) | 一个有名字的标签 |
内容寻址:ID = 内容的 SHA-1#
每个对象有一个 40 字符的 SHA-1 hash 作为唯一 ID。这个 ID 不是分配的,是算出来的——把对象内容做 SHA-1,前 2 位做目录名、后 38 位做文件名:
.git/objects/ab/cdef1234567890... ← 文件内容 SHA-1 = abcdef1234...关键性质:同样内容在任何机器上算出的 ID 相同。这就是为什么 Git 是分布式的——两个独立的 clone,对同一段代码算出的 commit hash 永远一致,不需要中央服务器协调。
完整性校验:因为 ID = 内容 hash,任何篡改都会让 ID 变化。commit ID 是整个仓库历史的 hash chain——伪造任何一段历史都会让后续所有 commit ID 失效。这一招同时解决了"完整性校验 + 去重 + 跨机器一致性"三件事。
注:2017 年 SHA-1 被证明可碰撞(Shattered 攻击),Git 正在向 SHA-256 迁移。但截至 2026 年大多数生产仓库仍是 SHA-1,迁移成本高但安全性在当前算力下仍然够用。
blob 和 tree 的关系#
一个 blob 只存文件内容,不存文件名。文件名存在 tree 对象里:
tree a1b2c3d4
├── README.md → blob e5f6a7b8 (内容是 "# My Project")
├── src/
│ └── main.py → blob 9a0b1c2d (内容是 "print('hello')")
└── package.json → blob 3e4f5a6b (内容是 "{...}")同一个文件内容(比如两个目录下都有相同的 LICENSE 文件)只存一份 blob,被两个 tree 引用。这就是 Git 的去重——内容寻址天然带来去重,不用额外算法。
commit 的结构#
一个 commit 对象包含:
commit f1e2d3c4
tree a1b2c3d4 ← 这次提交对应的目录树
parent 9a8b7c6d ← 上一次提交(多个 parent = merge commit)
author 张三 <zhangsan@example.com> 1719000000 +0800
committer 张三 <zhangsan@example.com> 1719000000 +0800
feat: add user login page
Add login form and OAuth integration.注意 commit 存的是 tree 的快照,不是 diff。很多人误以为 Git 像某些 VCS 那样存增量 diff,其实每个 commit 都是一份完整的目录快照(通过 tree 递归引用所有 blob)。这样设计的好处是 checkout 速度快——不需要回放历史 diff,直接按 tree 取文件即可。
author vs committer:author 是写代码的人,committer 是实际提交的人。这在开源协作里很重要——patch 通过邮件发来,maintainer 提交时 author 保留原作者,committer 是 maintainer 自己。git log --format="%an %cn" 可以看到两者差异。
merge commit 有多个 parent(通常是两个)——合并两条分支历史时产生。git log --graph 能画出分叉和合并的拓扑结构。
用 plumbing 命令直看对象#
Git 命令分两层:porcelain(给人用的上层命令,如 git add git log)和 plumbing(给脚本用的底层命令)。用 plumbing 可以直接看到对象存储:
# 查看某个 commit 对象的原始内容
git cat-file -p HEAD
# 查看某个 tree
git cat-file -p HEAD^{tree}
# 查看对象类型
git cat-file -t <hash>
# 列出所有对象
git rev-list --all --objects这种"可裸读"的特性是 Git 设计哲学的体现——简单 = 可恢复 = 可信任。即使 porcelain 命令全坏了,懂 plumbing 也能手动恢复仓库。
HEAD、branch、ref#
如果说对象存储是 Git 的"数据层",那 ref 系统就是"指针层"。所有"分支"、"标签"、"当前所在位置"都是 ref。
ref 是什么#
ref 就是一个文本文件,内容是某个对象的 SHA-1:
.git/refs/heads/main ← 内容是:f1e2d3c4...(一个 commit 的 SHA-1)
.git/refs/heads/feature/login ← 内容是:9a8b7c6d...
.git/refs/tags/v1.0.0 ← 内容是:5e6f7a8b...branch 就是一个 ref,不是一份代码拷贝。这是 Git 和 SVN 最大的区别——SVN 的 branch 是服务器上的一棵目录拷贝(虽然用了 fsfs 优化),Git 的 branch 只是一个 40 字节的指针文件。所以 Git 里建分支是 O(1) 操作,可以随便开。
HEAD:指向"当前在哪"#
HEAD 是一个特殊的 ref,它指向"当前所在分支":
.git/HEAD ← 内容是:ref: refs/heads/main这叫 symref(symbolic reference)——HEAD 不是直接指向某个 commit,而是指向另一个 ref。当你 git checkout feature/login,HEAD 的内容变成 ref: refs/heads/feature/login。
如果 HEAD 直接指向一个 commit 而不是分支,叫 detached HEAD 状态——这时候提交不会进任何分支,切走就找不到(除非用 reflog 找回)。
ref 之间的关系图#
HEAD ─► refs/heads/main ─► commit f1e2d3c4 ─► tree a1b2c3d4 ─► blob e5f6a7b8
│
└─► parent 9a8b7c6d (上一个 commit)
│
└─► ...
refs/tags/v1.0.0 ─► commit 5e6f7a8b (历史中的某个 commit)所有"修改"操作本质都是创建新对象 + 移动 ref:
git commit:创建新 commit 对象 → 把当前分支的 ref 移到新 commitgit branch dev:创建一个新 ref 文件,指向当前 commitgit reset --hard HEAD~1:把当前分支的 ref 移到上一个 commit(commit 对象本身不删,变孤儿,等 GC)
这套"不可变对象 + 可变 ref"的设计是 Git 的核心范式。它的好处是所有修改都有历史可追、可回滚——git reflog 记录了所有 ref 移动,即使 reset 错了也能找回。
.git 目录结构#
把一个仓库的 .git 目录展开看,就能把前面讲的所有概念落到具体文件:
.git/
├── HEAD ← symref,指向当前分支
├── config ← 仓库级配置(remote、user 等)
├── objects/ ← 所有对象存储
│ ├── ab/cdef1234... ← loose object(按 SHA-1 前两位分散)
│ ├── pack/
│ │ ├── pack-xxx.idx ← packfile 索引
│ │ └── pack-xxx.pack ← 打包后的对象(delta 压缩)
│ └── info/
├── refs/
│ ├── heads/ ← 分支
│ │ ├── main
│ │ └── feature/login
│ ├── tags/ ← 标签
│ │ └── v1.0.0
│ └── remotes/ ← 远程跟踪分支
│ └── origin/main
├── logs/ ← reflog(所有 ref 移动历史)
│ ├── HEAD
│ └── refs/heads/main
├── index ← 暂存区(二进制文件)
└── packed-refs ← 打包后的 ref(大量 ref 时优化)几个关键观察:
objects/是仓库主体——所有 commit、tree、blob、tag 都在这里。clone 一个仓库本质上就是把这些对象拷过来。refs/是分支和标签——只是一堆文本文件。删一个分支就是删refs/heads/<name>这个文件。logs/是 reflog——救命用的。git reflog读的就是这里的文件,它记录了所有 ref 的移动,包括 reset、checkout、commit。即使git reset --hard误操作,只要 reflog 还在就能找回。index是暂存区——一个二进制文件,存的是"下次 commit 会写进仓库的内容"。
loose object 和 packfile#
对象存储有两种形式:
- loose object:每个对象一个文件(
objects/ab/cdef1234...),用 zlib 压缩。新建的 commit 默认是 loose。 - packfile:把多个对象打包成一个
.pack文件 + 一个.idx索引。packfile 内部用 delta compression——只存对象之间的差异,相似对象(比如同一文件的连续版本)能压缩到很小。
git gc 会把 loose object 重新打包成 packfile。git clone 时传的也是 packfile,不是一堆 loose 文件。这是 Git 在大型仓库上仍然快的原因——对象多时 packfile 的压缩比和随机读性能都远超 loose。
packfile delta 压缩的工作方式#
packfile 不是简单地把 loose object 拼一起,而是重新组织:对每个对象,Git 会用启发式算法找一个"base 对象",然后只存"相对 base 的差异"(delta)。解压时沿着 delta 链回溯即可。
对象 v1(base,完整存) ──── 100KB
对象 v2(delta,存差异) ──── 2KB(只存和 v1 的 diff)
对象 v3(delta,存差异) ──── 3KB(只存和 v2 的 diff)同一文件的连续版本之间差异极小,delta 压缩比能达到 50:1 甚至更高。这就是为什么 Linux kernel 有百万级 commit 但 git clone 只要几百 MB——绝大部分对象以 delta 形式存储。
这套"loose 实时写入 + pack 周期归档"的思路在分布式系统里很常见:Cassandra 的 SSTable compaction、MySQL 的 binlog 归档、甚至日志系统的冷热分层,都是同一个范式。
实战要点#
- 理解暂存区是 Git 工程化的入口。不用
git add -p的人,commit 历史通常是"改了一堆东西一起提交"。一旦养成按 hunk stage 的习惯,commit 历史会变得可读、可回滚、可 cherry-pick。 - commit 是快照不是 diff——这解释了为什么
git log在百万行仓库上仍然亚秒级:它不需要回放历史 diff,直接遍历 commit 对象。 - branch 是 40 字节指针——所以"分支多"不是性能问题,"长命分支多"才是协作问题。前者是 Git 设计优势,后者是流程问题。
- reflog 是救命稻草。任何"我刚才 reset 错了"都能用
git reflog找回,前提是 reflog 没被清理(默认 90 天)。 - plumbing 命令值得学。
git cat-filegit ls-treegit rev-parse这些命令在写自动化脚本、排查诡异问题时极其有用。
小结#
Git 的核心机制可以浓缩成三句话:数据是内容寻址的不可变对象(blob/tree/commit/tag),引用是可变的 40 字节指针(ref/HEAD),暂存区是中间层(index)。这三层合起来构成了一个"可裸读、可恢复、可分布式"的版本控制系统。
理解了这套模型,后续的 rebase、cherry-pick、reset 三种模式、reflog 救命操作都能自然理解——它们都只是"在对象和 ref 上做不同操作"。下一章讲这些日常和进阶操作。