关于 Git 引用
Git ref究竟是什么,.git/refs下的文件与packed-refs文件如何关联,以及为什么“ref就是分支”的心智模型在边缘情况下不断引发困惑。
中文
复制
我最近更新了自己对 Git 的心智模型。看看这两条 git 命令:
$ git fetch origin master
$ git switch -c my-feature origin/master
你知道为什么一条命令里是 origin master,另一条里是 origin/master 吗?几周之前我还不知道!
我原来的理解是:git 是一个内容寻址的数据库。Git 存储 commit,一个 commit 由它内容的哈希标识,而一个 commit 的内容主要是:
- 代码库在某一时刻状态的无记忆快照,
- 一串父 commit 的(哈希)列表。
对我来说,这些 git 知识已经足够看懂 git log 的输出,也足够让我在任何搞砸的 rebase 之后不用重新 clone 仓库就能脱身(职业生涯里大概有一半时间,我确实在重新 clone 仓库。这没什么丢人的!学 git 有用,但你刚入行时它并不是最该优先学的东西)。
我现在明白了,git 不只是一个只追加(“不可变”)的内容寻址数据库,它同时还是一个平平无奇的、可变的键值存储。
Git 有一张可变的映射表,键是字符串,值是内容寻址的对象。键按惯例格式化成文件系统路径,通常你可以通过列出 .git/refs 目录来查看这张映射表的状态:
$ eza -T .git/refs
.git/refs
├── heads
│ ├── make
│ ├── master
│ ├── my-feature
│ └── pbd-adt
├── origin
├── remotes
│ └── origin
│ ├── context-switches
│ ├── gh-pages
│ ├── HEAD
│ ├── make
│ └── master
└── tags
$ cat .git/refs/heads/master
b59148228e52f7c615ead7fdd4e91001994ad50f
$ git show-ref refs/heads/master
b59148228e52f7c615ead7fdd4e91001994ad50f refs/heads/master
这套 refs 键值基础设施之所以让人困惑,是因为:
- 它支撑着许多彼此不同、用户可见的 git 功能,但 ref 本身只是一个实现细节。
- 它是一个有点漏的抽象,ref 在日常使用中几乎是隐形的。
- Git CLI 对 ref 使用简写,还有大量默认参数,让人不容易看出某个 CLI 参数其实就是一个 ref。
- 而且,git 一如既往地喜欢给同一个东西起好几个名字,又把同一个名字用在不同的东西上。
分支、标签、git notes,全都是 ref!
把 CLI 的各种简写展开之后,结构就清楚多了。原本的命令
$ git fetch origin master
就变成了
$ git fetch \
https://github.com/matklad/matklad.github.io \
refs/heads/master:refs/remotes/origin/master
fetch 的第一个参数(https://...)是远程仓库的地址。Git 会“拨通”这个地址,通过网络从那台机器上把一些数据传到本地。
第二个参数是一个由字符串键(ref)组成的 source:target 对。source 是远程仓库上的键,target 是本地键的名字,而 fetch 整体上就是让 git 从远程仓库读取一个值,并以另一个名字保存在本地。
为了避免每次都输入仓库 URL,git 会给它们分配符号名,其中 origin 是主远程仓库的惯用名:
$ git fetch origin \
refs/heads/master:refs/remotes/origin/master
refs/heads/master 是远程仓库上某个分支的完整展开名。也就是说,分支 my-feature 只是一个 refs/heads/my-feature ref。它本来也可以是 refs/branch/my-feature,但并不是 :)
我不清楚具体的简写规则,但一般来说,git 允许你只写 ref 的后缀:
$ git fetch origin \
master:refs/remotes/origin/master
refs/remotes/origin/master 是我们用来保存结果的本地 ref 名。看起来自然的是本地直接用和远程一样的名字,但这只在只有一个远程仓库时才行得通。如果有两个上游仓库(比如你的 fork,以及你 fork 出来的原始仓库),它们的 ref 名就会冲突。所以我们才要把名为 foo 的远程仓库的 ref 归到 refs/remotes/foo 这个命名空间下。而 origin 只是在简单配置中对那个远程仓库的惯用名。
同样,更自然的做法是直接在本地镜像远程 ref 的结构:
refs/ heads/my-branch -> refs/ remotes/origin/ heads/my-branch
但 git 会去掉多余的 heads 部分。而这个 -heads、+remotes/$remote 的映射是内置的,于是命令就压缩成了
$ git fetch origin master
值得想一想它为什么是这样。Git 的模型是离线优先的。而且它假定同步点是显式的。它不会在有网络时在后台与远程仓库同步,而是要求显式执行 fetch 和 push 操作来在网络上传输字节。在这个范式下,把对方在我们上次与之通信那一刻的状态建模出来是很有用的。心智理论!
这有望解开 git 中本地分支与远程分支概念的困惑。以 main 分支为例。它存在于名为 origin 的远程仓库上,名为 refs/heads/main。当你把本地仓库与 origin 同步时,你会得到 refs/remotes/origin/main——这是你目前对 main 在 origin 上状态的最佳了解。
然后是本地 refs/heads/main。它一开始通常和 refs/remotes/origin/main 指向同一个 commit。但你提交一次之后,refs/heads/main 会前进,refs/remotes/origin/main 却原地不动。
如果你想把本地的 commit push 到 origin,而此时 origin 上的 main 分支已经往前走了,就会撞上冲突。这种情况下 git 会自动更新 refs/remotes/origin/main(它本来就只是远端状态的本地镜像),但接下来就得你自己去更新 refs/heads/main,然后再 push 一次。
再看一遍完整例子:
$ git fetch origin master
$ git switch -c my-feature origin/master
第一条命令在 .git/config 里查出 origin remote 对应的 URL,然后向那台机器发起网络请求。结果是本地的 refs/remotes/origin/master 被更新到和远端的 refs/heads/master 相同的 commit(该 commit 及其祖先会一并传到本地)。
第二条命令创建一个 refs/heads/my-feature ref(也就是一个分支),起点是 refs/remotes/origin/master。这是一个抽象泄漏的例子。
那里的第二个参数是 ref 的(简写形式),所以你可以写
$ git switch -c my-feature \
refs/remotes/origin/master
但第一个参数虽然创建了一个 ref,它本身并不是 ref。换句话说,如果你也想把它展开
$ git switch -c refs/heads/my-feature \
refs/remotes/origin/master
就会得到
refs/heads/refs/heads/my-feature
就这些!我很确定这没什么实际用处,不过也许挺有意思!