checkout 和 reset 到底改了哪里:HEAD、分支、index、worktree 的变化
前面已经讲过 Git 里最容易混淆的三份状态:
HEAD:当前提交,或者当前分支指向的提交。index:下一次提交准备包含什么。worktree:你磁盘上真正看到、正在改的文件。
再加上一个分支引用:
refs/heads/master、refs/heads/feature:分支名,本质是指向 commit 的指针。
checkout和reset难理解,是因为它们经常同时影响这几样东西。
如果只记一句话:
checkout 更像“换位置”;reset 更像“挪当前分支”。
但这句话还不够。真正要弄清楚,要问三个问题:
HEAD最后指向哪里?index会不会变?worktree会不会被覆盖?
checkout branch:换到另一个分支入口
假设当前状态是:
master -> commitA
feature -> commitB
HEAD -> master
执行:
mgit checkout feature
结果是:
HEAD -> refs/heads/feature
index -> commitB 的 tree
worktree -> commitB 的 tree
也就是说,checkout feature做了两件事。
第一,切换当前所在分支:
HEAD 从 master 换到 feature
第二,把暂存区和工作区恢复成 feature 指向的提交快照:
index/worktree 变成 commitB 的样子
所以你执行完 checkout 后,会看到文件内容也跟着变了。
这就是很多人第一次用 Git 时困惑的地方:我只是切换分支,为什么文件也变了?
因为分支不是一个目录副本。分支只是一个指针。checkout 做的是“让当前工作区呈现这个指针指向的快照”。
checkout commit:进入 detached HEAD
如果不是 checkout 分支,而是 checkout 一个 commit:
mgit checkout HASH
这时HEAD不再指向某个分支,而是直接指向某个提交:
HEAD -> HASH
这叫 detached HEAD。
人话就是:
你站在了某个历史提交上,但没有站在任何分支入口上。
在 detached HEAD 状态下继续提交,新的 commit 不是自动挂在 master 或 feature 下面的。真实 Git 会提示你小心这一点。
这不是 Git 把历史弄坏了,而是你暂时离开了“分支名”这个入口。
checkout 为什么要检查工作区
checkout 会改worktree。
只要一个命令会改工作区,就必须考虑一个风险:会不会覆盖用户还没提交的修改?
比如你当前有:
a.txt 在 worktree 里被手动改了
但还没有 add,也没有 commit
这时 checkout 到另一个分支,如果目标分支里的a.txt内容不同,就可能直接覆盖你的修改。
mini-git 的策略比较保守:
- 如果 index 和当前 HEAD 不一致,说明有 staged changes,拒绝 checkout。
- 如果 worktree 文件内容和 index 不一致,说明有 unstaged changes,拒绝 checkout。
- 如果目标 tree 会覆盖未跟踪文件,也拒绝 checkout。
这比真实 Git 的某些情况更严格,但更适合学习。
因为它把规则讲清楚了:
会写 worktree 的命令,不能随便覆盖用户本地修改。
源码里cmd_checkout.c的主线也是这个思路:
解析目标 branch/commit
检查当前 index/worktree 是否安全
读取目标 commit 的 tree
恢复 index 和 worktree
更新 HEAD
其中安全检查就是 checkout 命令最重要的工程细节。
reset:移动当前分支
reset的核心动作不是“切换分支”,而是:
把当前分支指针移动到另一个 commit。
假设现在是:
HEAD -> master -> commit2
commit2 -> parent commit1
执行:
mgit reset --mixed commit1
结果不是 HEAD 换到另一个分支,而是 master 本身被挪了:
HEAD -> master -> commit1
这就是 reset 和 checkout 的一个关键区别:
- checkout branch:HEAD 换到另一个分支入口。
- reset commit:当前分支入口本身被移动。
所以 reset 的危险性更高一点。它会改变当前分支指向的提交。
不过注意:reset 不会删除 commit 对象。
commit2 只是暂时没有分支指向了,对象还在.git/objects里。真实 Git 可以通过 reflog 找回,mini-git 里也会记录 reset 的 reflog。
reset 的三种模式
reset 难点在于它有三种模式。
| 模式 | 移动当前分支 | 更新 index | 更新 worktree |
|---|---|---|---|
--soft | 是 | 否 | 否 |
--mixed | 是 | 是 | 否 |
--hard | 是 | 是 | 是 |
一句话版:
soft:只撤提交,不动暂存区和工作区。mixed:撤提交,也撤 add,但保留文件修改。hard:撤提交、撤 add、覆盖工作区。
真实 Git 里reset默认是--mixed。mini-git 也保持了这个默认行为。
用两次提交理解 reset
假设你做了两次提交:
commit1:a.txt = one
commit2:a.txt = two
现在状态是:
HEAD -> master -> commit2
reset --soft commit1
执行:
mgit reset --soft commit1
结果:
master -> commit1
index 仍然像 commit2
worktree 仍然像 commit2
适用场景:
提交写错了,想撤掉 commit,但保留“已经 add 好”的状态,重新 commit。
reset --mixed commit1
执行:
mgit reset --mixed commit1
结果:
master -> commit1
index -> commit1
worktree 仍然像 commit2
适用场景:
提交不想要了,add 状态也不想要了,但文件修改还想保留。
这也是最常用的撤提交方式。
reset --hard commit1
执行:
mgit reset --hard commit1
结果:
master -> commit1
index -> commit1
worktree -> commit1
适用场景:
当前修改完全不要了,工作区也要回到 commit1。
这也是最危险的模式,因为它会覆盖工作区。
所以reset --hard之前要先确认:当前未提交修改真的不需要了。
checkout 和 reset 的核心区别
可以这样对比:
| 命令 | 更像什么 | 主要影响 |
|---|---|---|
checkout branch | 换到另一个分支入口 | HEAD、index、worktree |
checkout commit | 站到某个历史提交 | HEAD、index、worktree |
reset --soft | 挪当前分支 | branch/HEAD |
reset --mixed | 挪当前分支并重置暂存区 | branch/HEAD、index |
reset --hard | 挪当前分支并覆盖到目标快照 | branch/HEAD、index、worktree |
如果还是混,可以记一个判断:
checkout 改“我现在站在哪里”;reset 改“当前分支指到哪里”。
源码里怎么落地
mini-git 里相关文件主要是:
src/commands/cmd_checkout.csrc/commands/cmd_reset.csrc/core/ref.csrc/core/tree.csrc/core/index.c
checkout 的关键逻辑是:
解析目标
如果是分支,准备让 HEAD 指向 refs/heads/xxx
如果是 commit,准备进入 detached HEAD
检查 index/worktree 是否干净
用目标 commit 的 tree 恢复工作区和 index
更新 HEAD
reset 的关键逻辑是:
解析目标 commit
根据模式决定是否读取目标 tree
soft 不动 index/worktree
mixed 用目标 tree 重建 index
hard 用目标 tree 重建 index 并恢复 worktree
最后移动当前分支引用
这里有一个细节:mini-git 的reset --mixed/--hard是先同步状态,成功后再移动引用。这样如果恢复 index/worktree 失败,不会出现“分支已经挪了,但文件没恢复成功”的半截状态。
面试里怎么说
可以这样答:
checkout 更偏切换当前位置。checkout 分支时,HEAD 会指向目标分支,同时 index 和 worktree 会恢复成目标分支对应 commit 的 tree;checkout 某个 commit 时会进入 detached HEAD。reset 更偏移动当前分支指针,soft/mixed/hard 的区别在于是否同步 index 和 working tree:soft 只动分支,mixed 动分支和 index,hard 三者都动。
如果追问为什么 checkout/reset 容易丢修改,可以补一句:
因为 checkout 和 reset --hard 都可能写 worktree。如果当前工作区有未提交修改,目标快照又要覆盖同名文件,就可能丢数据,所以实现上必须做安全检查或要求用户明确使用 hard。
总结
这一篇抓住五句话就够了:
checkout branch是换分支入口。checkout commit是 detached HEAD。checkout会恢复 index/worktree,所以要保护本地修改。reset是移动当前分支指针。soft/mixed/hard的区别,就是影响范围从 HEAD 扩大到 index,再扩大到 worktree。
理解这几个点,checkout、reset就不再是“玄学撤销命令”,而是几个明确状态之间的移动。