Git Branch:深入了解 Git 的轻量级指针系统
简要总结: 在 Git 中,分支不是源文件的目录副本,也不是厚重的快照。在 Git 默认的 files 引用格式下,它只是一个轻量、可变的引用——一个包含 40 个字符的 commit SHA-1 哈希和一个换行符、共 41 字节的文本文件——存放在 .git/refs/heads/ 目录中。创建、重命名或删除 Git 分支几乎是瞬时完成的,因为这些操作只会改动引用文件,而不会修改或复制工作树中的文件。
范围说明: 本页所说的“41 字节文件”,精确适用于 Git 默认的
files引用后端且分支处于未打包状态的情况。Git 也可以通过git gc将引用打包到.git/packed-refs中;此外,Git 还支持 reftable 后端,此时分支根本不会分别存成文件。无论哪种情况,命令和行为都相同,变化的只是磁盘上的表示方式。详情请参阅 Git Refs。
分支是 ref,而不是文件副本
要理解 Git 的分支模型为何如此重要,不妨先与 Subversion(SVN)或 Perforce 等较早的版本控制系统(VCS)对比。在那些传统系统中,创建分支意味着物理复制整个项目目录树。如果代码库有 5 GB,那么创建分支就意味着在服务器上复制 5 GB 的文件。这不仅耗时、浪费磁盘空间,也会让开发者不愿为小任务创建分支。
Git 不会复制文件。相反,Git 将项目历史组织成一个由提交对象组成的有向无环图(DAG)。每个提交都指向它的父提交,从而形成树状的历史结构。在这个图中:
- 提交(Commit) 表示项目在某个时间点的已跟踪文件快照,同时包含作者元数据和父提交链接。有关提交对象结构的详情,请参阅 /git-commit。
- 分支(Branch) 只是指向图中某个具体提交的命名引用。Git 可以从这个起点沿父提交指针向后遍历,构建该分支的历史。
可以把分支想象成贴在提交链某个节点上的便利贴。当你在当前分支上工作并创建新提交时,Git 会自动把便利贴向前移动,指向最新提交。因为分支只是一个引用(或称“ref”),Git 的分支操作只需写入或修改一个很短的 40 字符字符串。
想深入了解 Git 的通用引用系统,请阅读我们的 /git-refs 指南。
有三个引用容易混淆:refs/heads/main 是本地分支;refs/remotes/origin/main 是远程分支在本地的记录,由 git fetch 等命令更新;HEAD 指向当前检出的分支,或在 detached HEAD 状态下直接指向某个提交。成功执行 git push 也可能更新对应的远程跟踪引用。
观察创建分支时 refs/heads 的变化
要真正理解 Git 分支的工作机制,让我们按步骤进行终端演示,观察分支操作期间 .git/ 目录发生了什么变化。
第 1 步:初始化一个干净的仓库
我们创建一个新仓库,添加一次提交,再查看 .git/ 目录的结构。
$ mkdir git-branch-demo
$ cd git-branch-demo
$ git init
Initialized empty Git repository in /users/admin/git-branch-demo/.git/
此时,如果列出 .git/refs/heads/ 的内容,会发现它完全为空:
$ ls -la .git/refs/heads/
total 0
drwxr-xr-x 2 admin staff 64 Jul 7 10:25 .
drwxr-xr-x 4 admin staff 128 Jul 7 10:25 ..
还没有创建默认分支文件,因为目前不存在提交。让我们看看 .git/HEAD 指向哪里:
$ cat .git/HEAD
ref: refs/heads/main
虽然 refs/heads/main 此时还不存在于磁盘上,但 HEAD 已预先配置为指向它。这种状态称为“未出生分支(unborn branch)”。现在创建第一次提交,让这个分支正式出现。
$ echo "Hello Git Branching!" > README.md
$ git add README.md
$ git commit -m "Initial commit"
[main (root-commit) f24e93e] Initial commit
1 file changed, 1 insertion(+)
create mode 100644 README.md
现在已经有了提交,再次检查 .git/refs/heads/:
$ ls -la .git/refs/heads/
total 8
drwxr-xr-x 3 admin staff 96 Jul 7 10:27 .
drwxr-xr-x 4 admin staff 128 Jul 7 10:25 ..
-rw-r--r-- 1 admin staff 41 Jul 7 10:27 main
一个名为 main 的文件出现了!让我们打印这个文件的内容:
$ cat .git/refs/heads/main
f24e93e25b90f46c6a7e0c4a45adbb474f8e6c7c
这个文件恰好包含一个 40 字符的 SHA-1 提交哈希,后面跟着一个换行符。让我们用底层命令 git cat-file 验证这个哈希指向的对象:
$ git cat-file -t f24e93e25b90f46c6a7e0c4a45adbb474f8e6c7c
commit
接下来打印提交对象的内容,看看它如何表示我们的快照:
$ git cat-file -p f24e93e25b90f46c6a7e0c4a45adbb474f8e6c7c
tree 5b38a7c29375ab2b4a53239a584a22c5432a138b
author User <user@example.com> 1783420000 +0000
committer User <user@example.com> 1783420000 +0000
Initial commit
第 2 步:创建新分支
现在使用 git branch 命令创建一个名为 feature-login 的新分支:
$ git branch feature-login
查看 .git/refs/heads/ 的内容,看看 Git 在底层做了什么:
$ ls -la .git/refs/heads/
total 16
drwxr-xr-x 4 admin staff 128 Jul 7 10:30 .
drwxr-xr-x 4 admin staff 128 Jul 7 10:25 ..
-rw-r--r-- 1 admin staff 41 Jul 7 10:30 feature-login
-rw-r--r-- 1 admin staff 41 Jul 7 10:27 main
一个名为 feature-login 的新文件已经创建。读取它的内容:
$ cat .git/refs/heads/feature-login
f24e93e25b90f46c6a7e0c4a45adbb474f8e6c7c
它包含的提交哈希与 main 完全相同!Git 没有复制任何源文件,只是创建了一个指向已有提交的 41 字节文本文件(假设使用默认的未打包 files 引用格式)。这就解释了为什么创建分支可以瞬间完成。
读取 .git/HEAD,检查当前所在的分支:
$ cat .git/HEAD
ref: refs/heads/main
HEAD 仍指向 main,这意味着我们仍在 main 分支上。要切换分支,必须更新 HEAD 引用;通常由 git checkout 或 git switch 等命令完成。关于这一机制的更多信息,请参阅 /git-checkout。
第 3 步:在分支上提交
切换到新分支并做一次修改。
$ git checkout feature-login
Switched to branch 'feature-login'
现在验证 .git/HEAD:
$ cat .git/HEAD
ref: refs/heads/feature-login
HEAD 已更新为指向 refs/heads/feature-login。现在创建一次新提交:
$ echo "Login Form UI" > login.html
$ git add login.html
$ git commit -m "Add login page"
[feature-login a7c8e9d] Add login page
1 file changed, 1 insertion(+)
create mode 100644 login.html
现在查看分支引用的内容:
$ cat .git/refs/heads/feature-login
a7c8e9d8928424ef245eefb925b42d3851b2c129
$ cat .git/refs/heads/main
f24e93e25b90f46c6a7e0c4a45adbb474f8e6c7c
feature-login 已向前移动,指向新的提交 a7c8e9d...。但 main 仍指向 f24e93e...。使用 /git-log 查看历史:
$ git log --oneline --graph --all
* a7c8e9d (HEAD -> feature-login) Add login page
* f24e93e (main) Initial commit
图中显示 feature-login 已经分叉。main 分支指针保持不动,而 feature-login 随着 HEAD 一起移动。如果稍后决定合并这两个分支,Git 会读取这些指针来计算它们的历史。相关内容请参阅 /git-merge。
创建、重命名和删除分支
git branch 命令不仅能创建引用,还是管理、查询和审查分支图的多用途工具。
1. 列出和筛选分支
在大型项目中,你经常会面对几十个本地分支和远程分支。Git 提供了筛选功能,帮助你保持条理。
- 列出所有分支(本地和远程):
这会用一种颜色显示本地分支(例如绿色),用另一种颜色显示远程跟踪分支(存储在$ git branch -a.git/refs/remotes/中;例如红色)。 - 按合并状态筛选:
清理旧分支前,你需要知道哪些分支已经安全地合并到当前分支:
要查看包含尚未合并到当前分支的工作的分支:$ git branch --merged
对$ git branch --no-merged--no-merged列表中的分支运行git branch -d会失败,除非强制执行。 - 显示上游跟踪信息的详细列表:
这会显示最新提交哈希、提交主题行和上游跟踪状态(例如本地分支相对于远程对应分支是领先还是落后)。$ git branch -vv
2. 删除的内部机制与垃圾回收
删除分支是常见操作,但使用不同选项时,工作方式也不同。
- 安全删除(
-d或--delete):
如果某个分支包含当前分支或上游分支无法到达的提交,Git 会阻止删除它。这可以防止工作被意外丢失。$ git branch -d feature-login - 强制删除(
-D或-d --force):
这相当于告诉 Git:“无论如何都删除这个引用。”$ git branch -D feature-login
提交会发生什么?
删除分支只会移除它的引用,并不会立刻删除提交对象。如果没有其他引用能到达这些提交,它们会变成不可达对象,在一段时间内仍可通过 reflog 恢复。Git 最终可能清理不可达对象,具体时机取决于配置和仓库状态。如果这些工作很重要,请在清理前另建一个分支;对象生命周期见 Git objects。
3. 跟踪上游分支
协作时,本地分支会跟踪远程服务器(例如 GitHub)上的分支。这种跟踪配置存储在 .git/config 中。
为当前活动分支设置上游分支:
$ git branch -u origin/feature-login
# Or:
$ git branch --set-upstream-to=origin/feature-login
这会修改 .git/config 文件,加入跟踪元数据:
[branch "feature-login"]
remote = origin
merge = refs/heads/feature-login
有了这项配置,你只需输入 git pull 或 git push,无需指定远程仓库和分支名称。
恢复误删的分支
想象一下,你正在清理工作区。你以为已经把 feature-payment 合并到 main,于是运行:
$ git branch -D feature-payment
Deleted branch feature-payment (was c3b91a2).
突然,你意识到 feature-payment 并没有合并,而你刚刚删除了几天的工作!由于编辑器中已经看不到这些文件,这看起来像是一场灾难。
如何恢复:
由于 Git 分支只是引用,提交本身仍在数据库中。你只需要找到提交哈希,再在它上面挂接一个新的分支引用。
- 搜索 Reflog:
reflog 会记录
HEAD每次改变位置的情况。运行:
仔细看可以发现,$ git reflog f24e93e HEAD@{0}: checkout: moving from feature-payment to main c3b91a2 HEAD@{1}: commit: Add Stripe Integration and webhooks f24e93e HEAD@{2}: checkout: moving from main to feature-paymentc3b91a2就是我们在feature-payment分支上完成提交、随后切回main之前的那个提交。 - 重新创建分支指针:
现在运行
git branch,后面接新分支名称和找回的哈希:
就这样!Git 会在$ git branch feature-payment c3b91a2.git/refs/heads/feature-payment创建一个包含c3b91a2的新引用文件,立即恢复你的工作。
分支命令容易出错的地方
1. 误以为 git branch 会切换到新分支
运行 git branch new-feature 只会创建指针,不会更新 HEAD。如果你马上开始编辑文件,提交就会写入旧分支(例如 main)。请务必记得运行 git checkout new-feature(或者使用 git checkout -b new-feature 一步完成创建和切换)。
2. 命名空间冲突(文件与目录)
由于 Git 将分支引用存为 .git/refs/heads/ 中的文件,它们必须遵守操作系统的文件路径规则。
- 如果有一个名为
feature的分支,Git 会创建文件.git/refs/heads/feature。 - 如果随后尝试创建名为
feature/login的分支,Git 必须创建目录feature,再把文件login放进去。 - 在大多数系统中,同一个父目录下不能同时存在同名文件和目录,因此会出现以下错误:
error: cannot lock ref 'refs/heads/feature/login': 'refs/heads/feature' exists; cannot create 'refs/heads/feature/login' - 修复方法: 如果计划使用
feature/login和feature/payment这样的斜杠命名空间,请避免创建名为feature的通用分支。
3. 指向已删除远程分支的本地引用过期
当队友删除远程服务器上的分支时,本地 Git 仍会在 .git/refs/remotes/origin/ 中保留对它的跟踪。这可能造成混淆。
- 修复方法: 运行以下命令,清理过期的远程跟踪分支:
$ git fetch --prune
分支命令速查
| 命令 | 说明 | Git 内部操作 |
|---|---|---|
git branch | 列出所有本地分支 | 读取 .git/refs/heads/ 中的文件 |
git branch <name> | 创建新的分支引用 | 在 .git/refs/heads/<name> 创建 41 字节文件(默认未打包 files 格式) |
git branch -d <name> | 分支已合并时安全删除 | 先检查可达性,再删除引用文件 |
git branch -D <name> | 强制删除分支 | 无论可达性如何都删除引用文件 |
git branch -m <new-name> | 重命名当前分支 | 重命名 .git/refs/heads/ 中的引用文件 |
git branch -a | 列出本地和远程分支 | 读取 .git/refs/heads/ 和 .git/refs/remotes/ |
git branch -vv | 显示跟踪状态和提交 | 将本地引用文件与上游设置关联起来 |
分支问题,一次回答
“分离的 HEAD”是什么意思?
当 HEAD 直接指向某个具体提交哈希,而不是命名的分支引用时,就处于“分离的 HEAD”状态。
例如,.git/HEAD 中可能不是 ref: refs/heads/main,而是一个提交 ID。在这种状态下创建的提交不属于任何分支;切换走后,它可能无法从具名引用到达,但仍可通过 reflog 恢复。要保留这些工作,请在仍处于该提交时运行 git switch -c rescue-work 创建分支。HEAD 与分支引用的关系见 Git refs。
两个分支可以指向同一个提交吗?
可以。多个分支可以指向完全相同的提交哈希。事实上,从 main 创建新分支时,两个分支会一直指向同一提交,直到其中一个分支上创建新提交。Git 不介意多个引用包含同一个 40 字符的 SHA-1 字符串。
如何把本地分支推送到远程服务器?
要把分支推送到远程仓库,并设置它跟踪该远程分支,请使用:
$ git push -u origin <branch-name>
-u(或 --set-upstream)标志会告诉 Git 创建跟踪关联,并更新 .git/config 文件;今后在这个分支上只需使用 git push 或 git pull 即可完成推送或拉取。
检查你的理解
正在加载测验…