Day 29,我还在啃 Git。今天不聊命令速查,也不列操作清单,专门把分支和指针这两个概念彻底掰开揉碎。很多同学卡在 Git 分支这块,不是因为命令记不住,而是脑子里对“分支到底是什么”没有建立正确的模型。我自己也是踩了不少坑才真正想通:分支本质上就是指针,一个指向提交对象的指针。把这个想明白,后面什么合并、变基、分离头指针、误删分支恢复,全都顺了。
这篇文章不假设你已经懂 Git 底层,但也不会只停留在 git branch、git checkout 这种表面操作。我会从 Git 存储对象的角度,把分支、HEAD、提交之间的关系一层层拆开,再配合实际命令和实验验证,帮你建立一套真正能迁移到任何 Git 场景的底层思维。无论你是刚入行的前端、后端,还是被 Git 折磨过的老实习生,这套理解方式都能让你少走很多弯路。
1. 先搞清楚 Git 到底存了什么东西,分支才有讨论的基础
1.1 提交对象不是“差异”,而是一张完整快照
我刚开始用 Git 的时候,脑子里默认 Git 存的是代码差异(diff)。后来才意识到,这个理解害了我很久。Git 的核心存储单位是“提交对象”(commit object),每次 git commit 都会生成一个提交对象,而提交对象内部是一个完整的项目快照指针,不是某几个文件的改动记录。
更准确地说,一个提交对象里面保存了三类东西:
- 提交信息:作者、提交者、时间、message;
- 指向一个树对象(tree)的指针:树对象记录目录结构和每个文件对应的 blob 对象;
- 父提交指针:指向上一个提交(或者多个提交,合并提交会有多个父提交)。
这里要特别强调:Git 的快照是“按内容寻址”的。文件内容一旦相同,blob 对象就可以复用,不会每提交一次就物理复制一遍整个仓库。所以“分支多了仓库体积会爆炸”这种担心,在绝大多数场景下是多余的。真正让仓库变大的是那些无意中提交进去的大文件(视频、压缩包、二进制文件),而不是分支数量。
为了验证这个模型,我建议你亲手跑一下这几个命令:
bash复制# 查看当前分支最新的提交对象
git cat-file -p HEAD
# 查看这个提交对象指向的树对象
git cat-file -p HEAD^{tree}
输出大概是这样的感觉:
code复制tree 7f5a3f5a3f5a3f5a3f5a3f5a3f5a3f5a3f5a3f
parent 9d2e4c7a9d2e4c7a9d2e4c7a9d2e4c7a9d2e4c7
author 你的名字 <you@example.com> 1712213123 +0800
committer 你的名字 <you@example.com> 1712213123 +0800
提交说明
看到那个 parent 字段了吗?这就是指针链的起点。每个提交都通过 parent 指向上一个提交,串成一条完整的“提交历史链”。分支、指针、HEAD,全部围绕这条链做文章。
1.2 分支的本质:就是一个 40 位的提交 ID
现在到了最关键的点。你可以做一个实验:
bash复制# 在项目任意目录下执行
cat .git/refs/heads/master
只要这个分支没有使用 packed-refs 被压缩,你就能看到一个 40 位的十六进制字符串(如果你用的是 SHA-256 仓库,则是 64 位)。这个字符串,就是一个具体的提交 ID。
分支就是这么朴素的东西:一个文件,文件名就是分支名,内容就是一个提交 ID。
所以当你执行 git branch dev 的时候,Git 做的事情特别简单——在 .git/refs/heads/ 下新建一个名为 dev 的文件,把当前提交的 ID 写进去。不复制代码,不新建目录,不改变任何工作区文件。整个过程快得几乎没有感知。
为了验证,你可以这么做:
bash复制# 在 master 上创建一个新分支,指向当前提交
git branch dev
# 看看 dev 这个文件的内容
cat .git/refs/heads/dev
# 对比当前 HEAD 指向的提交
git rev-parse HEAD
你会发现,两个内容一模一样。因为新建分支的时候,它就是复制了当前位置的指针值。
1.3 HEAD:一个“指向指针的指针”
很多教程把 HEAD 比作“当前所在位置”,这没错,但更准确的类比是:HEAD 是一个指针,它指向的不是提交,而是分支。也就是说,HEAD 是“指向分支指针的指针”。
为了验证,你可以看这个文件:
bash复制cat .git/HEAD
正常情况下,输出是这样的:
code复制ref: refs/heads/master
它告诉你:HEAD 这个指针,指向的是 refs/heads/master 这个分支指针。当你在 master 上提交代码时,Git 的逻辑是这样的:
- 创建新的提交对象,父指针指向
master当前指向的提交; - 把新的提交对象 ID 写入
refs/heads/master; - 工作区文件更新到新提交的内容。
也就是说,每次提交,都是移动 HEAD 最终指向的那个分支指针,而不是移动 HEAD 本身。HEAD 始终指向分支,分支“前进”了,HEAD 也就跟着“前进”了。
这个模型能帮你解释一个非常经典的问题:为什么 git checkout dev 之后,HEAD 文件内容变
