Git 深入课(3/12):引用、HEAD 与 reflog

📚 Git 深入课(以 sourcepack 仓库为标本)· 第 3/12 章

全部示例均来自真机实测;实验请在隔离克隆里做。

本章目标:搞清"分支"到底是什么、"提交丢了"能不能救,以及 Git 为什么敢承诺
"几乎所有误操作都可恢复"。学完你会理解:
对象库不可变(第 2 章) + 引用只是可移动的指针 = 历史永不被真正删除。

本章还会当场解掉第 2 章留的思考题:手工造的提交怎么才能出现在 git log 里。


3.1 引用就是文件

第 1 章说过 Git 内部只有三块东西,其中"引用"是唯一可变的部分。
它可变的方式非常朴素——就是一个装着 40 位哈希的文本文件:

# 新建一个分支(= 写一个 41 字节的文件)  
$ git update-ref refs/heads/demo <sha>  
$ cat .git/refs/heads/demo  
94af1cd57e1573048348bc429bc053de2492dd1a  
$ cat .git/refs/heads/demo | wc -c  
41            # 40 位哈希 + 一个换行  

"创建分支"就是这么便宜——第 1 章说的"分支廉价",字面意思就是写个 41 字节的文件。

松散引用 vs 打包引用

引用可以以两种形态存在,松散优先:

形态 位置 何时出现
松散(loose) .git/refs/heads/<name> 每次提交/移动分支时新写
打包(packed) .git/packed-refs(一个文件装很多引用) git gc / git pack-refs 之后

实测:仓库里 44 个引用被打包,而新提交会立刻产生一个松散文件覆盖它 [lab]:

$ grep -c "refs/" .git/packed-refs  
44  
$ git commit -q --allow-empty -m "空提交"  
$ cat .git/refs/heads/demo  
94af1cd57e1573048348bc429bc053de2492dd1a     # ← 新写的松散引用,值已变  

对工具的意义:永远不要自己读 .git/refs/。你以为它在文件里,它可能在
packed-refs 里,也可能在两者之中(松散优先)。sourcepack 一律用
git rev-parse / git show-ref / git for-each-ref 去问 Git 本身——
这也是我们"不自己实现 git 层"的第一条具体收益。


3.2 HEAD 是"符号引用"

.git/HEAD 里装的不是哈希,而是另一个引用的名字:

$ cat .git/HEAD  
ref: refs/heads/git-course  

这叫符号引用(symbolic ref)。所以:

  • HEAD 的意思是"我现在在哪个分支上";
  • git rev-parse HEAD 会递归解析:HEAD → refs/heads/git-course → 实际哈希;
  • 直接问"HEAD 这个符号指向谁"要用另一个命令:
$ git symbolic-ref HEAD  
refs/heads/git-course  
$ git rev-parse --short HEAD  
aa686d8  

这两个命令的区别是整个第 3 章的关键:symbolic-ref 问"名字",
rev-parse 问"值"。


3.3 引用的命名空间

所有引用都在 refs/ 下,按用途分命名空间:

$ git for-each-ref --format='%(refname)  →  %(objectname:short)'  
refs/heads/git-course          →  aa686d8      # 本地分支  
refs/remotes/origin/HEAD       →  aa686d8      # 远端分支(fetch 得到)  
refs/remotes/origin/main       →  aa686d8  
refs/tags/v1.0.0               →  962753c      # 标签  
refs/tags/v1.0.1               →  b1feaa8  
refs/tags/v2.0.1               →  c7a057a      # 注意:这不是 commit 的哈希(见 3.4)  

除了 for-each-ref 能看到的这些,还有几个特殊引用(存在 .git/ 根下):

引用 含义
HEAD 当前所在位置
ORIG_HEAD 上次 reset / merge / rebase 之前的位置("后悔药")
FETCH_HEAD 上次 fetch 抓到了什么
MERGE_HEAD 正在合并的那个"对方"提交(合并冲突时存在)
CHERRY_PICK_HEAD 正在 cherry-pick 的提交

这些文件的存在与否本身就是状态:比如 .git/MERGE_HEAD 存在 = 仓库正处于
"合并未完成"状态。git status 会据此提示你。


3.4 轻量标签 vs 附注标签(一个真实的 tag 对象)

轻量标签(lightweight) 附注标签(annotated)
本质 一个直接指向 commit 的 ref 一个独立的对象(第 4 种对象类型)
创建 git tag name git tag -a name -m msg
cat-file -t commit tag

