# 分支到底是什么：为什么 git branch 快到像什么都没发生

分支只是 41 字节的指针，HEAD 指向分支而非提交——搞清这两点，新建分支为什么是瞬间的就有了解释

> Git 分支 · Git 基础操作 · HEAD 与引用 · 约 6 分钟 · 09 月 24 日

## 本篇要点

1. 一条分支在磁盘上就是 .git/refs/heads/ 下的一个文件，里面只有一个提交的哈希，40 个字符加一个换行，共 41 字节。
2. 新建分支之所以是瞬间完成，是因为它只写入这一个哈希，不复制任何文件内容：所有版本的文件都已在同一份对象库里，分支只是多加了一个“名字→提交”的映射。
3. 分支指向的是它最新的那个提交（尖端），历史则是从这个提交顺着每个提交里的 parent 指针往回走得到的，所以分支并不“包含”提交。
4. HEAD 通常是符号引用，内容是 ref: refs/heads/main 这样的一行分支名；它指向分支而不是提交，这样 Git 才知道提交时该把哪个指针往前移。
5. 一次提交的底层过程是：解析 HEAD 找到当前分支和它指向的提交，用该提交作 parent 生成新 commit，再把新哈希写回分支文件；HEAD 文件本身不改动。
6. 同一个提交可以被多条分支同时指向而不占额外空间；删除分支只删除名字，不删除提交；git log 默认只显示从 HEAD 能到达的提交，看全部分支要加 --all。
7. main 只是普通的分支名，没有特殊地位；第一次提交前 HEAD 指向的分支文件尚不存在，这叫 unborn branch；HEAD 里直接存哈希时就处于 detached HEAD 状态。

---

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

## 提交之间是用 parent 串起来的

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

![提交对象各自记录快照，并通过 parent 指针指向上一个提交](https://git-scm.com/book/en/v2/images/commits-and-parents.png)

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

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

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

Git 用**引用**（reference，缩写成 ref）给提交起名字，分支是引用的一种，放在 `.git/refs/heads/` 目录下。一条分支就是一个同名文件：

```console
$ cat .git/refs/heads/main
c5d9b1e...（40 个十六进制字符，一行，末尾一个换行）
```

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

这里有个容易搞反的地方：文件里那个哈希指的是这条分支**最新**的那个提交（分支的尖端），不是“这条分支上的所有提交”。分支并不“装”提交。

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

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

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

![两条分支指向同一串提交，其中一条往前走之后历史发生分叉](https://git-scm.com/book/en/v2/images/branch-and-history.png)

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

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

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

```console
$ cat .git/HEAD
ref: 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 要存名字而不是存哈希的原因 [1][2]。

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

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

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

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

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

## 自己验证一遍

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

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

## 两个边界，避免把话说满

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

## 术语表

- 引用（reference / ref）：给某个提交起的名字，本质上是一个存着提交哈希的文件；分支就是引用的一种。
- 符号引用（symbolic reference）：内容不是哈希而是“ref: 某个分支名”的引用，HEAD 通常就是这种，它指向分支而不是直接指向提交。
- HEAD：记录你当前在哪条分支的引用；提交时 Git 由它决定新提交的 parent，以及要移动哪一条分支。
- parent 指针：每个提交里记录的“上一个提交是谁”，历史上的先后关系完全由它串起来，分支指向哪里并不会改变它。
- detached HEAD：HEAD 里直接存着提交哈希、不指向任何分支的状态，此时提交没有分支可推进。
- unborn branch：首次提交之前，HEAD 指向的某个分支文件还不存在，这种状态叫未出生的分支。

## 来源

1. [Pro Git 第 3.1 节：Branches in a Nutshell — 分支是指向提交的轻量可移动指针，创建分支相当于写 41 字节](https://git-scm.com/book/en/v2/Git-Branching-Branches-in-a-Nutshell)
2. [Git 官方数据模型文档 — refs/heads 分支、parent 链回溯历史、HEAD 符号引用与 detached HEAD](https://git-scm.com/docs/gitdatamodel)
3. [Pro Git 第 10.3 节：Git References — .git/refs 中的引用文件与 HEAD 文件内容](https://git-scm.com/book/en/v2/Git-Internals-Git-References)

---

原文：https://pangzhengboyin.com/articles/what-a-git-branch-really-is-dd454aec

> **庞征博引** · 想学的，慢慢都会
>
> 庞征博引是把想学的东西写成连载的 AI 学习工具。说出想学什么，它会先了解你的基础，再把主题写成一篇篇 5–10 分钟能读完的文章；边读边问，接下来学什么跟着你走。这篇就是这样写出来的。
>
> 开始你自己的连载 → https://pangzhengboyin.com
