直接看 .git 目录,Git 的原理比你想的简单

2026-06-17开发工具
分享到

项目做到第三年,仓库涨到了两个多 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 根本不需要"去重逻辑",寻址方式天然去重。

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 命令速查手册 按场景查常用命令。不过读完这篇文章你可能会发现,真正需要记的命令没几个——理解了"对象 + 指针"这两个概念,剩下的都是排列组合。

评论