本页内容

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 branch -d feature-login
    如果某个分支包含当前分支或上游分支无法到达的提交,Git 会阻止删除它。这可以防止工作被意外丢失。
  • 强制删除(-D 或 -d --force):
    $ git branch -D feature-login
    这相当于告诉 Git:“无论如何都删除这个引用。”

提交会发生什么?

删除分支只会移除它的引用,并不会立刻删除提交对象。如果没有其他引用能到达这些提交,它们会变成不可达对象,在一段时间内仍可通过 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 分支只是引用,提交本身仍在数据库中。你只需要找到提交哈希,再在它上面挂接一个新的分支引用。

  1. 搜索 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-payment
    仔细看可以发现,c3b91a2 就是我们在 feature-payment 分支上完成提交、随后切回 main 之前的那个提交。
  2. 重新创建分支指针: 现在运行 git branch,后面接新分支名称和找回的哈希:
    $ git branch feature-payment c3b91a2
    就这样!Git 会在 .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 即可完成推送或拉取。


检查你的理解

正在加载测验…