在前一篇文章中,我们已经知道在 Git 的底层存在 4 种对象:
- blob
- tree
- commit
- tag
它们一旦创建就不会被修改,整个历史就是一张由 hash 串起来的引用图。
那问题来了:Git 凭什么能保存项目的“不同版本”?它背后保存的到底是什么?为什么不会把磁盘撑爆?
先说结论:
Git 背后保存的不是差异(diff),而是快照(snapshot),每一次 commit,都是对“当前整个项目状态”的一次记录。
注意这里的“快照”不是说 Git 会把整个项目复制一份。Git 的精妙之处在于:快照是“逻辑上的全量”,物理上却能大量复用。
我们回忆一下 commit 的结构:
- commit 通过 tree 指向一棵目录树
- tree 里记录了目录下每一项的 name、type 以及 hash
- 最底层的 blob 里才是真正的文件内容
所以一次提交其实就是:
commit → tree(根目录) → tree(子目录) → ... → blob(文件内容)
只要拿到某个 commit 的 hash,就能顺着它的 tree 一层层展开,得到当时项目的完整目录结构和每个文件的内容。因此,“不同版本”其实就是“不同的 commit”,每个 commit 都有自己的 tree 入口。
所谓“回到某个版本”,其实就是“让 HEAD/分支指向某个 commit”。Git 做的事情也很简单:
- 找到目标 commit
- 读取它指向的 tree
- 把 tree 展开到工作区
这也是为什么 Git 的切换速度通常非常快,因为本质上就是“指针切换 + 文件还原”。
🙋 那么 Git 为什么不存储 diff 呢?这样难道不会把磁盘撑爆么?
要回答这个问题,就要回到 Git 最核心的设计:内容寻址 + 去重复用。
这里我们以两种情况为例来讲解。
情况A:文件内容没变
如果一个文件内容没变,那么它对应的 blob hash 也不会变。下一次提交时,新 tree 里会继续引用同一个 blob。
例如 Tree2 里 index.js 仍然指向 Blob_A
结果就是: Git 并不会重复存储这份文件内容。
情况 B:只改了一个文件
如果你只改了 index.js,那么:
index.js 会产生一个新的 blob,因为内容变了,导致计算出来的 hash 也变了
- 受影响的目录 tree 会产生新的 tree,因为 entries 引用变了
- 最终产生一个新的 commit 指向新的根 tree
但项目里其他没变的文件:
- 依然复用旧的 blob
- 依然复用旧的子 tree(如果该子目录完全没变化)
所以一次提交“看起来是全量快照”,但实际只新增了“变化那条链路”上的对象:
新 commit
↓
新 root tree
↓
(只有涉及变化的子 tree 是新的)
↓
新 blob(改动的文件内容)
你可以把 Git 的存储想象成:它不是每次复制整个项目,而是每次只新增“变化导致的新对象”,其他对象全部复用。
那这里有同学就又有疑问了,这不代表着就是存储的 diff 么?
实际上和 diff 很像,但是和真正的 diff 还是有区别,因为 Git 存储的是“新的对象 + 旧对象的引用复用”。
到目前为止,我们已经多次提到这样一句话:
所谓切换版本,本质上就是让 HEAD / 分支指向某个 commit。
这意味着一个非常重要的结论:分支并不是一条“代码线”,而只是一个指向 commit 的可移动指针。
正是因为分支本身只是一个指针,因此 Git 后续的所有操作:
- checkout
- commit
- reset
- merge
- rebase
- ....
本质上都可以理解为:在不同场景下,对这些指针进行移动或重写。
在理解了这一点之后,我们再来看 HEAD 和 checkout,到底在干什么。
2. HEAD、checkout、reset 到底在干什么?
接下来,我们来看一下 Git 里三个最容易让人产生恐惧的东西:
- HEAD 是什么?
- checkout 到底做了什么?
- reset 为什么有三种,而且为什么危险?
2.1. HEAD是什么?
在 Git 中,HEAD 并不是一个 commit,而是一个指针的指针。
举一个例子:
假设现在是这样一个状态:
commit1 ← commit2
然后我们创建了分支,分支关系为:
main → commit1 // main 指向 commit1
dev → commit2 // dev 指向 commit2
而 HEAD 的作用,就是在这两者之间做选择。例如:
HEAD → main → commit1
这表达的含义是:我当前是在 main 分支上,工作区对应的是 commit1 的那一刻状态。当用户在
git status
git log
git commit
时看到的一切,都是以 HEAD 当前指向的位置为基准。
2.2 checkout 到底做了什么?
理解了 HEAD,接着我们再来看 checkout。很多人直觉上会觉得:checkout 是在回滚代码或者说切换文件内容。但这只是结果,不是本质。
checkout 的本质是:移动 HEAD,让它指向另一个引用(分支或 commit),然后根据新的 tree 展开工作区。
拆成两步就是:
- HEAD 指向哪里
- 把对应 commit 的 tree 展开到工作区
例如:
git checkout dev
发生的事情是:
HEAD → dev → commit2
然后 Git 做一件事:
- 读取 commit2 指向的 tree
- 把 tree 展开成当前工作区的文件
所以 checkout 并不是“回放 diff”,而是直接换了一个快照入口。
有些同学在执行:
git checkout <commit-hash>
之后,会看到提示:
You are in 'detached HEAD' state
这并不是错误,而是一个非常精确的描述。因为此时结构变成了:
HEAD → commit C
注意这里 HEAD 不再指向分支,而是直接指向某个 commit。这意味着:
- 你依然可以查看代码
- 也可以继续 commit
- 但这些 commit 不会被任何分支记录
所以 detached HEAD 的本质是:HEAD 脱离了分支,只是临时站在某个历史节点上。
2.3 reset 为什么有三种,为什么危险?
接下来我们来看一下最容易出事故的命令:reset
很多人对 reset 的印象是:“一用就很危险”,但危险的并不是 reset 本身,而是你不知道它改了哪些东西。
要理解 reset,我们需要先记住一句话:reset 的核心作用,是“移动分支指针”。
例如:
git reset <commit>
在最基础的层面,它做的事情是:当前分支 → 指向新的 commit,首先你需要理解这一步,而这一步本身并不神秘。
2.3.1 三种模式
reset 拥有三种模式:
- reset --soft
- reset --mixed
- reset --hard
这 3 种模式的差别并不在“是否回滚代码”,而在于它影响了哪些区域。Git 里一共有三个相关区域:
- HEAD / 分支(引用层)
- 暂存区(index)
- 工作区(working directory)
三种 reset 的行为如下:
--soft:移动分支指针,不动暂存区、不动工作区
--mixed(默认):移动分支指针,重置暂存区,不动工作区
--hard,移动分支指针,重置暂存区,重置工作区
可以用一个很形象的比喻:
- HEAD/分支:书签指向哪一页(历史快照)
- 暂存区:你准备复印提交的那一叠纸(提交候选)
- 工作区:你桌上正在涂改的草稿纸
reset 的核心只有一句话:把“书签”移到某个 commit 上。但移完书签后,你可能希望:
- 只改书签,不动你桌上的草稿(soft 模式)
- 书签改了,复印那叠也跟着回到那一页(mixed 模式)
- 书签改了,复印那叠也回去,桌上草稿也全部覆盖回去(hard 模式)
这就是三种 reset 的差别。下面我们通过一个完整的例子来理解三种模式的区别。
前期准备
假设当前的历史是:
A —— B —— C (main/HEAD)
file.txt 在 commit C 的内容是:
C版本内容
接下来做两个操作:
- 改了工作区的文件(但还没 add),例如把
file.txt 改成:
工作区修改内容(未add)
此时状态:
- HEAD/分支:还在 C
- 暂存区:还是 C
- 工作区:已经变了
- 又 add 了一次(让暂存区也变化),执行:
git add file.txt
现在状态变成:
- HEAD/分支:C
- 暂存区:工作区修改内容(已add)
- 工作区:工作区修改内容(已add)
现在我们执行 reset,把 main 从 C 挪回到 B,也就是:
git reset B
注意:不写参数默认是 --mixed,下面我们来分布看一下三种模式的区别。
1. reset --soft B:只动“书签”,不动草稿和复印件
git reset --soft B
上面的指令会发生了什么?
- 移动分支指针:
main 从 C → B
- 暂存区不变
- 工作区不变
结果是什么?
- HEAD/分支:B
- 暂存区:仍然是“工作区修改内容(已add)”
- 工作区:仍然是“工作区修改内容(已add)”
可以这样理解:我后悔 commit C 了,我想把它“撤回成待提交状态”,但我不想丢任何改动。
这也是 --soft 最常用的场景:“把最近一次提交变成 staged(可重新提交)”
2. reset --mixed B(默认):动书签 + 重置暂存区,不动工作区
git reset --mixed B
# 等价于 git reset B
上面的指令会发生了什么?
- 移动分支指针:C → B
- 暂存区重置到 B
- 工作区不变
结果是什么?
- HEAD/分支:B
- 暂存区:回到了 B 的内容
- 工作区:仍然保留“工作区修改内容(未提交)”
可以这样理解:我想撤回 commit C,并且把 add 的东西也取消掉,但我写的代码别动。
所以 --mixed 的典型用途就是:“撤销提交 + 取消暂存(但保留本地修改)”
3. reset --hard B:动书签 + 重置暂存区 + 覆盖工作区(最危险)
git reset --hard B
上面的指令会发生了什么?
- 移动分支指针:C → B
- 暂存区重置到 B
- 工作区也强制覆盖成 B
结果是什么?
之前的:
全部没了。可以这样理解:我就想彻底回到 B,那之后的改动我都不要了。
所以 --hard 才危险:它会直接动你的工作区内容。
下面是三种模式的一个对比表格:
| 命令 |
分支/HEAD |
暂存区 index |
工作区 working tree |
结果直觉 |
reset --soft |
✅移动 |
❌不动 |
❌不动 |
撤回提交,但保留 add |
reset --mixed |
✅移动 |
✅重置 |
❌不动 |
撤回提交 + 取消 add |
reset --hard |
✅移动 |
✅重置 |
✅重置 |
全部回到目标 commit |
2.3.2 为什么危险?
因为 reset 不会修改任何 commit 对象, 但它会:
如果你:
- 把分支指针往回挪
- 又没有其他引用指向那些 commit
那么这些 commit 就会变成暂时无法通过分支访问的历史节点。
来看一个具体的例子。
假设现在的仓库现在是这样一条提交历史(时间从左到右):
A —— B —— C —— D
并且当前状态是:
main → D
HEAD → main
这意味着:
- 有 4 个 commit:A、B、C、D
main 分支指向最新的 D
HEAD 跟着 main,现在就在 D
执行了一次 reset
现在假设我们执行:
git reset --hard B
那么此时具体会做什么呢?
Git 会不会删除 commit?
不会。
此时 Git 内部依然是:
A —— B —— C —— D (这些 commit 对象一个都没少)
C 和 D 还在对象数据库里,没被删。reset 真正干的事 —— 移动分支指针。
reset 的核心行为只有一个,那就是把当前分支指针,挪到目标 commit 上。所以现在变成:
main → B
HEAD → main
而历史“视觉上”看起来像:
A —— B C —— D
注意这一步非常关键:
main 现在只指向 B
- C 和 D 没有任何分支再指向它们
为什么这一步开始“危险”了?
这里有一个关键的问题:现在还有没有“名字”能找到 C 和 D?
答案是:没有了。
所以:C 和 D 这两个 commit,现在只是“漂浮在 Git 历史图里的匿名节点”,也就成为了“暂时无法通过分支访问的历史节点”。
不过,目前为止这还只是“暂时”的,Git 内部状态是:
(对象数据库里)
A、B、C、D 全都存在
它们都还存在。但问题在于:Git 的日常操作,只认识“从引用(branch / tag / HEAD)能走到的 commit”,而 C、D 现在:
- 不在任何分支上
- 不在任何 tag 上
- HEAD 也不在它们上面
它们已经“脱离可达路径”了。
如果接下来你继续正常工作,比如:
# 在 B 的基础上继续开发
git commit -m "new work"
历史就会变成:
A —— B —— E
而原来的:
C —— D
此时没有任何引用,完全不可达,一段时间后 Git 的垃圾回收会把 C、D 当成“无用对象”清理掉,这时候就真的再也找不回来了。
因此,reset 的危险不在于“删除了 commit”,而在于“让 commit 失去了名字”。Git 不会马上杀掉历史,但如果你把分支指针挪走,又不给旧 commit 留任何引用,那它们迟早会被清理。
通过深入理解 Git 的快照机制与指针操作,我们才能在日常开发中更加自信地使用 Git 进行代码管理。如果你想深入学习更多此类底层原理和开源实战经验,欢迎来云栈社区的技术文档板块与其他开发者一起交流探讨。