bigywater.tech
Git 内部原理 · 02

Git 分支的本质:创建一个分支,.git/objects 里到底多了几个对象

"git 分支很轻量"这句话到处都能看到,但很少有人真的数过"轻量"具体是多轻。上一篇验证了 blob/tree/commit 都是按内容哈希存的对象,这一篇接着问:分支本身算不算一种对象?创建一个分支,磁盘上到底多写了什么?真实建了两次提交、真实创建/删除分支,边跑边数。

对象数 +0
真实创建一个分支前后,.git/objects 目录里的对象总数完全没变
41 字节
一个分支在磁盘上就是这么大的一个文件,内容是一行 40 位哈希加换行符

上一篇:Git 是怎么给内容去重的

思路:ref 跟 blob/tree/commit 完全不是一回事

前三种是内容寻址的对象,分支是纯粹的指针,连哈希都不用算。

上一篇验证过,.git/objects 里的每一份内容——不管是文件、目录结构还是提交本身——都要先按内容算一次 SHA-1,再按这个哈希存放,内容不变,哈希必然不变。分支不是这样的东西。一个分支只是 .git/refs/heads/ 目录下的一个普通文本文件,文件名就是分支名,文件内容就是它当前指向的 commit 哈希——没有类型头,没有 zlib 压缩,不参与 .git/objects 那套内容寻址体系。git branch foo 做的事,说到底就是"新建一个文本文件,写一行当前 commit 的哈希进去"。

真实验证:创建分支,对象总数一个没多

两次真实提交之后创建一个新分支,前后对比 .git/objects 里的对象数量。

真实实测
$ cat .git/HEAD
ref: refs/heads/main

$ cat .git/refs/heads/main
f70352e6a0ab6d433b5f4595f2ec98c36d08f282

$ find .git/objects -type f | wc -l
6

$ git branch feature-x

$ find .git/objects -type f | wc -l
6
创建分支前后,对象总数都是 6,一个没多——git branch 没有让任何新内容进入内容寻址存储

再看这个新分支的 ref 文件本身:

真实实测
$ cat .git/refs/heads/feature-x
f70352e6a0ab6d433b5f4595f2ec98c36d08f282

$ ls -la .git/refs/heads/
-rw-r--r--  41 main
-rw-r--r--  41 feature-x
跟 main 内容完全一样(因为刚创建,两个分支指向同一个 commit),文件大小都是 41 字节 —— 40 个十六进制字符 + 1 个换行符,没有任何多余的结构

这就是"分支创建几乎零成本"这句话背后真实发生的事:不用复制任何提交历史,不用给任何对象打标记,只是多写了一个 41 字节的文本文件。一个仓库里有 5 个分支还是 500 个分支,只要它们指向的 commit 集合不变,.git/objects 里的内容不会有任何差别。

真实验证:在新分支上提交,main 完全不受影响

切到 feature-x,真实提交一次,看 main 分支的 ref 文件会不会跟着变。

真实实测
$ git checkout feature-x
$ echo "v3-on-feature" >> file.txt
$ git add -A && git commit -m "commit 3 on feature-x"

$ cat .git/refs/heads/feature-x
107222f948aafe595e3a18b4497bf519d23b9616    # 变了,指向新提交

$ cat .git/refs/heads/main
f70352e6a0ab6d433b5f4595f2ec98c36d08f282    # 和提交前一模一样,main 完全没动

$ find .git/objects -type f | wc -l
9    # 6 -> 9,只多了 commit3 需要的 3 个新对象(新内容的 blob/tree/commit)
main 分支的 ref 文件内容一个字节都没变,新提交只改了 feature-x 自己那个 41 字节的文件

这也是为什么在 git 里"切分支"是一个几乎瞬间完成的操作:不涉及任何对象的复制或移动,git 要做的只是把 .git/HEAD 指向另一个 ref 文件,再把工作目录的文件内容换成那个 ref 指向的 commit 对应的快照——磁盘上没有任何东西被"搬"过,搬的只是指针。

真实验证:detached HEAD 状态下,.git/HEAD 的内容不一样

正常在分支上,HEAD 是"指针的指针";直接 checkout 一个哈希,HEAD 就直接变成那个哈希本身。

真实实测
正常在 main 分支上:
$ cat .git/HEAD
ref: refs/heads/main              # 间接引用:HEAD 指向一个分支,分支再指向 commit

直接用哈希 checkout(不接分支名):
$ git checkout d9f2b09ee3b6fdb52dd581850a9d60027196ca6f
$ cat .git/HEAD
d9f2b09ee3b6fdb52dd581850a9d60027196ca6f   # 直接引用:HEAD 直接就是 commit 哈希本身,没有 "ref:" 前缀

平时在分支上,.git/HEAD 存的是一个符号引用(symbolic ref)——"当前指向哪个分支",分支再指向具体的 commit,隔了一层。git checkout <哈希> 跳过了这一层,HEAD 直接变成了一个具体的 commit 哈希——这就是 detached HEAD 这个名字的来源:HEAD 跟所有分支"断开"了,不再挂在任何一个分支指针上。这个状态下如果继续提交,新提交同样会生成真实的 commit 对象,但没有任何分支 ref 会跟着移动指向它——一旦切回某个分支,这些提交就不再挂在任何引用链上了(不是被删除,后面这节会验证)。

真实验证:删掉分支,底层 commit 对象根本没消失

git branch -D 删的只是那个 41 字节的指针文件,不是它指向的内容。

真实实测
$ git branch -D feature-x
Deleted branch feature-x (was 107222f).

$ git cat-file -p 107222f948aafe595e3a18b4497bf519d23b9616
tree 25e3b26e8ad7ed0625e74a443e98be85ba925fdf
parent f70352e6a0ab6d433b5f4595f2ec98c36d08f282
author demo <demo@example.com> 1790057452 +0800
committer demo <demo@example.com> 1790057452 +0800

commit 3 on feature-x
分支已经被删了,但这个 commit 对象用它原来的哈希直接查,内容完完整整还在——tree/parent/author 一个字段都没少

git branch -D 只是删掉了 .git/refs/heads/feature-x 这一个文件,并没有连带把它指向的 commit 对象从 .git/objects 里清掉。这个 commit(以及它引用的 tree、blob)现在成了"悬挂对象"(dangling object)——没有任何分支、tag 或其他引用指着它,git log/git branch 这类日常命令也看不到它,但只要知道那个哈希,git cat-file 依然能把内容完整读出来,这也是误删分支之后经常能救回来的原因。真正把这些没人指的对象从磁盘上清掉,是 git gc/git prune 的工作,不是删分支这一步做的。

那 git gc 到底会不会立刻删掉悬挂对象?不一定——默认有个宽限期(悬挂对象的最后修改时间在最近 2 周内不会被清理),就是为了给"删错分支了"这种情况留出反悔和找回的时间。这部分涉及 .git/objects 从松散文件被打包压缩成 pack 文件的完整流程,留给系列后面专门写一篇。

参考与说明

  • 本文所有 ref 文件内容、对象数量均在本机真实 git init 后用真实命令(git branch/git checkout/git cat-file)现场跑出,没有编造任何一个哈希值。
  • 对象内容寻址存储(blob/tree/commit 怎么按内容算哈希、怎么去重)参见上一篇Git 是怎么给内容去重的。
  • 没有涉及:packed-refs(分支多了之后 git 会把松散的 ref 文件打包成一个文件)、reflog 的具体存储格式——这些留给系列后面的文章。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号