"git 分支很轻量"这句话到处都能看到,但很少有人真的数过"轻量"具体是多轻。上一篇验证了 blob/tree/commit 都是按内容哈希存的对象,这一篇接着问:分支本身算不算一种对象?创建一个分支,磁盘上到底多写了什么?真实建了两次提交、真实创建/删除分支,边跑边数。
上一篇:Git 是怎么给内容去重的
前三种是内容寻址的对象,分支是纯粹的指针,连哈希都不用算。
上一篇验证过,.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 里的内容不会有任何差别。
切到 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 对应的快照——磁盘上没有任何东西被"搬"过,搬的只是指针。
正常在分支上,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 会跟着移动指向它——一旦切回某个分支,这些提交就不再挂在任何引用链上了(不是被删除,后面这节会验证)。
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 的工作,不是删分支这一步做的。