适用读者:用过
commit/push/branch,但说不清"Git 到底存了什么"的人;被"reset 之后还能找回"吓到过的人。
前置:S01-B10(Git 入门)。
实测环境:Ubuntu 24.04.1 / Git 2.43.0;本篇所有 hash、对象内容、reflog 输出均为 2026-11-05 实测。
覆盖旧文:A-1849(Git 教程:数据模型与 60 命令,479 点)、A-1851(Pro Git 引用,22 点)。
新增扩展:E-S01-015(partial clone / sparse checkout / worktree,本篇只讲原理对应的部分)。
上一篇:S01-X01 · 工具与库速查:JSON、日志、测试、数据库、AI 编码客户端
下一篇:S01-J02 · 分支策略与 Code Review / CI 流程。
1. 一句话结论
Git 是一个"内容寻址的键值数据库":键 = 内容的 SHA-1/SHA-256 哈希,值 = 内容本身。提交不是"差异",而是"指向一棵树的指针"。
理解这一句,三个日常困惑立刻消失:
| 困惑 | 答案来源 |
|---|---|
| 每次都存全量快照,为什么仓库不大? | §3(相同内容只存一份 + 压缩 + 打包) |
reset --hard 删掉的提交为什么能找回? |
§5(对象还在,只是没有引用指向它) |
rebase 后为什么每次提交的 hash 都变了? |
§7(hash 由内容决定,父提交变了内容就变了) |
2. 三种对象:blob / tree / commit
| 对象 | 存什么 | 类比 |
|---|---|---|
| blob | 文件内容(不含文件名!) | 一个文件的内容块 |
| tree | 目录:文件名 → 对象 hash 的映射 | 一层目录的清单 |
| commit | 指向一个 tree + 父提交 + 作者/时间 + 说明 | 一次"快照凭证" |
关键点:文件名不在 blob 里,在 tree 里。所以 Git 里"重命名文件"不产生新内容对象——内容没变,只是 tree 里那条记录的键改了。
2.1 实测:把对象拆开看
mkdir /tmp/gitobj && cd /tmp/gitobj && git init -q
echo "hello" > a.txt && git add a.txt && git commit -qm "first"
BLOB=$(git hash-object a.txt); echo $BLOB
# ce013625030ba8dba906f756967f9e9ca394464a
git cat-file -t $BLOB # blob
git cat-file -p $BLOB # hello
tree:
TREE=$(git rev-parse HEAD^{tree}); echo $TREE
# 2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1
git cat-file -p $TREE
# 100644 blob ce013625030ba8dba906f756967f9e9ca394464a a.txt
读法:100644(文件模式)+ blob(类型)+ hash(内容地址)+ a.txt(文件名)。
commit:
git cat-file -p HEAD
# tree 2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1
# author T <t@t> 1793895142 +0000
# committer T <t@t> 1793895142 +0000
#
# first
一个 commit 只包含:tree 的 hash、父提交(首个提交没有)、作者、提交者、时间戳、说明。没有文件内容。
2.2 内容寻址:内容决定 hash(实测)
$ echo "hello" > a.txt; git hash-object a.txt
ce013625030ba8dba906f756967f9e9ca394464a
$ echo "hellp" > a.txt; git hash-object a.txt
d7a963a648c4564f03a0952546d2800681628048 # 改一个字符 → hash 完全不同
$ echo "hello" > b.txt; git hash-object b.txt
ce013625030ba8dba906f756967f9e9ca394464a # 内容相同 → hash 相同(哪怕文件名不同)
最后一行是理解 Git 的钥匙:两个文件内容一样,Git 只存一份。这就是"全量快照不占空间"的第一层原因。
2.3 对象存在哪
$ find .git/objects -type f
.git/objects/ad/2ee6a5b9b521c34c7e95f585db93a5b859f859
.git/objects/2e/81171448eb9f2ee3821e3d447aa6b2fe3ddba1
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
路径规则:hash 前 2 位做目录名,后 38 位做文件名。这是文件系统的"分桶",避免单目录塞几十万文件。
内容用 zlib 压缩存放。想直接看原始字节:
git cat-file -p ce013625... # 解压后的内容
cat .git/objects/ce/013625... | xxd | head -2 # 压缩后的二进制(zlib 头 0x78 0x01)
3. 为什么"全量快照"不占空间:三层机制
| 机制 | 作用 | 实测/依据 |
|---|---|---|
| 内容去重 | 相同内容 = 同一 hash = 只存一次 | §2.2 第三行 |
| zlib 压缩 | 每个对象单独压缩 | .git/objects 里的文件是压缩流 |
| packfile + delta | git gc 把松散对象打包,相似对象做增量 |
git count-objects -vH 的 in-pack / size-pack |
3.1 count-objects 读法(实测)
$ git count-objects -vH
count: 3
size: 12.00 KiB
in-pack: 0
packs: 0
size-pack: 0 bytes
| 字段 | 含义 |
|---|---|
count / size |
松散对象数量与占用(未打包) |
in-pack / size-pack |
已打包对象数量与占用 |
prune-packable |
既松散又已打包(可清理的冗余) |
跑一次 git gc 再对比,count 会归零、in-pack 上升、总体积下降。这就是"仓库用久了变小"的原因(同时也是 packfile delta 的效果)。
旧文的问题:A-1849 讲了"快照、对象、引用"的概念,但没有给出可动手验证的路径(没有
cat-file、没有count-objects)。本篇把这些补齐——概念不经手摸一遍,记不住。
4. 引用:分支只是"一个文件里的一行 hash"
| 引用 | 位置 | 含义 |
|---|---|---|
| 分支 | .git/refs/heads/<name> |
指向某个 commit |
| 远程分支 | .git/refs/remotes/origin/<name> |
上次 fetch 时远端的位置 |
HEAD |
.git/HEAD |
当前在哪个分支(或直接指向 commit,即 detached) |
ORIG_HEAD |
.git/ORIG_HEAD |
危险操作(merge/rebase/reset)前的位置 |
| reflog | .git/logs/HEAD |
HEAD 的移动历史(本地,不推送) |
$ cat .git/HEAD # ref: refs/heads/main
$ cat .git/refs/heads/main # 654216a42dfc00e9adce814f9ad52546b466991e
"创建分支"为什么是瞬时操作? 因为它只是新建一个 41 字节的文件。"切换分支"为什么快? 因为只需要改 HEAD + 检出 tree 对应的文件。
5. reset --hard 之后为什么还能找回(实测)
5.1 端到端复现
$ git log --oneline
2f56673 c2
a547b41 c1
$ git reset --hard HEAD~1
HEAD is now at a547b41 c1
$ git log --oneline
a547b41 c1
$ cat f.txt
v1 # 工作区被回退了
$ git reflog --oneline
a547b41 HEAD@{0}: reset: moving to HEAD~1
2f56673 HEAD@{1}: commit: c2 ← 丢失的提交还在这里
a547b41 HEAD@{2}: commit (initial): c1
恢复:
$ git reset --hard HEAD@{1}
HEAD is now at 2f56673 c2
$ git log --oneline
2f56673 c2
a547b41 c1
$ cat f.txt
v2 # 回来了
5.2 没有 reflog 也能找:fsck
$ git fsck --lost-found
dangling commit 2f566732d258d2cbe97353918119fbe92a9b9e63
原理:reset 只是把分支引用从 2f56673 挪到 a547b41。2f56673 这个对象没有被删除,只是"没有引用指向它"(dangling)。gc 才会真正清理。
5.3 边界:什么时候真的找不回
| 情况 | 结果 |
|---|---|
| 刚 reset / 刚误删分支 | reflog 里有,能找回 |
reflog 过期(默认 90 天;gc.reflogExpire) |
reflog 记录没了,但 fsck 可能还能捞到悬空对象 |
跑过 git gc --prune=now |
对象被物理删除,找不回 |
未 git add 的新文件被 checkout 覆盖 |
Git 从未存过,找不回(只能靠 IDE 的 Local History,见 S01-B09) |
实践建议:误操作后先别 gc,先 git reflog + git fsck。
6. 这条原理怎么用:四个日常推论
| 推论 | 结论 | 应用 |
|---|---|---|
| 分支 = 指针 | 建/删分支几乎零成本 | 大胆开分支(S01-J02 的分支策略因此才可行) |
| commit hash = 内容 + 父 + 作者 + 时间 | 任何一项变,hash 就变 | rebase/cherry-pick 会产生"新提交",不是移动旧提交 |
| 提交不可变 | "修改历史"= 造新对象 + 挪引用 | 已推送的历史改写会让他人分叉(S01-J03 讲安全做法) |
| 对象共享 | 相同文件内容只存一份 | 为什么仓库里塞大二进制文件代价高(每次改动都是新对象,且难 delta) |
6.1 大文件为什么是 Git 的痛点
- 二进制文件(图片、jar、视频)内容几乎每次都不同 → 每次都是新 blob → 仓库膨胀。
- delta 压缩对二进制效果差(相似度低)。
- 结论:大文件用 LFS 或制品库(S01-A02),不要直接进 Git。
7. 与 Pro Git 的对应关系(A-1851)
旧文 A-1851 只给了一个链接(Pro Git)。这里给出精确章节,按本篇顺序读最省力:
| 本篇 | Pro Git 章节 |
|---|---|
| §2 三种对象 | Git 内部原理 · 底层命令与上层命令 |
| §2.2 内容寻址 | Git 对象 |
| §3 打包 | 包文件 |
| §4 引用 | 引用规范 |
8. 自测
- 一个 commit 对象里存了哪些字段?没有存什么?
- 把
a.txt改名成b.txt并提交,会产生新的 blob 吗?为什么? git reset --hard之后立刻git gc --prune=now,还能找回吗?- 为什么 rebase 之后每个提交的 hash 都变了?
- 为什么把 200 MB 的视频提交进 Git 是坏主意?(用 §3、§6.1 解释)
9. 下一篇
S01-J02 · 分支策略与 Code Review / CI 流程
懂了"分支只是指针",就能理解为什么分支策略可以很轻。下一篇把旧文 A-555(543 个点位的团队 Git 规范)重组成一份可执行的流程:Commit 类型怎么选、分支从哪拉、MR 写什么、CR 看什么、谁能合、合完 CI 做什么,并补上 Conventional Commits 自动化与提交签名。
本篇属于系列《S01 · 开发环境与工具链》。旧文来源:A-1849、A-1851;扩展点 E-S01-015。所有 hash 与命令输出为 2026-11-05 实测。