卷 II · 贴纸CH 06深度 6/24

分支只是一张贴纸

卷 I 讲的对象库是只增不改的——那里没有任何东西会变。这一卷讲另外半边:Git 里唯一会变的部分。而它小得离谱:一个分支,41 个字节。

refs41 字节O(1) 建分支packed-refs

先把它 cat 出来

不用铺垫,直接看:

$ cat .git/refs/heads/main
1bada9988e4c142f884f13990c310e024c36f1ea

$ wc -c .git/refs/heads/main
      41 .git/refs/heads/main

40 个十六进制字符,加一个换行。41 个字节。

这就是一个分支的全部内容。没有别的文件,没有隐藏的数据库,没有元信息。main 这个分支从头到尾就是这么一个小文件,文件名是分支名,内容是一个提交哈希。

◆ 这本书的主线(第二句)

分支只是一张贴纸。

它是一个指向某个提交的、可以随手撕下来贴到别处的名字。它不拥有提交,不包含代码,不记录历史。

这就是为什么本书里分支永远画成这个样子——歪着贴、带阴影,一眼区别于那些不可变的对象。对象是内容决定的、改不了的;贴纸是可变的、随时能撕。

那「分支上的提交」是什么意思

这是个用得很顺口、但严格来说不成立的说法。

提交对象里没有任何字段记录它属于哪个分支。你回去翻第 4 章那个 commit 对象——treeparentauthorcommitter、信息。没有 branch 这一项。

git branch --contains <提交> 是怎么算出来的?

反过来算的。Git 拿每个分支的贴纸做起点,顺着 parent 一路往回走,看能不能走到那个提交。走得到,就说这个分支「包含」它。

◆ 「在某个分支上」的精确含义

「提交 X 在分支 B 上」= 从 B 的贴纸出发,顺着 parent 指针能走到 X。

就这么一个图上的可达性判断,没有别的含义。

两个推论,都很反直觉:

  • 一个提交可以同时「在」很多个分支上——只要它们都能走到它。事实上,主干上的老提交几乎在所有分支上。
  • 一个提交可以不在任何分支上——没有任何贴纸能走到它。它还在库里,只是没有名字(第 7、9 章)。

建分支到底做了什么

下面这台贴纸台可以直接操作。

▶ 动手 · 贴纸台

点「git branch feat」,看看发生了什么。然后注意底下那个「建分支要拷贝代码吗」。

再试试提交几次,看贴纸怎么跟着走;然后删掉 feat,看它有多干脆。

建一个分支,Git 做的全部事情是:

$ git branch feat
# 等价于:
$ echo "1bada9988e4c142f884f13990c310e024c36f1ea" > .git/refs/heads/feat

新建一个文件,写 41 个字节。没了。

不复制代码、不复制历史、不遍历任何东西、不碰对象库。O(1),而且是很小的那个 1。

这一点值得跟别的版本控制系统对照一下,因为差距大到会改变工作习惯:

系统建分支做什么代价
Git写一个 41 字节的文件瞬间,与仓库大小无关
SVN在仓库里复制一整棵目录(服务端有优化,但概念上是拷贝)要联网;大仓库明显有感
老式集中式 VCS常常真的复制文件贵到「建分支」是一个需要审批的决定

这个代价差异直接决定了工作方式。分支贵的时候,团队会尽量避免分支,长期在主干上直接改;分支便宜到可以忽略的时候,「每个功能开一个分支」「随手开个分支试一下」才成为默认习惯。

Git 的分支模型不是因为它设计得好,而是因为它便宜到让人根本不需要犹豫。

那为什么 checkout 也这么快

这是个好问题——切换分支毕竟要改工作区的文件,那可不是改 41 个字节就完事的。

但它依然很快,靠的是第 3 章那个 Merkle 剪枝:

  1. 拿到两个分支各自的根树哈希
  2. 一样?那整个工作区不用动,直接改 HEAD 就完事
  3. 不一样?比较两棵树的条目,哈希相同的整棵子树直接跳过
  4. 只对真正不同的那些路径做文件读写

所以 checkout 的代价正比于「两个分支之间的差异」,而不是仓库大小。两个只差三个文件的分支,无论仓库有 10 万个文件还是 100 个,切换起来都是一样快。

