Git 分支为一条历史线的末端提供名字。新建分支通常只是增加一个指向既有提交的引用,不会再复制一份全部受跟踪文件。两个名字可以先指向同一提交,再分别前进。这让分支创建成本很低,但不意味着名字内部装着历史,更不意味着删除指向某个提交的全部引用后,内容仍会永远保留。理解分支时,需要把名称、对象和保留条件分开。
它是引用,不是固定的存储文件
本地分支属于 refs/heads/ 引用命名空间,具体表示方式是实现细节:引用可能独立存储、被打包,或使用其他受支持的后端。不要假定一定存在某个松散文件,就直接编辑 Git 内部目录来移动分支。应使用 Git 的引用命令,让它遵守仓库实际的存储结构和更新规则,而不是把一种磁盘布局当作永久接口。
提交记录目录树和父提交,沿着父级关系向前追溯,就得到分支末端可达的历史。因此,两条分支可以共享大部分祖先,却拥有不同的末端。分支名称不是提交身份的一部分,新增或重命名分支也不会改写它指向的提交。图中的对象关系解释了共享历史,名称只是人们访问这些对象的入口。
创建分支与切换分支也不同。git branch topic 在当前提交创建名称,但当前分支不会改变;git switch topic 才选中它,并按需要更新工作树。git switch -c topic 把创建与切换合在一起。记录新工作之前,应检查真正活动的分支,不能因为名称已经存在,就误以为接下来的提交一定会记在那条线上。
HEAD 决定哪个名字前进
第一次提交之前,HEAD 可以指向尚未诞生的分支名,此时分支引用还无法解析为提交。这与分离状态不同:名字已被选中,只是没有首个提交对象。仓库已有提交之后,通常的附着状态下,HEAD 通过符号引用指向当前分支。创建提交时,该分支前进到新对象,其他原本共享旧末端的分支保持不动;新提交的父级关系保留祖先。分支也可能因合并或其他明确更新引用的操作而移动,并不是只有普通提交才能改变它。因此,判断某个名字为什么变化,需要检查实际发生的操作,而不只看提交按钮是否被点击。
直接选中一个提交,可以进入分离状态。之后的新提交会推动 HEAD,但不会推动某条本地分支。提交本身是真实对象,只是切换离开后可能没有持久的分支名称指向它。离开之前可以在目标提交创建分支,或者之后凭已知对象标识重新命名。引用日志有助于恢复,却受到保留时间和垃圾回收影响,不能被当作永久保存承诺。
origin/main 这类远端跟踪引用,是本地对远端状态的观察,通常随获取操作更新;它不是实时连接,也不等同于本地 main。上游配置描述供相关命令使用的分支关系,不会在任意一方提交时自动同步两个末端。排查本地与远端差异时,应明确比较的是哪两个引用,以及远端状态上次何时被获取。
坏掉的样子
未提交改动属于工作树和索引,不是一份已经保存在分支里的快照。切换时,兼容的改动可能被一起带走;如果会覆盖当前修改,Git 也可能拒绝切换。应先检查工作区状态,再有意识地保存未完成工作。仅仅创建一个分支名字,并没有保存那些从未提交的编辑,不能据此清理或丢弃文件。
删除分支是删除引用,不是要求立即擦除它曾经到达的全部对象;其他引用可能继续保留那些提交。但如果最终没有任何保留路径,对象以后仍可能被清理。普通删除命令检查目标是否已经合入配置的上游;没有上游时,则检查是否合入当前 HEAD。这种保护检查的是图上关系,不是合并后程序是否正确,更不是对所有业务改动都已经保留的验收。
合并也不只是多加一个名字。快进会移动已有引用,而分叉历史的普通合并通常创建带结果目录树和多个父级的新提交。父级可达关系保留历史对象,却不保证每项贡献都正确地出现在合并后的文件中。审阅时既要看图,也要看内容,不能把“历史还能找到”解释成“最终行为没有丢失”。
快速验证
在 POSIX 系统上使用 Python 3 和 Git 2.28 或更新版本运行下面的实验。它只在私有临时仓库创建空提交,排除个人 Git 配置和继承的 Git 路由变量,关闭钩子及签名,不配置任何远端。删除尝试也只针对这个可丢弃仓库里的演示分支,不会影响当前项目的引用。实验结束后,临时目录管理器会清理整套演示数据。
import os
import subprocess
import tempfile
env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
with tempfile.TemporaryDirectory(prefix="git-branch-demo-") as directory:
def git(*args, check=True):
return subprocess.run(
["git", "-c", "user.name=Branch Example",
"-c", "user.email=branch@example.invalid",
"-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args],
cwd=directory, env=env, text=True, capture_output=True, check=check,
)
git("init", "-q", "-b", "main")
print("unborn-is-not-detached:",
git("symbolic-ref", "--short", "HEAD").stdout.strip() == "main"
and git("rev-parse", "--verify", "HEAD", check=False).returncode != 0)
git("commit", "--allow-empty", "-qm", "base")
base = git("rev-parse", "HEAD").stdout.strip()
git("branch", "topic")
print("created-without-switch:",
git("symbolic-ref", "--short", "HEAD").stdout.strip() == "main")
print("same-tip:", git("rev-parse", "topic").stdout.strip() == base)
git("switch", "-q", "topic")
git("commit", "--allow-empty", "-qm", "topic work")
topic = git("rev-parse", "HEAD").stdout.strip()
print("only-topic-moved:", topic != base
and git("rev-parse", "main").stdout.strip() == base)
git("switch", "-q", "--detach", "HEAD")
git("commit", "--allow-empty", "-qm", "detached work")
detached = git("rev-parse", "HEAD").stdout.strip()
print("detached-without-moving-topic:",
git("symbolic-ref", "-q", "HEAD", check=False).returncode == 1
and git("rev-parse", "topic").stdout.strip() == topic)
git("branch", "rescue", detached)
git("switch", "-q", "main")
print("unmerged-delete-refused:",
git("branch", "-d", "topic", check=False).returncode != 0)
print("rescued-commit:", git("rev-parse", "rescue").stdout.strip() == detached)
七个判断都应为真。未诞生分支有名字,但还没有提交。创建 topic 后当前仍选中 main;在 topic 提交只推动那一个名字。分离状态的新提交不会推动 topic,而 rescue 随后为新结果提供名称。从尚未合入该工作的 main 删除 topic 会被拒绝,即便另一个引用也保留着这些历史。这说明当前分支选择、提交可达性和删除策略是不同问题,不能只看到某个对象还在,就推断任意分支操作都会被允许。
来源
Pro Git:分支简介,分支末端、HEAD 与共享祖先,已核验 2026-10-15。
Git branch 参考,创建分支、上游关系和删除检查,已核验 2026-10-15。
Git 仓库布局,HEAD 可以指向未诞生分支,已核验 2026-10-15。