实测我们仓库的 v2.0.1(git tag -a 创建的)[lab]:

$ git cat-file -t v2.0.1  
tag  
$ git cat-file -p v2.0.1  
object aa686d8e14f8fc729a32a30d42dbf32c0b94b29f  
type commit  
tag v2.0.1  
tagger yuanguangshan <yuanguangshan@gmail.com> 1791813987 +0800  
  
v2.0.1: -h 末尾换成参数手册博客版(https://s.want.biz/sourcepack)  
$ git cat-file -t lightweight-demo        # 对照:轻量标签  
commit  

为什么发布要用附注标签:它带签名、日期、发布说明,而且是一个不可变对象
(第 2 章:对象不可变)。我们的 goreleaser 工作流就是靠 v* 标签触发的——
refs/tags/v2.0.1 这个引用一旦推送,CI 就能拿到它。


3.5 revspec:引用系统的表达式语言

Git 允许用文字表达式指代任意提交。sourcepack 的 --rev 直接把你的输入交给
Git 解析,所以下面这些全部可用(实测)[lab]:

revspec 含义 实测结果
HEAD 当前提交 ✅
HEAD~1 第一个父提交(往回 1 代) ✅
HEAD@{1} reflog 里的上一步(3.8 节的主角) ✅
handmade 任意引用名(分支/tag/远端) ✅
v2.0.1 标签 ✅
$ gx -g --rev HEAD@{1} --dry-run -d .  
📦 Git changes: git diff HEAD@{1}  (working tree + index vs HEAD@{1})  

常用语法速查:

写法 含义
<rev>~<n> 沿第一父往回 n 代
<rev>^<n> 第 n 个父(合并提交:^1 主线、^2 被并进来的分支)
<rev>@{<n>} 该引用的第 n 步 reflog(@{0} 是当前)
<rev>@{<time>} 按时间:HEAD@{yesterday}、main@{2.hours.ago}
<rev>:<path> 该版本下的某个文件(HEAD:README.md)
<rev>^{commit} 剥壳:一路剥到 commit(tag → commit,见下)
<rev>^{tree} 剥到 tree

剥壳 ^{commit}:为什么 sourcepack 需要它

v2.0.1 是附注标签,它的哈希指向一个 tag 对象,不是 commit:

$ git rev-parse v2.0.1  
c7a057a324abc15567c01951c1003be614f94608      # tag 对象  
$ git rev-parse "v2.0.1^{commit}"  
aa686d8e14f8fc729a32a30d42dbf32c0b94b29f       # 它指向的提交  

所以 changeset.go:321 用 ^{commit} 校验用户给的 --rev:

	} else if _, err := g.run("rev-parse", "--verify", base+"^{commit}"); err != nil {  

实测这个校验的价值:给它一个树对象(合法 revspec,但不是提交)也会被拦住,
并给出可读报错:

$ gx -g --rev "not-a-rev" --dry-run -d .  
❌ invalid --rev "not-a-rev": git rev-parse --verify not-a-rev^{commit} failed:  
   fatal: Needed a single revision  

这里体现两种设计的选择:我们不解析 revspec(那是 Git 的领域,规则极多),
只做两件事——① 让 Git 去解析;② 把 Git 的报错包装成用户能看懂的一句话。


3.6 第 2 章思考题解答:怎么让"手工提交"进入历史

第 2 章我们用 plumbing 造了一个提交,但它不出现 git log 里。原因很简单:
提交之间不靠"列表"连接,靠的是"引用能到达它"。git log 的起点是引用
(默认 HEAD),顺着 parent 边回溯——没有引用指向的提交就是"不可达"的。

实测(同一个提交,加引用前后)[lab]:

$ C=$(git commit-tree "$TREE" -m "手工提交:现在给它一个引用")  
$ git log --oneline --all | grep -c b42db3a  
0                                   # ← 加引用之前:找不到  
$ git update-ref refs/heads/handmade "$C"  
$ git log --oneline --all | grep -c b42db3a  
1                                   # ← 加引用之后:出现了  
$ git log --oneline -1 handmade  
b42db3a 手工提交:现在给它一个引用  

所以"创建分支"= 给某个提交一个名字。这也解释了 git branch <name> <sha>
为什么可以给任意提交起名字——包括你以为已经丢掉的提交(见 3.8)。


3.7 detached HEAD:符号引用会"断"

git checkout <sha> / git checkout --detach 会让 HEAD 不再指向分支,
而是直接指向一个提交:

$ git checkout -q --detach HEAD~1  
$ git status  
HEAD detached at aa686d8  
$ git symbolic-ref --short HEAD  
fatal: ref HEAD is not a symbolic ref        # ← 这个命令失败了  
$ git rev-parse --short HEAD  
aa686d8                                       # ← 但值还是能取到  

这就是为什么"取值"和"取名字"必须分开:detached 状态下有值、没有名字。

回到 sourcepack:我们的三段式回退

changeset.go:282-288 正是为这三种状态写的(代码原样):

	if br, berr := g.run("symbolic-ref", "--short", "HEAD"); berr == nil && strings.TrimSpace(br) != "" {  
		cs.Branch = strings.TrimSpace(br)  
	} else if sha, serr := g.run("rev-parse", "--short", "HEAD"); serr == nil {  
		cs.Branch = "detached at " + strings.TrimSpace(sha)  
	} else {  
		cs.Branch = "(no commits yet)"  
	}  

三种状态一一对应(下面这张表是实测出来的,不是推想的):

状态 symbolic-ref --short HEAD rev-parse HEAD sourcepack 显示
在分支上 成功 → refs/heads/x 成功 Branch: x
detached HEAD 失败(fatal: ref HEAD is not a symbolic ref) 成功 Branch: detached at aa686d8
还没有任何提交(unborn HEAD) 仍然成功 → main 失败(退出码 128) Branch: main + 警告(见下)

unborn HEAD 这一格值得停一下:git init 之后、第一次提交之前,
.git/HEAD 里已经写着 ref: refs/heads/main,但 .git/refs/heads/main
这个文件并不存在——HEAD 是"指向一个还不存在的分支"的符号引用。
所以 symbolic-ref 成功、rev-parse 失败。

实测(空仓库 + 一个未跟踪文件) [lab]:

$ git init -q -b main . && printf 'package main\n' > a.go  
$ git symbolic-ref --short HEAD  
main                                  # 成功:符号引用有效  
$ git rev-parse HEAD ; echo $?  
128                                   # 失败:还没有提交对象  
$ test -f .git/refs/heads/main || echo "分支文件不存在"  
分支文件不存在  
  
$ gx -g --dry-run -d .  
📦 Git changes: git diff 4b825dc642cb6eb9a060e54bf8d69288fbee4904  (working tree + index vs 4b825dc...)  
  Repository:          /private/tmp/unborn  
  Branch:              main  
  Files changed:       1 (+1 −0)  
                       staged 0 · unstaged 0 · untracked 1  
  ⚠ repository has no commits yet — diffing against the empty tree  

注意这一行的基准是 4b825dc...——正是第 2 章那个空树对象。
源码在 changeset.go:320 附近:

		cs.Warnings = append(cs.Warnings, "repository has no commits yet — diffing against the empty tree")  

而 -G(按提交数打包)在无提交时直接报错(changeset.go:444),
因为"最近 N 个提交"没有可数的对象:

$ gx -G 3 --dry-run -d .  
❌ repository has no commits yet: -G/--commits needs at least one commit  

这里体现了第 2 章那个常量的第二个用途:不仅是"根提交的基准",
也是"整个仓库还没有提交时"的基准。-g 与 -G 面对同一状态给出不同反应
(一个降级并警告、一个明确报错)也是有意为之:前者"相对空树看工作区"是
有意义的(就是整个项目),后者"最近 N 个提交"没有意义。

实测 detached 状态下的产物 [lab]:

$ gx -g --dry-run -d . | grep -E "Branch|HEAD"  
  Branch:              detached at aa686d8  
  HEAD:                aa686d8 docs: v2.0.1 记录(-h 末尾换成参数手册博客版);还原 v2.0.0 的 help 引用为当时真实输出  

注意第三行 HEAD: 是另一个调用的结果(changeset.go:290:
rev-parse HEAD + log -1 --format=%s),与 Branch 分开取——
因为 detached 时"名字"没有,但"值"和"提交标题"都有。


3.8 reflog:Git 的"后悔药"(本章重头戏)

reflog 是 Git 记录"引用是怎么移动的"的日志,存在:

  • .git/logs/HEAD(HEAD 的移动史)
  • .git/logs/refs/heads/<branch>(每个分支各自的移动史)

它不是历史,是"历史的历史"——每次 checkout/commit/reset/merge/rebase 都追加一行。

完整实验:把提交 reset 掉,再救回来 [lab]

# 1) 造一个"重要提交"  
$ printf 'important\n' > important.txt  
$ git add important.txt && git commit -q -m "重要提交:马上会被我 reset 掉"  
$ GOOD=$(git rev-parse HEAD)  
$ git log --oneline -1  
4f2ada7 重要提交:马上会被我 reset 掉  
  
# 2) 事故:硬重置回上一个提交  
$ git reset --hard HEAD~1  
HEAD is now at aa686d8 docs: v2.0.1 记录(...)  
$ test -f important.txt || echo "文件不在了(工作区被重置)"  
文件不在了(工作区被重置)  
$ git log --oneline | grep -c 4f2ada7  
0                                   # ← 从"当前历史"里消失了  
  
# 3) 救援第一步:reflog 记得一切  
$ git reflog -5  
aa686d8 HEAD@{0}: reset: moving to HEAD~1  
4f2ada7 HEAD@{1}: commit: 重要提交:马上会被我 reset 掉     # ← 就在这儿  
aa686d8 HEAD@{2}: checkout: moving from aa686d8... to recover  
  
# 4) 救援第二步:把引用指回去  
$ git reset --hard "$GOOD"  
HEAD is now at 4f2ada7 重要提交:马上会被我 reset 掉  
$ cat important.txt  
important                            # ← 文件、历史、全部回来  
$ git reflog -1  
4f2ada7 HEAD@{0}: reset: moving to 4f2ada71141360476dd16561680c432a695e256b  

四个值得记住的观察:

  1. reset --hard 后提交"看不见了",但对象还在磁盘上(第 2 章的不可变性);
  2. reflog 是本地的:clone 不会带上别人的 reflog(所以它是"个人操作史");
  3. 救援动作本身也被记进 reflog——reflog 是追加日志,不会被"覆盖";
  4. 同理,git reset 回退分支不会丢提交,只是把指针往后挪。

reflog 也会过期

配置 默认 含义
gc.reflogExpire 90 天 HEAD 的 reflog 保留期
gc.reflogExpireUnreachable 30 天 不可达提交的 reflog 保留期

过期 + git gc 之后,没有任何引用的对象才会被真正清除。
所以"事故后别慌、别急着 gc"是有道理的——reflog 还在,就能救。


3.9 reflog 也没了怎么办:dangling 对象与 fsck

如果提交从来没被引用过(比如第 2 章手工造的),reflog 里也不会有它。
但对象仍在对象库里,git fsck 能看到"没人引用"的对象:

$ git fsck --lost-found | grep -c "dangling commit"  
6                                    # ← 6 个吊着的提交(含我实验里造的那些)  
$ git log --oneline -1 8c8ccb5        # 直接按哈希读  
8c8ccb5 孤儿提交  
概念 含义
可达对象 从某个引用出发、顺着 parent/tree 能找到的
dangling(吊着的)对象 对象库里存在、但没有任何引用能到达
会被 gc 删掉吗 只有"过期 + 不可达"才会。reflog 里的引用也算一种到达

实用技巧:git fsck --lost-found 会把吊着的对象写进 .git/lost-found/;
git fsck --unreachable 只列不写。找到哈希后用 git show <sha> 看内容,
git branch rescue <sha> 把它救回历史。


3.10 回到 sourcepack:一行行对照

代码位置 做什么 对应本章概念
changeset.go:256 rev-parse --show-toplevel 找仓库根 不自己找 .git,问 Git
changeset.go:271 rev-parse --show-prefix 算子目录作用域 用 Git 的路径语义(绕开 /tmp 软链坑)
changeset.go:282-285 symbolic-ref → rev-parse 分支名 / detached 符号引用 vs 值
changeset.go:287 (no commits yet) unborn HEAD 的兜底 第三种状态
changeset.go:290-297 rev-parse HEAD → log -1 --format=%s 取当前提交与标题 递归解析;无提交时退到 (no commits yet)
changeset.go:291 log -1 --format=%s 取提交标题 commit 对象的 message
changeset.go:321 rev-parse --verify <base>^{commit} 校验 --rev 剥壳 + 让 Git 报错(unborn 时走 :320 的空树分支)
changeset.go:542 shortSHA(s) 取前 7 位 Git 的最小无歧义缩写习惯

shortSHA 的实现(我们没有调 git rev-parse --short,因为哈希已在手):

func shortSHA(s string) string {  
	if len(s) > 7 {  
		return s[:7]  
	}  
	return s  
}  

一个诚实的注脚:硬截 7 位在超大仓库里理论上有歧义风险
(Git 默认会自动加长到无歧义)。sourcepack 的场景是"给人看的摘要",
且同一产物里同时有完整路径与提交标题,所以这个简化是可接受的——
但如果哪天要拿它当唯一标识去查询,就该换成
git rev-parse --short=8 或 git rev-parse --verify <short>^{commit}。


3.11 动手实验 [lab]

cd /tmp/git-lab  
  
# 1) 引用就是文件  
git update-ref refs/heads/demo HEAD  
cat .git/refs/heads/demo          # 看它只有 41 字节  
cat .git/packed-refs | head -3    # 打包形态  
git for-each-ref --format='%(refname) → %(objectname:short)'  
  
# 2) 名字 vs 值  
cat .git/HEAD  
git symbolic-ref HEAD  
git rev-parse HEAD  
  
# 3) 让手工提交进入历史(第 2 章思考题)  
BLOB=$(printf 'x\n' | git hash-object -w --stdin)  
TREE=$(printf '100644 blob %s\tx.txt\n' "$BLOB" | git mktree)  
C=$(git commit-tree "$TREE" -m "手动提交")  
git log --all --oneline | grep "$(echo $C | cut -c1-7)" || echo "不可达:log 里没有"  
git update-ref refs/heads/handmade "$C"  
git log --oneline handmade  
  
# 4) detached HEAD 与三段式回退  
git checkout --detach HEAD~1  
git symbolic-ref HEAD ; echo "上一条命令的退出码: $?"  
gx -g --dry-run -d . | grep Branch  
git checkout -  # 回到原分支  
  
# 5) 救援演练(本章必须动手做一遍)  
printf 'x\n' > rescue.txt && git add rescue.txt && git commit -q -m "待救提交"  
GOOD=$(git rev-parse HEAD)  
git reset --hard HEAD~1  
git reflog -3  
git reset --hard "$GOOD"  
cat rescue.txt  

自测题:

  1. git branch new 和 git update-ref refs/heads/new HEAD 等价吗?
  2. HEAD@{1} 与 HEAD~1 有什么本质不同?(提示:一个是"历史的形状",一个是"你的操作史")
  3. 为什么 git clone 之后,别人看不到你本地的 reflog?
  4. 一个提交既没有分支指向、也没有 tag、reflog 也已过期——它一定被删了吗?
  5. 为什么 sourcepack 判断"分支名"要先用 symbolic-ref 而不是直接 rev-parse HEAD?

3.12 小结

  • 引用 = 装着哈希的文件(松散 .git/refs/... 或打包 .git/packed-refs,松散优先),
    创建分支的代价就是写 41 字节;
  • HEAD 是符号引用(指向另一个引用);symbolic-ref 问名字、rev-parse 问值,
    detached HEAD 时前者失败、后者成功;
  • 引用按命名空间组织:heads / remotes / tags,另有 ORIG_HEAD、FETCH_HEAD、
    MERGE_HEAD 等特殊引用(它们的存在与否本身就是仓库状态);
  • 附注标签是独立对象(cat-file -t = tag),轻量标签直接指向 commit;
  • revspec 是引用系统的表达式语言(~、^、@{n}、^{commit});
    sourcepack 不自己解析,只做校验 + 包装报错(changeset.go:321);
  • 提交靠"引用可达"进入历史——这是第 2 章思考题的答案(update-ref 0 次 → 1 次);
  • reflog 是后悔药:reset --hard 丢的提交可以靠 HEAD@{n} 找回来(完整实验见 3.8),
    reflog 过期后还能靠 fsck 找 dangling 对象,最终救回靠的还是"对象不可变";
  • sourcepack 的分支名三段回退(symbolic-ref → detached → no commits yet)是本章
    概念在真实代码里的落地,实测输出 Branch: detached at aa686d8。

下一章:索引(暂存区)——git add 到底写了什么、为什么会有 staged/unstaged 两种
状态、以及 sourcepack 的 --staged 为什么曾经把计数写成了恒为 0。