找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2699

积分

0

好友

383

主题
发表于 10 小时前 | 查看: 1| 回复: 0

在前一篇文章中,我们已经知道在 Git 的底层存在 4 种对象:

  1. blob
  2. tree
  3. commit
  4. 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 做的事情也很简单:

  1. 找到目标 commit
  2. 读取它指向的 tree
  3. 把 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 后续的所有操作:

  1. checkout
  2. commit
  3. reset
  4. merge
  5. rebase
  6. ....

本质上都可以理解为:在不同场景下,对这些指针进行移动或重写。

在理解了这一点之后,我们再来看 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 展开工作区。

拆成两步就是:

  1. HEAD 指向哪里
  2. 把对应 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 拥有三种模式:

  1. reset --soft
  2. reset --mixed
  3. reset --hard

这 3 种模式的差别并不在“是否回滚代码”,而在于它影响了哪些区域。Git 里一共有三个相关区域:

  1. HEAD / 分支(引用层)
  2. 暂存区(index)
  3. 工作区(working directory)

三种 reset 的行为如下:

  • --soft:移动分支指针,不动暂存区、不动工作区
  • --mixed(默认):移动分支指针,重置暂存区,不动工作区
  • --hard,移动分支指针,重置暂存区,重置工作区

可以用一个很形象的比喻:

  • HEAD/分支:书签指向哪一页(历史快照)
  • 暂存区:你准备复印提交的那一叠纸(提交候选)
  • 工作区:你桌上正在涂改的草稿纸

reset 的核心只有一句话:把“书签”移到某个 commit 上。但移完书签后,你可能希望:

  • 只改书签,不动你桌上的草稿(soft 模式)
  • 书签改了,复印那叠也跟着回到那一页(mixed 模式)
  • 书签改了,复印那叠也回去,桌上草稿也全部覆盖回去(hard 模式)

这就是三种 reset 的差别。下面我们通过一个完整的例子来理解三种模式的区别。

前期准备

假设当前的历史是:

A —— B —— C   (main/HEAD)

file.txt 在 commit C 的内容是:

C版本内容

接下来做两个操作:

  1. 改了工作区的文件(但还没 add),例如把 file.txt 改成:
工作区修改内容(未add)

此时状态:

  • HEAD/分支:还在 C
  • 暂存区:还是 C
  • 工作区:已经变了
  1. 又 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

结果是什么?

  • HEAD/分支:B
  • 暂存区:B
  • 工作区:B

之前的:

  • 工作区修改
  • 已 add 的内容

全部没了。可以这样理解:我就想彻底回到 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?

答案是:没有了。

  • main 指不到
  • 没有其他分支
  • 没有 tag

所以: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 进行代码管理。如果你想深入学习更多此类底层原理和开源实战经验,欢迎来云栈社区的技术文档板块与其他开发者一起交流探讨。




上一篇:Android性能优化:使用MPAM协调CPU与系统缓存
下一篇:内卷与工贼之辩:别再让中年程序员为糟糕的职场环境背锅
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-27 18:15 , Processed in 0.274840 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表