项目做到第三年,仓库涨到了两个多 G,同事跑来问我:"Git 不是说只存差异吗,怎么越存越大?"我让他 du -sh .git/objects 看了一眼——好家伙,散对象 1.4G。那一刻我发现,用了多年 Git 的人,大部分对它的认知停留在"命令背下来了",底下到底存了什么完全没概念。
其实 Git 的设计出乎意料地简单。它本质上是一个内容寻址的文件系统:所有东西都是对象,对象用内容的哈希做地址。剩下的分支、暂存区、历史,全都是这个对象库之上的"视图"。这篇文章带你直接把 .git 目录拆开看,看完之后 reset、revert、cherry-pick 这些命令为什么是那个行为,会变得非常显然。
一切都是对象
Git 里有四种对象,常用的前三种就能解释日常几乎所有行为:
| 类型 | 内容 | 作用 |
|---|---|---|
| blob | 文件内容本身 | 不含文件名、不含路径 |
| tree | 目录:一列 (权限, 名字, 子对象哈希) | 组织文件结构 |
| commit | 指向一个 tree + 父 commit + 作者 + 提交信息 | 一次快照的"凭证" |
| tag | 指向一个 commit + 标签名 + 签名信息 | 带注释的标签 |
对象存在 .git/objects/ 下,以哈希前两位做目录名。每个对象的哈希这样算出来:
SHA-1( "blob {内容字节数}\0{内容}" )
动手验证,不用改任何文件:
$ echo "hello, world" | git hash-object --stdin
8c206d4c5ce7cbcdc68a5a25afe9cf5e8dc6e6d1
# 再跑一次,结果一模一样——内容定地址
$ git hash-object -t blob 8c206d4c # -t 指定类型
blob
$ git cat-file -p 8c206d4c # -p 打印内容
hello, world
同样的内容永远得到同样的哈希——这就是内容寻址。这个性质带来一个立刻能理解的推论:项目里 100 个文件内容相同,对象库里只存一份 blob。Git 根本不需要"去重逻辑",寻址方式天然去重。
commit、tree、blob 是怎么连起来的
一次提交的完整链路是:commit → tree → blob(tree 里还能套 tree,对应子目录)。
用 git cat-file 把一个真实提交拆开看:
$ git cat-file -p HEAD
tree e4f5a6b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7
parent a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
author chengzi <ttghost@126.com> 1759000000 +0800
feat: add pagination to article list
$ git cat-file -p e4f5a6b3
100644 blob b8c9d0e1... README.md
040000 tree 7d6e5f4a... src
注意 tree 里那行 100644 blob b8c9... README.md:文件名不存在于 blob 里,存在 tree 里。blob 是纯粹的"内容",名字和路径是目录结构的属性。这解释了一件事:Git 检测重命名不是靠记录"改名了",而是靠对比两棵 tree 时发现"内容相同的 blob,名字换了"——git mv 和手动改名再 add,在对象库里产生的数据完全一样。
tree 的 040000 对应子目录,100755 对应可执行文件,120000 是符号链接,160000 是子模块(gitlink)。权限模型就是这几个固定值,没有更多花样。
commit 里的 parent 指向上一个提交,一条链串起整个历史。所以"Git 存快照不是存差异"这句话是对的:每个 commit 记录的是当时整棵目录树的完整状态。两个 commit 之间的"差异"是事后用树对比算出来的,从来不是存出来的。(仓库越用越大的真正原因通常是散对象没打包,git gc 之后会变成 packfile 加 delta 压缩,那是存储层的优化,不改变模型。)
分支只是一个 41 字节的文件
理解了对象,再看分支——这是 Git 最让人"祛魅"的一刻:
$ cat .git/refs/heads/main
c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4
main 分支的全部内容,就是这一行 40 个十六进制字符加一个换行——41 字节。分支不是一串提交,分支是一个指向某个 commit 的指针。提交链本身靠 commit 对象里的 parent 字段串起来。
于是这些日常操作突然变得极其廉价,原因都一样:
git branch dev:写一个 41 字节的文件。所以建分支"瞬间完成",和 SVN 拷贝目录是两个世界;git commit:创建新 commit 对象,然后把当前分支文件的内容改成新 commit 的哈希。分支"前进"就是指针移动;- 两个分支指向同一个 commit?完全可以,
git branch dev刚建完就是这样。
HEAD 是另一个特殊引用,一般内容是 ref: refs/heads/main——指向分支的指针。它标识"你现在在哪"。当 HEAD 直接指向一个 commit 哈希而不是分支时,就是 detached HEAD 状态:你提交的新对象没有分支指针指着,切走后随时可能被垃圾回收。这就是为什么 detached HEAD 下提交完要赶紧 git switch -c 新分支 把它接住。
暂存区不是"半个提交",是一份快照清单
.git/index 是暂存区的实体:一个二进制文件,里面是一张扁平的 (路径, blob 哈希, 权限) 列表。git add 做的事就是把工作区文件的内容存成 blob,然后把这张表更新到当前状态。
这个模型解释了两个常见困惑:
为什么暂存后再改文件,提交里没有最新改动? 因为 commit 打包的是 index 里记录的那份 blob(add 时刻的内容),你后来改的工作区文件还没进 index。
为什么 git status 能同时显示"已暂存"和"未暂存"? 因为它对比的是三份东西:HEAD 的 tree、index、工作区,两两各比一遍。staged 和 unstaged 是两组独立的差异。
reset 和 revert:在对象层面看清区别
有了对象模型,这两个命令的区别一句话说清:
git revert:新建一个 commit,内容是指定提交的逆向补丁。历史变长,旧 commit 还在,适合已推送的公共分支;git reset:移动分支指针,可以顺带重置 index 和工作区。旧 commit 变成"不可达对象",等回收。
reset 的三个模式,对应"指针动完之后还要重置什么":
| 模式 | 分支指针 | 暂存区 | 工作区 | 典型用途 |
|---|---|---|---|---|
--soft | 移动 | 保留 | 保留 | 撤销 commit 重新组织提交 |
--mixed(默认) | 移动 | 重置 | 保留 | 撤销 add 和 commit |
--hard | 移动 | 重置 | 重置 | 彻底丢弃本地改动 |
被 reset 甩掉的 commit 并没有立刻消失。.git/logs/ 下的 reflog 记录了 HEAD 的每次移动,git reflog 能找回 90 天内的一切——包括 reset --hard 丢掉的提交。所以"reset --hard 会不会丢代码"的答案是:本地会话内都能救回来(git reset --hard HEAD@{n} 或 git cherry-pick 哈希),但没有 push 过的提交一旦过期被 gc 回收,就真没了。这也顺带回答了"rebase 后代码去哪了":旧提交链同样躺在 reflog 里。
为什么 SHA-1 哈希可以当 ID 用
对象的地址就是内容的 SHA-1,这承担了三重职责:唯一 ID、完整性校验(内容被篡改一个字节,哈希全变,git fsck 能检出损坏)、去重索引。之前写过MD5 碰撞的原理,SHA-1 同样已被实际碰撞攻破(Google 的 Shattered 攻击,成本约 6500 CPU 年),但 Git 的场景不同——攻击者要伪造的对象哈希必须恰好被仓库引用,实际风险可控。即便如此,Git 从 2.29 起已支持 SHA-256 对象库,迁移在缓慢推进中。
日常用到的短哈希(如 a1b2c3d)就是完整 40 位的前缀,Git 只要求在你仓库内无歧义即可。
最后照例给个动手入口:记不全命令的时候,可以用 Git 命令速查手册 按场景查常用命令。不过读完这篇文章你可能会发现,真正需要记的命令没几个——理解了"对象 + 指针"这两个概念,剩下的都是排列组合。
评论