这也解释了一个你可能遇到过的反例:切换到一个差异巨大的分支(比如跨了几百个提交)确实会明显变慢,因为这时候真的有几千个文件要写。快的是「差异小」,不是「切换」这个动作本身。

⌗ 掀开 .git 看一眼

整个引用系统就是一个目录树,可以直接列出来:

$ find .git/refs -type f
.git/refs/heads/main
.git/refs/heads/feat
.git/refs/tags/v1.0
.git/refs/remotes/origin/main

目录结构就是命名空间。refs/heads/ 下是本地分支,refs/tags/ 下是标签,refs/remotes/ 下是远程跟踪分支(第 8 章)。

顺便解释一个你可能见过的怪事:分支名里的斜杠feature/login 这样的分支名,在磁盘上就是真的目录

$ git branch feature/login
$ find .git/refs/heads -type f
.git/refs/heads/main
.git/refs/heads/feature/login

这也是为什么不能同时有 featurefeature/login 两个分支——文件系统里,feature 不能既是文件又是目录。Git 会直接报错,而错误信息经常让人一头雾水。现在你知道原因了。

还有一个更权威的查看方式,它会连打包过的引用一起列出来:

$ git show-ref
⚠ 找不到 .git/refs/heads/main 别慌

分支多了以后,Git 会把它们打包进一个文件来省 inode:

$ cat .git/packed-refs
# pack-refs with: peeled fully-peeled sorted
1bada9988e4c142f884f13990c310e024c36f1ea refs/heads/main
a3f21c9e77b0d4c8135e6a9f02b4d7e8c1a5b3d6 refs/remotes/origin/main

这时候 .git/refs/heads/ 可能是空的,但分支一个没丢。

查找顺序是:先看松散的文件,没有再查 packed-refs所以一旦某个分支被更新,它会重新以松散文件的形式出现,覆盖掉打包版本。

教训:不要自己 cat .git/refs/... 来写脚本,用 git rev-parse <分支>——它会处理所有这些情况。本书里直接 cat 是为了让你看见真相,不是推荐的做法。

✎ 「分支」这个词的三种用法

日常对话里这个词至少有三个意思,混起来会很乱:

  • 那张贴纸(严格意义):refs/heads/main 这个 41 字节的引用。本书说「分支」时默认指这个。
  • 那条线(口语):「这个分支上的提交」——其实指的是从贴纸能走到的那部分 DAG
  • 那件事(工作流):「我在做登录分支」——指一项工作,跟 Git 无关。

大部分 Git 困惑来自把第一种和第二种混为一谈。删掉一个分支,删的是贴纸,不是那条线上的提交。那些提交还在库里,只是没人叫得出它们的名字了。

这也解释了 git branch -d-D 的区别:-d 会检查「这个分支的提交是不是已经被别的分支包含了」,没有就拒绝删除并警告你——因为删掉之后那些提交就没名字了。-D 是「我知道,照删」。

↩ 回到你的仓库

「同事说『你分支太多了,清理一下』——这会释放空间吗?」

几乎不会。每个分支 41 个字节。一千个分支加起来 41 KB,还不如一张截图。

但删分支确实有别的效果,而且是真实的:

  • 那个分支独有的提交会变得不可达,于是它们指向的对象有资格gc 回收了。如果那个分支上提交过大文件,空间是能省下来的——但省的是对象的空间,不是分支的。
  • 人的注意力。这才是真实动机:git branch 列出两百行没人记得的分支,是一种认知负担。

顺带说一句:删远程分支(git push origin --delete xxx)之后,你本地的 origin/xxx 不会自动消失——那是另一张贴纸,得 git fetch --prune 才会清掉。下一章正好讲这个。

这一章的一句话

一个分支就是 .git/refs/heads/ 下的一个 41 字节文件,里面写着一个提交哈希。建分支是写一个文件,删分支是删一个文件——提交对象里没有任何字段记录「我属于哪个分支」,所谓「在某个分支上」只是「从那张贴纸出发能走到」这个图上的可达性判断。

下一章:分支是贴纸,那 HEAD 是什么?它是一张贴在贴纸上的贴纸——而当它不贴在任何贴纸上、直接贴在提交上时,就出现了那个吓人的名字:detached HEAD。你会看到它其实一点都不可怕。