HEAD:你现在站在哪
Git 会给你看一段很长的警告,标题叫「You are in 'detached HEAD' state」,读起来像是出了大事。其实什么事都没有。这一章把 HEAD 拆开——它比分支还简单,而 detached 状态只是「你站在一个没贴纸的地方」。
又是一个小文件
$ cat .git/HEAD ref: refs/heads/main
就这一行。
注意它不是一个哈希,而是一句「我指向 refs/heads/main 这个引用」。这种「指向另一个引用」的引用,Git 叫它符号引用(symbolic ref)。
所以现在有两层:
HEAD ──→ refs/heads/main ──→ 1bada99 (提交对象) 你在哪 那张贴纸在哪 真正的东西
分支贴纸回答的是:「这条线走到哪儿了?」
HEAD 回答的是:「你现在站在哪?」
这个区别很关键。main 是一个客观存在的标记;HEAD 是你的位置——它决定了 git commit 时新提交挂在谁后面、git status 拿什么跟工作区比、git diff 的默认参照物是什么。
Git 里几乎每一个不带参数的命令,隐含的参数都是 HEAD。
提交的时候,谁在动
这是理解 HEAD 最重要的一步。你 git commit 的时候,发生的是:
- 把索引写成一棵树(第 3 章)
- 造一个 commit 对象,
parent设成 HEAD 当前解析到的那个提交(第 4 章) - 看 HEAD 指向什么:
- 指向一个分支 → 把那张贴纸挪到新提交上。HEAD 因为贴在贴纸上,自动跟过去了。
- 直接指向一个提交 → 只更新 HEAD 自己。没有任何贴纸跟着走。
第二种情况就是 detached HEAD。差别只在第 3 步的那个分叉。
按这个顺序点,最有感觉:
① 点「checkout B(提交号)」——注意 .git/HEAD 里的内容变了。
② 点两次「在这里提交一个」——看新提交上有没有贴纸。
③ 再点「checkout main」——那两个提交去哪了?
detached HEAD 到底怎么回事
当你 checkout 一个提交而不是分支时:
$ git checkout 06b431f $ cat .git/HEAD 06b431f5f556d4b4d9b2f2fa4240e4d12fc6c975
.git/HEAD 里直接写着一个哈希,不再是 ref: ...。中间那层贴纸没有了。
这就是「detached(脱离)」的字面含义:HEAD 从分支上脱下来了。
Git 那段长警告吓到过太多人,所以要说清楚:detached HEAD 是一个完全合法、经常有用的状态。
你在这里可以随便看、随便编译、随便跑测试、甚至随便提交。工作区就是那个提交的样子,一切正常。
唯一的区别只有一个:你在这里做的提交,上面没有任何贴纸。
而这意味着——一旦你 checkout 走,就没有任何名字能叫出它们了。它们还在库里(对象不会被删,第 5 章),但你得靠 reflog 才能找回来(第 9 章)。
Git 那段警告啰嗦,但意思其实就一句:你要是在这儿干活,记得走之前贴张贴纸。
贴贴纸的命令朴素得可笑:
$ git switch -c 我的新分支 # 或者老写法 $ git checkout -b 我的新分支
它做的事就是:用当前 HEAD 的位置建一个分支,然后把 HEAD 贴回那张贴纸上。两个小文件的写入而已。
什么时候你会遇到它
这些场合都会让你进入 detached 状态,而且大多是正常操作:
| 场合 | 为什么 | 要紧吗 |
|---|---|---|
git checkout <提交号> | 你明确要求站到一个提交上 | 不要紧,看完切回去就行 |
git checkout v1.0(标签) | 标签不是分支,不能移动,所以只能 detach | 正常。看历史版本就该这样 |
| rebase 进行中 | Git 正在一个个重放提交,中途本来就没有分支 | 正常,rebase 结束会自动贴回去 |
git bisect 过程中 | 它在历史里跳来跳去,每次 checkout 一个提交 | 正常,bisect reset 会回去 |
| CI 系统里 | CI 通常 fetch 一个提交然后直接 checkout,不建分支 | 正常。这就是为什么 CI 里 git branch 常常是空的 |
看这张表能得出一个安慰:你每次 rebase,中途都处在 detached HEAD 状态。只是 Git 没提醒你而已。它真的没那么特殊。
亲手把 HEAD 改一遍,看它有多朴素:
# 现在的状态 $ git symbolic-ref HEAD refs/heads/main # 解析成真正的提交 $ git rev-parse HEAD 1bada9988e4c142f884f13990c310e024c36f1ea # 切到一个提交 —— 现在 symbolic-ref 会报错,因为它不再是符号引用了 $ git checkout 06b431f $ git symbolic-ref HEAD fatal: ref HEAD is not a symbolic ref $ cat .git/HEAD 06b431f5f556d4b4d9b2f2fa4240e4d12fc6c975
再看看 .git 下还有哪些「特殊 HEAD」:
$ ls .git/*HEAD*
.git/HEAD 你现在在哪 .git/ORIG_HEAD 危险操作前,Git 自动存的「你原来在哪」 .git/FETCH_HEAD 上次 fetch 拉下来的东西 .git/MERGE_HEAD 合并进行中时,「另一边」是谁(合并完就删)
ORIG_HEAD 值得记住。在 reset、merge、rebase 这类会大幅移动 HEAD 的操作之前,Git 会自动把原来的位置写进去。所以后悔了可以:
$ git reset --hard ORIG_HEAD
这是 Git 专门为「刚才那下干错了」准备的后门。第 20 章会再用到它。
这几个符号被搞混的频率极高,一次说清:
HEAD—— 你现在的位置。@是它的简写,完全等价。HEAD~n—— 往上数 n 代,每次都走第一个父提交。HEAD~2= 爷爷。HEAD^n—— 第 n 个父提交。HEAD^2只在合并提交上有意义(第二个爹)。HEAD@{n}—— reflog 里第 n 条,也就是「HEAD 上上上次在哪」。完全是另一个维度(第 9 章)。
为什么大家总混?因为普通提交只有一个父提交,所以 HEAD~1 和 HEAD^1 恰好指向同一个东西。只有在合并提交上,两者才会分道扬镳:
A ← B ← C ← M M 是合并提交
/
D ← E ─
HEAD = M
HEAD~1 = HEAD^1 = C (第一个父提交,也就是「你合并时所在的那条线」)
HEAD^2 = E (第二个父提交,「被合进来的那条线」)
HEAD~2 = B (沿第一父提交再往上一代)
记忆法:~ 是竖着走(往上数几代),^ 是横着挑(选第几个爹)。
顺带一个实用技巧:git log --first-parent 只走第一个父提交,能把一堆合并泡泡压平成主干的直线历史——看「主干上发生了什么」时非常好用。
真发生了也别慌,按这个顺序做:
如果你还没切走(工作区还在那儿):
$ git switch -c 救命分支 # 就地贴一张贴纸,什么都不会丢
如果已经切走了:
$ git reflog # 找到那几个提交的号 $ git branch 救命分支 <提交号> # 贴回去
唯一真的会丢东西的情况:你在 detached HEAD 上改了文件但从没 commit 过,然后 git checkout main 强行覆盖了工作区。这跟 detached 无关——没提交过的东西本来就不在 Git 手里(第 20 章会把这条规律讲透)。
好消息是:Git 在切换分支时如果发现会覆盖你未提交的改动,默认会拒绝并报错。所以真要丢,得你自己加 -f。
「CI 报错说 fatal: You are not currently on a branch,本地跑好好的。」
典型的 detached HEAD 场景。绝大多数 CI 系统(GitHub Actions、GitLab CI、Jenkins)为了速度,会 fetch 一个特定的提交然后直接 checkout 那个哈希,不建分支——因为它只需要那一刻的代码,不需要分支这个概念。
所以在 CI 里:
git branch --show-current返回空git push origin HEAD会报「不在分支上」- 依赖「当前分支名」的脚本会拿到空字符串,然后以各种莫名其妙的方式失败
解决办法:别从 Git 里读分支名,从 CI 的环境变量里读(GITHUB_REF_NAME、CI_COMMIT_REF_NAME 之类)。CI 知道你在构建哪个分支,Git 不知道——因为那个信息本来就不在仓库里。
这又回到了第 6 章那句话:提交里没有任何字段记录「我属于哪个分支」。CI checkout 了一个哈希之后,那个信息就真的不存在了。
这一章的一句话
HEAD 是「你现在站在哪」,通常是一个写着 ref: refs/heads/xxx 的符号引用——贴在贴纸上的贴纸。当它直接写一个哈希时就是 detached HEAD,这不是错误,只意味着你在这里的提交不会有任何贴纸跟着走;解法就是 git switch -c,贴一张上去。
下一章:还有第三种贴纸——origin/main。它看起来像「服务器上的分支」,但它根本不是。它是你上一次跟服务器通话时抄下的一张便条,而这个误解制造了大量的「为什么我 pull 了还是旧的」。