如果你一直只用 addcommitpush,第一次敲 git branch feature-x 大概会怀疑它没生效:没有等待,没有复制目录,git status 里只多了一行。这个“太快了”不是错觉,也不是 Git 偷偷优化过,而是因为分支在 Git 里本来就不是一份代码副本。

提交之间是用 parent 串起来的

要看懂分支,得先看提交本身是怎么放的。每次 git commit,Git 会生成一个 commit 对象,里面装着三样东西:这次提交时整个项目的快照(指向一棵记录所有文件和内容的 tree 对象)、作者与提交说明、以及一个或几个 parent——也就是“上一个提交是谁”。

提交对象各自记录快照,并通过 parent 指针指向上一个提交

第一个提交没有 parent,普通提交有一个,合并提交有两个或更多(merge 留到下一篇讲)。正是这条 parent 链让“历史”成为可能:历史不是另外存着的一张提交列表,而是从某个提交出发、顺着 parent 往回走能到达的全部提交。

这也解释了 commit 一旦生成就不能改:内容哪怕改一个字符,算出的哈希就变了,那实际上是另一个新提交。

分支只是一个指向提交的引用

Git 用 引用(reference,缩写成 ref)给提交起名字,分支是引用的一种,放在 .git/refs/heads/ 目录下。一条分支就是一个同名文件:

1$ cat .git/refs/heads/main
2c5d9b1e...(40 个十六进制字符,一行,末尾一个换行)

41 个字节,没有别的了。没有文件清单,没有提交列表,也没有元数据。所以官方文档的说法是:新建一个分支,就是往文件里写 41 个字节 1

这里有个容易搞反的地方:文件里那个哈希指的是这条分支 最新 的那个提交(分支的尖端),不是“这条分支上的所有提交”。分支并不“装”提交。

所以新建分支为什么是瞬间的

因为除了写这 41 个字节,它什么都不做。git branch testing 干的事是:找到你当前所在的位置(也就是当前分支指向的那个提交),新建 .git/refs/heads/testing,写入同一个哈希。结果是两条分支指向同一个提交。

对比一下别的版本控制工具:它们建分支常常要真的复制一份源码目录,或者在服务器上做一次拷贝,项目大的时候要等几秒甚至几分钟 1。Git 不复制任何东西——所有版本的文件内容都在同一份对象库里共享,分支只是多了一个“名字 → 提交”的映射。所以建分支的代价和你仓库里已经有了多少提交基本无关。

两条分支指向同一串提交,其中一条往前走之后历史发生分叉

代价低到这个程度,才会出现后面要讲的工作方式:每做一个功能就开一条分支,做完再合回去,一天开好几条也不心疼。

HEAD:Git 怎么知道你当前在哪条分支

建完分支你并没有被切过去。git branch testing 只建指针,人还留在原来的分支上。那 Git 凭什么知道你在哪条分支上?答案在 .git/HEAD

1$ cat .git/HEAD
2ref: refs/heads/main

注意它里面装的不是哈希,而是一行“ref: 某个分支名”。这种不直接指向对象、而是指向另一个引用的引用,叫 符号引用(symbolic reference)2。所以从 HEAD 到提交其实跳了两次:HEAD → 分支 → 提交。

HEAD 为什么非要绕这一下?因为提交时需要知道两件事:新提交的 parent 是谁,以及提交完成后该移动哪个指针。有了 HEAD 里这行记录,两件事都能推出来。一次 git commit 在底层是:

  1. 解析 HEAD,得到当前分支;再解析当前分支,得到它指向的提交哈希;
  2. 把这个哈希当作 parent,生成新的 commit 对象;
  3. 把新哈希写回 .git/refs/heads/main

关键在第 3 步:被改写的是分支文件,HEAD 文件一个字都没变,依然写着 ref: refs/heads/main。所以准确的说法是“提交移动的是分支,不是 HEAD”——这正是 HEAD 要存名字而不是存哈希的原因 12

顺着这一点也能理解另一个现象:只有当 HEAD 确实指向某条分支时,提交才是在推进那条分支。如果 HEAD 里直接放着提交哈希(比如你切到某个具体提交或 tag 上),就没有分支可推进,这就是 detached HEAD。它为什么会出现、遇到时怎么处理是另一件事,这里只需要知道:它是 HEAD 这个设计的直接后果,不是异常。

分支不装历史,这个结论比听起来有用

既然分支只指向一个提交,其余历史全靠 parent 链回推,那么几件事就顺理成章:

  • 同一个提交可以同时被好几条分支指向,仓库里不会因此多出任何副本;
  • 删掉一条分支只是删掉一个名字,提交本身还在(只要还有别的名字或 reflog 能到达它);
  • git log 默认只显示从 HEAD 出发往回走能到达的提交,别的分支上的提交默认看不到,要看全得加 --all

第三条是很多人第一次协作时踩的坑:明明昨天提交过,今天 git log 里却找不到。多半不是提交丢了,而是那些提交在另一条分支上,不在你现在这个位置能到达的范围内。

自己验证一遍

1$ git branch -v          # 每条分支现在指向哪个提交
2$ git rev-parse main     # 直接打印 main 指向的哈希
3$ cat .git/HEAD          # 当前在哪条分支,就是这个文件说的
4$ git switch -c feature  # 建一条新分支并切过去(Git 2.23 起可用;旧写法是 git checkout -b)
5$ git log --oneline --decorate --graph --all

最后一条值得记住。--all 让它从所有引用出发显示历史,于是你既能看到每条分支名画在哪个提交上,也能一眼看出两条分支从哪里分开、各自往前走了多少。这正好是下一篇的问题:分开的两条分支怎么重新合到一起,合不到一块儿(冲突)时又该怎么办。

两个边界,避免把话说满

  • mainmaster 不特殊。 它只是名字,是 git init 时默认建的那条。现在很多托管平台新建仓库默认叫 main,你也可以用 init.defaultBranch 改自己机器上的默认值。分支叫什么都不影响 Git 的行为,影响的是团队约定。
  • 第一次提交之前,这条分支文件还不存在。 那会儿 .git/HEAD 里已经写着 ref: refs/heads/main,但 refs/heads/main 还没有。Git 管这叫 unborn branch(未出生的分支)——HEAD 之所以能“指向一条还不存在的分支”,正是因为它存的是名字而不是哈希。