Git新建分支为什么不复制文件?用两次提交看懂commit、ref与HEAD 我最开始理解分支时会把它想成“从这里复制一份项目以后各改各的”。但这解释不了两个现象新建一个分支工作目录没有多一份文件在当前分支提交另一个分支也不会跟着更新。后来写 mini-git我才把这几个东西拆开commit 保存历史节点branch 是可以移动的引用HEAD 决定当前使用哪一个引用。新建分支增加的是入口不是复制一条历史。这篇先用标准 Git 做一个小实验再回到 C 源码。没有 mini-git 也能完整复现。我们只改一个文件刻意让“文件变化”和“指针变化”都看得见。1. 先造一个只有一次提交的仓库下面在 Git Bash、Linux Bash 或 WSL 的 Bash 中执行需要 Git 2.23 或更新版本用到了git switch。所有命令在同一个终端按顺序执行mktemp创建全新的实验目录不要在自己的项目里照着改。lab$(mktemp-d${TMPDIR:-/tmp}/git-head-lab.XXXXXX)cd$labgitinit-q-bmaingitconfig user.nameGit Labgitconfig user.emailgit-labexample.invalidgitconfig commit.gpgsignfalsegitconfig core.autocrlffalseprintfv1\nnote.txtgitaddnote.txtgitcommit-q-mC1: write v1C1$(gitrev-parse HEAD)gitsymbolic-ref HEADgitrev-parse maincatnote.txt最后三个命令分别得到refs/heads/main、C1 的实际对象 ID、v1。这里用 C1 表示第一次提交不要求你得到和我一样的 hash作者、时间等提交内容也参与计算。这时关系是HEAD - refs/heads/main - C1 - tree - note.txt 的 blobtree 描述这一版本的目录结构和对象引用blob 保存文件内容。commit 还会记录作者、提交说明和父提交等信息所以它不仅是一张目录快照。2. 创建 feature然后继续在 main 提交先预测一下执行git branch feature后HEAD 会改指向 feature 吗gitbranch featuregitsymbolic-ref HEADgitshow-ref--heads不会。git branch feature是创建分支不是切换分支。HEAD 仍指向 main两个分支暂时都指向 C1。接下来只修改 mainprintfv2\nnote.txtgitaddnote.txtgitcommit-q-mC2: write v2C2$(gitrev-parse HEAD)gitsymbolic-ref HEADgitshow-ref--headsgitlog--all--oneline--decorate--graph这一次main 指向 C2feature 仍指向 C1HEAD 的符号引用仍然是refs/heads/main。HEAD 文件里的分支名字没变但通过它解析出来的提交变了。图中的 parent 箭头是从新提交指向旧提交不是从旧提交向未来增长。历史连接存在 commit 对象里分支引用只记录入口。3. 切换分支时文件为什么会变回去gitswitch-qfeaturegitsymbolic-ref HEADcatnote.txtgitswitch-qmaincatnote.txt切到 feature 后得到refs/heads/feature和v1切回 main 后文件又是v2。这个实验的工作区是干净的所以切换能够顺利完成。不是 feature 目录里藏着另一份note.txt。Git 根据目标提交的快照更新 index 和工作区让它们对应目标版本。工作区有未提交改动时Git 会判断切换是否覆盖改动不能把“切换一定成功”当成规则。把刚才的结果压成一张表操作后HEAD 的符号引用mainfeature工作区内容提交 C1mainC1尚不存在v1创建 featuremainC1C1v1在 main 提交 C2mainC2C1v2切到 featurefeatureC2C1v1新建分支只增加引用提交创建新对象并更新当前引用切换改变 HEAD 使用的入口并相应更新工作区。这三个操作不是同一件事。4. commit 是完整快照的入口不是“这次改的那几行”现在回到 main检查 C2 对象gitcat-file-p$C2gitrev-parse$C2^gitls-tree$C1gitls-tree$C2gitshow${C1}:note.txtgitshow${C2}:note.txtcat-file输出形如下面这样实际对象 ID 和时间以你的运行结果为准tree C2的tree对象ID parent C1的commit对象ID author ... committer ... C2: write v2C2^解析为第一个父提交这里就是 C1。两个ls-tree中的note.txt指向不同 blob两个show分别读到v1和v2。我们不需要切换工作区也能直接从历史提交读取文件。所以差异是比较两个版本得到的不是把 commit 理解成一段补丁。逻辑上提交指向完整快照但这不等于物理上每次完整复制所有文件相同对象可以复用存储还可能使用 pack 和增量压缩。链表类比到这里也要收住初始提交没有 parent普通提交通常有一个 parent合并提交可以有多个 parent。Git 的提交历史是有向无环图不保证永远是一条链。5. 两个容易把模型讲错的边界分支存在不代表有独立的分支文件在传统 files 引用后端下松散引用可以保存为.git/refs/heads/main这样的文件。但它也可能被打包进packed-refs。在这个新建实验仓库里可以继续运行gitpack-refs--all--prunegitshow-ref--headsgitrev-parse main feature分支仍然存在main 和 feature 的对象 ID 也没有改变。“找不到 refs/heads 下的某个文件”不能直接推导出“分支丢了”。查询引用优先使用 Git 命令而不是写死文件路径还有其他引用存储后端。HEAD 可以不经过分支gitswitch-q--detach$C1gitsymbolic-ref-qHEADprintfsymbolic-ref exit%s\n$?gitrev-parse HEADcatnote.txtgitswitch-qmain这里symbolic-ref的退出码是 1在终端执行下一行仍可继续rev-parse HEAD得到 C1文件内容是v1。这就是 detached HEADHEAD 直接指向提交而不是通过某个分支名字。本实验只查看没有在 detached 状态创建提交。若在该状态提交新提交不会自动推动 main需要及时用分支等引用保留它不能误以为已经提交到了 main。还有一个相反的边界刚git init、还没提交时HEAD 可以已经指向 main但 main 尚未有提交对象可解析。这叫 unborn branch。HEAD 能表示当前分支名字并不保证已经有当前提交。6. 回到 mini-git在 C 里看见这两个动作下面是我这版 mini-git 的实现片段省略错误处理不是标准 Git 的内部源码上面的实验使用标准 Git也不代表对 mini-git 的所有兼容性做了测试。src/commands/cmd_commit.c的普通提交路径先从 index 写 tree解析当前 HEAD 作为 parent然后创建 commitHash tree_hash;index_write_tree(idx,store,tree_hash);Hash parent_hash;Hash*parent_ptrNULL;if(ref_resolve_head_quiet(refs,parent_hash)0){parent_ptrparent_hash;}Hash commit_hash;commit_create(store,tree_hash,parent_ptr,message,commit_hash);首次提交没有旧 HEAD commit所以 parent 指针为 NULL。随后更新当前引用charbranch_ref[256];ref_get_head_branch(refs,branch_ref,sizeof(branch_ref));ref_update(refs,branch_ref,commit_hash);关键就是这两个动作不同创建对象和更新引用。创建了 C2并不意味着 feature 也要移动这里只更新 HEAD 对应的引用。src/core/ref.c中ref_get_head_branch读取 HEAD。如果内容以ref:开头就返回后面的引用名否则返回HEAD用于 detached 状态更新 HEAD 本身。ref_create_branch的核心则只是charref_path[1024];snprintf(ref_path,sizeof(ref_path),refs/heads/%s,name);returnref_update(mgr,ref_path,hash);这里没有复制工作目录也没有遍历历史链。这正是“分支轻量”的实现含义。这版ref.c还会在读取松散引用失败后尝试packed-refs但不能据此宣称它已经完整覆盖标准 Git 所有引用后端和操作。这一节展示的是概念如何落到代码不把学习项目当成完整兼容实现。7. 实验怎么验证而不只看命令没报错我把这些命令放进独立临时仓库验证判断的不是 hash 是否等于文章里的某串字符而是关系是否成立C2 的 parent 是 C1第一次提交没有 parent。创建 feature 不改变 HEAD 所在分支第二次提交只推动 main。从两个提交读文件得到 v1、v2切换后工作区内容对应目标提交。打包引用前后两个分支仍解析到同样的对象。detached 时 HEAD 解析为 C1符号引用查询返回 1main 仍保持 C2。这些关系比某次输出的 hash 更适合作为测试断言换一台机器、换提交时间对象 ID 会变模型不应该变。回头再看最开始的问题新建分支不复制文件因为它只是给某个历史节点增加一个名字不同分支不会一起前进因为提交更新的是当前引用而不是所有引用。参考Git symbolic-ref、Git pack-refs、Git commit-tree。