用了好几年 Git,日常无非就是 add、commit、push、pull、merge 这些命令,一开始觉得挺顺手。可一旦遇到 detached HEAD、rebase 冲突、误操作找回这种事,就只能上网搜命令,搜到哪条试哪条,能恢复算运气好,恢复不了就只能认栽。后来我花了一整段时间把 Git 的原理比较系统地啃了一遍,才发现之前那些“玄学报错”和“灵异操作”,底层逻辑其实特别干净。这篇我不想讲命令速查,也不做那种“Git 二十条最佳实践”的清单,而是想把原理这层窗户纸捅破:对象模型、分支指针、暂存区索引、合并机制、reflog 与 gc,一层层拆开看。适合那些已经会基本操作、但总感觉 Git 是个黑盒子的开发者;看完你会发现,Git 的绝大多数行为都能用“几个对象和一堆指针”推出来,不需要背。
1. 先从.git目录解剖入手:原理的第一个真相
很多人用 Git 几个月都不一定打开过项目里的.git目录看一眼。实际上 Git 的所有秘密基本都装在这个文件夹里。一个典型的仓库,.git目录打开之后大概是这样的:
.git/ ├── objects/ 对象库,存放所有的内容快照 ├── refs/ 引用目录,分支和标签都在这 │ ├── heads/ 本地分支 │ └── tags/ 标签 ├── HEAD 指向当前分支的指针文件 ├── index 暂存区的索引文件(二进制) ├── config 当前仓库的配置 ├── logs/ reflog 操作日志 └── hooks/ 钩子脚本每个目录都有职责,但排在最关键位置的肯定是objects/和refs/。一个存“内容”,一个存“位置”。只要把这两个概念在脑子里立住了,后面所有东西都顺了。
1.1 几乎所有的东西都是 key-value 存储
Git 底层不是一个文件系统,而是一个典型的内容寻址存储系统。你往仓库里提交任意一个文件,Git 都会先把文件内容做一次哈希运算,得到一串 40 位的 SHA-1(新版本也在向 SHA-256 过渡),然后把这份内容保存成一个对象。对象的名字就是哈希值,也就是说,内容本身就是寻找它的地址。
这个设计在实操中有一个很容易体会到的效果:如果两个文件内容一模一样,不管文件名是什么、在哪个目录下,Git 只保存一份对象。膨胀量来自内容变化,而不是文件数量。所以很多人担心的“仓库文件太多会不会撑爆 Git”,其实方向搞反了,真正让仓库膨胀的是反复修改大文件。
原本你可能会把.git看成“项目快照的备份”,打开之后你会发现,它更像是一个极其精简的键值数据库。数据库的 key 是哈希,value 是压缩后的内容、目录结构、提交信息,而 refs 和 HEAD 只是这个数据库的“书签指针”。这个模型的优点是把去重、完整性校验、历史追溯全部统一了。
1.2 用底层命令偷看一眼对象库
日常我们用的git log、git show都是高层命令,真正去读对象的是git cat-file这类底层命令。比如想看看某个提交对象里面装了什么,可以这样:
git cat-file -p HEAD-p是 pretty 输出,HEAD指的是当前分支指向的那个提交对象。你会看到类似这种结构:
tree 3b18e512dba79e4c8300dd08aeb37f8e728b8dad parent 2e1c8a1f4b6e0c8d6f0a3b2c1d0e9f8a7b6c5d4e author 某开发者 <dev@example.com> 1735600000 +0800 committer 某开发者 <dev@example.com> 1735600000 +0800 这里是一段提交信息看到没有,commit 对象本身并不直接存储文件内容,它只记录了三个关键字段:指向某个 tree 对象、指向父提交、以及作者和提交信息。这个设计就是 Git 整个数据模型的核心骨骼。
再想深一层:如果连续提交三个版本,第一个 commit 是 A,第二个 commit 是 B(父提交是 A),第三个 commit 是 C(父提交是 B),那你其实得到了一个链表 A→B→C。实际仓库中因为分支合并的存在,这个结构还会从链表变成一个有向无环图。
2. Git 对象模型的四个主角:从 blob 到 commit 的完整链路
Git 的对象库里有四种对象:blob、tree、commit、tag。理解这四者的关系,相当于把 Git 内部的地图拿到了手里。
2.1 blob 对象:只有内容,没有名字
blob 是最底层的内容容器。当你执行git hash-object把一个文件写入 Git 时,生成的就是 blob 对象。重点来了:blob 只保存文件内容本身,不保存文件名,不保存目录结构,不保存权限位。
echo "hello" | git hash-object --stdin -w这条命令会把“hello”写进对象库,然后输出它的哈希值。因为没有文件名,你在对象库里看到一个又一个哈希文件时,根本无法直接判断它对应的是项目里的哪个文件。这种设计看似反直觉,但它天然支持了内容去重——同样的文件内容只存一份,不管它出现在多少个目录、多少个历史版本里。
我在带新人时经常打这个比方:blob 就像寄存柜里的行李,行李上只贴了一张由内容算出来的编号纸,至于你是哪个乘客、从哪个车站来的,柜子不关心。
2.2 tree 对象:目录结构的快照
有了 blob,还需要一种东西把文件名、目录层级和 blob 关联起来,这就是 tree 对象。tree 记录了目录下每个条目的文件名、文件模式(比如普通文件或可执行文件),以及它指向的是 blob 还是另一个 tree。
git cat-file -p <某个tree的哈希>输出类似:
100644 blob 3b18e512... README.md 100644 blob a1b2c3d4... main.py 040000 tree 5f6e7d8c... src也就是说,顶层 tree 表示根目录,src又是一个子 tree,层层嵌套,最后叶子节点全是 blob。tree 的存在让 Git 能完整复刻任意一次提交当时的目录状态。
这个设计其实和文件系统的 inode 结构特别像。目录不是一堆文件名的平铺集合,而是“目录项→inode”的映射;tree 也一样,只不过 inode 换成了哈希地址。
2.3 commit 对象:把 tree 串成历史
如果只有 blob 和 tree,Git 只能保存“某一瞬间的快照”,无法表达时间线上的先后关系。commit 对象就是为了解决这个问题诞生的。
commit 对象的内部结构前面看过:一个 tree 引用、一个或多个 parent 引用、作者和提交者信息、提交说明。每次提交,本质上是把当时的 index 快照固化成新的 tree,然后包装成 commit,并把 parent 指向上一次提交。
这里有个很多人没意识到的事实:Git 里的“历史”不是一组差异补丁,而是一连串完整的快照。每次 commit 都保留了一个完整的 tree,而不是“这次改了哪几行”。至于你平时在git log -p里看到的 diff,是 Git 拿相邻两个 commit 的 tree 现场对比算出来的。
2.4 内容寻址与不可变性
SHA-1 哈希带给 Git 的另一个特性是内容不可变。一个对象一旦生成,哈希值就固定了;修改内容 = 生成新对象,旧对象原封不动地留在对象库里。这意味着一份写进去的数据不会因为后续操作被“覆盖”,只会被“遗忘”。后面讲 reflog 和误删恢复,靠的就是这个特性。
类似地,tag 对象是对 commit 对象的再包装,附注标签可以包含标签说明、签名信息,它本质上也是一个指针,指向某个 commit。
3. 分支和 HEAD:原来 branch 只是会移动的指针文件
很多人初学 Git 时被分支绕得头晕,总觉得分支是某种复杂的“代码线”。实际上在 Git 的世界里,分支就是一个极其简单的概念:一个名字,指向某一个 commit 哈希。仅此而已。
3.1 refs 和 HEAD 的物理真实
在.git/refs/heads/目录下,每个分支名对应一个文件。比如你本地有一个dev分支,这个文件里就是一行文本:
f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2这串就是dev分支最新指向的那个 commit 的哈希。前面说的“切换分支”,实际操作就是修改.git/HEAD文件里的内容,比如让它指向:
ref: refs/heads/dev我来解释一下这意味着什么:你在任意时刻做一次git commit,Git 做的事情是先创建新的 commit 对象,再把当前分支的名字(也就是那个 ref 文件)更新成新 commit 的哈希。分支为什么叫“会动的指针”,因为提交一次,它就往前走一步。
3.2 detached HEAD:为什么会出现、怎么回来
detached HEAD这个状态吓到过不少人。其实它只是因为 HEAD 不再指向某个分支文件,而是直接指向了一个 commit 哈希。比如你执行:
git checkout <某个commit的哈希>HEAD 文件里就不是ref: refs/heads/xxx,而是直接写着那个哈希。在这个状态下如果新建提交,没有分支名接收这个提交,你切回分支后就很容易“丢”掉它——注意我说的是引号,因为它不是真的丢了,只是没有指针能顺着找到它了。
理解了这点,遇到 detached HEAD 就不会慌。常见做法是立刻执行git branch temp创建一个临时分支指向当前 HEAD,这样就把这个游离的提交“接住”了。
3.3 为什么创建分支如此廉价
因为分支只是一个 41 字节的小文件(40 位哈希 + 换行),创建分支的代价几乎为零。不需要复制文件、不需要记录差异、不需要保留目录副本。这种廉价让 Git 分支工作流成为可能,也让很多人“随手建分支”的习惯有了底层支撑。
4. 暂存区不是中转站,而是 index 索引文件
日常的理解里,暂存区是工作区和版本库之间的一个“中间缓冲区”。这个说法没错,但它没讲清楚暂存区的本质。暂存区实际上就是一个二进制文件.git/index,它按路径记录了当前暂存的内容对应哪个 blob 对象。
4.1 git add 之后发生了什么
git add README.md这个操作拆开来看,其实是两步:
- 把 README.md 的内容写入对象库,生成一个新 blob(第一次 add 这个内容时)。
- 更新
.git/index,把README.md这个路径和上一步生成的 blob 哈希绑定起来。
此时你还没有提交,历史里没有任何变化。真正被“暂存”的,其实是 index 文件里的路径到 blob 的映射关系。所以你可以连续git add同一个文件多次,每次都会生成新 blob,index 里的映射逐步后移;如果你git add之后再修改文件,工作区内容和 index 里的内容就会不一致,git status会同时显示“已暂存的新版本”和“未暂存的新改动”。
4.2 三种 reset 的本质差异
git reset后面跟--soft、--mixed、--hard,很多人靠背动词记忆,其实用 index 的视角一推就明白:
| 模式 | 移动 HEAD | 重置 index | 重置工作区 |
|---|---|---|---|
| --soft | 是 | 否 | 否 |
| --mixed | 是 | 是 | 否 |
| --hard | 是 | 是 | 是 |
--soft撤销上一次提交,但保留暂存状态,适合“提交完发现自己漏了文件,想重新组织提交内容”;--mixed默认模式,把暂存区打回工作区,文件改动还在但不再处于已暂存状态;--hard则是把工作区也一并还原到目标提交时的状态。理解了 reset 是移动指针 + 重置 index,就不会把 reset 和 revert 混为一谈。
4.3 三个区域的状态机
工作区、暂存区、HEAD 共同组成了一个三态流转:
工作区文件 --> git add --> 暂存区(index) --> git commit --> 版本库(HEAD) ^ | | v +---------------- git checkout/hard reset -------------+很多看似复杂的操作,本质上就是在这三个区域之间搬运内容。比如git diff比较的是工作区和 index,git diff --cached比较的是 index 和 HEAD。搞清楚每个命令动的是哪两个区域,Git 就失去了神秘感。
5. merge 与 rebase 的底层逻辑:冲突为什么会发生
合并是 Git 里最容易被误解的部分。为什么有时一个 merge 自动完成,有时却冲突迭起?答案可以追溯到提交图结构。
5.1 fast-forward 与 merge commit
当你执行git merge dev,Git 会先判断当前分支和dev的历史关系。如果当前分支的 HEAD 是dev分支历史里的直接祖先(换句话说,dev 只是比当前多了一串提交,当前没有独有的提交),Git 会直接把当前分支指针快进到dev所指的提交,这叫做 fast-forward 合并。
但如果两个分支从同一个提交分叉后都有各自的新提交,Git 就无法快进了。它必须做一次真正的合并:新建一个 commit,这个 commit 的 parent 同时指向当前分支的 HEAD 和dev的最新提交。这就是为什么你在git log里能看到合并提交有多个 parent。
5.2 三路合并:冲突产生的必要条件
一次真正的合并,Git 会同时参考三个提交:
- base:两个分支分叉前的共同祖先
- ours:当前分支的 HEAD
- theirs:被合并分支的 HEAD
对于每一个文件,Git 对比这三个版本。如果 ours 和 theirs 在某个文件的同一区域修改方式不同,就会产生冲突。用一个不太贴切但好懂的例子:两个人拿同一篇文章的同一段在改,一个人把标题改成了 A,另一个人改成了 B,编辑器不知道听谁的,只能把两个选择都摆出来让你拍板。
这就是冲突的本质:不是 Git 笨,而是 Git 无法判断哪个是“正确”的修改。它把决定权交给你,而不是默默覆盖某一方。
5.3 rebase 为什么是重放,以及重写历史的代价
rebase 和 merge 的目标类似,都是整合分支,但做法完全不同。git rebase dev不是合并两个分支,而是把当前分支上相对于 dev 的每一个提交,逐个“重放”到 dev 的最新提交之后。
“重放”不是原样搬运旧 commit,而是根据旧 commit 的内容变化,生成一批全新的 commit 对象。这批新 commit 的哈希和旧 commit 完全不一样,因为它们换了 parent,换了 tree 的某些节点。结果就是,从外部看这条分支的历史变得线性,干干净净;但代价是历史被“改写”了。
理解这一点,你就知道为什么不要对共享分支做 rebase:如果别人已经把旧分支拉到本地,你在远端改了历史,别人下次 push 时会发现自己的历史和远端历史对不上,只能强行 push 或者重新拉取,这是团队协作里最常见的混乱来源之一。
6. 从 reflog 到 gc:误删的提交到底还能不能找回
每次看到有人问“我不小心 git reset --hard 了,能找回吗”,我就想说,如果你能看懂 reflog,这个问题根本不用问别人。
6.1 reflog 记录了什么
reflog 是 reference log 的缩写,它记录了 HEAD 以及每个分支引用在过去一段时间内的每次移动。可以理解成 Git 自带的操作日记。每一次 commit、checkout、reset、merge、rebase,都会在 reflog 里留下一条记录。
git reflog输出会告诉你时间顺序上 HEAD 指针的每一次变化,以及对应的 commit 哈希。比如你误操作git reset --hard HEAD~2,旧提交瞬间“没了”,但它的哈希还躺在 reflog 里。找到那条记录,执行git reset --hard <那个哈希>,就能把指针拨回误操作之前的位置。
我们之所以说它“还在”,是因为对象库里那个 commit 以及它引用的 tree、blob 都还在,没有任何对象被删除。一切的终点只是:没有任何常用引用指向它们了。
6.2 gc 和打包机制:真正意义上的清理
如果对象一直不清理,仓库会越来越大。Git 的 gc(garbage collection)会做两件事:
- 检查所有对象,找出没有被任何引用可达的对象,也就是“孤儿对象”,然后删除它们。
- 把大量小对象打包成一个 pack 文件,用压缩算法减少体积。
但 gc 并不会立刻销毁孤儿对象,因为它要照顾 reflog。默认情况下,reflog 中的记录有过期时间,通常在 90 天左右。只有超过这个时间、且没有任何引用可达的对象,才会被真正从对象库中移除。
所以在自救时有个原则:误删之后越早处理越好,别拖到 gc 跑完,也别自己手贱去执行git gc --prune=now,那是把最后的救命稻草也拔了。
6.3 恢复现场的操作范式
误删分支、误 reset、rebase 中断想回到起点,套路其实高度一致:
git reflog找到目标commit哈希。- 创建临时分支
git branch recover <哈希>,或直接git checkout -b recover <哈希>。 - 确认恢复内容没问题后,再决定是把当前分支强制指过去,还是挑一部分提交过来。
关键点在于:先接住,再决策。不要一上来就git reset --hard某个很老的位置,把当前还活着的状态也一并覆盖了。
7. 站在原理上看日常问题:几个经验判断
前面讲的都是底层机制,最后用几个高频问题收个尾。你会发现,如果不用原理,这些问题只能靠猜;用原理,基本三分钟能定位方向。
7.1 大文件为什么总是改变仓库体量
因为 blob 存的是完整内容快照,不是差异。一个 200MB 的二进制文件哪怕每次只改一个字节,Git 也会在历史里存一个全新的 200MB blob。这解释了为什么“把大文件删了仓库依然很肥大”——历史里的 blob 不会因为当前目录里没有它而消失。想根治,只能深度重写历史,或者对大文件改用持久化存储方案,把路径和元数据交给 Git 管,文件本体不放对象库。
7.2 clone 为什么慢,以及 shallow clone 的原理
clone 的本质,是把远端的引用全部拉到本地,然后根据引用去下载对象。如果远端仓库历史很长,哪怕你只需要最新代码,Git 默认也会把整条历史的对象全部拉下来。这就是完整 clone 的特点。
如果我只需要最新快照,可以git clone --depth=1,它做了一个浅克隆,只下载最新提交对应的 tree 和 blob。代价是拿不到历史记录,且后续做某些操作时需要额外获取历史。这个取舍在实践中经常被忽略,但原理上极其直接:对象有多少,拉多少。
7.3 tag 为什么不能随意移动
分支可以随便动,因为分支本身就是可变引用,它的设计目的是跟随开发前进。tag 正好相反,它被设计成“固定引用”,通常指向某个永远不变的发布点。如果强行移动 tag,比如把 v1.0 指到另一个 commit,所有基于 v1.0 的发布、构建、备份都会跟着漂移,这在发布工程里是很危险的事。所以 Git 设置了一些保护机制,并且约定 tag 不做修改,必要时用删除后重建的方式处理,但也只在完全可控的本地环境里推荐。
最后再分享一个我个人的体会:我开始学 Git 原理时,最大的收获不是“会了更多命令”,而是“遇到问题不再慌了”。很多操作在原理层面其实就是一个指针移动加对象创建,搞明白数据是怎么组织和寻址之后,那些网上流传的复杂恢复方案,你甚至可以自己在脑子里推一遍再动手。希望这篇能把你的 Git 认知从“命令背诵”带进“模型推演”的阶段,省下以后踩坑重启的时间。