关于 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

就这些!我很确定这没什么实际用处,不过也许挺有意思!

Fix typo Subscribe All posts%20Get%20in%20touch matklad

来源: matklad← 返回首页