使用NFS将Git提交挂载为文件夹
摘要
Julia Evans 创建了一个名为 git-commit-folders 的工具,它使用 NFS(和 FUSE)将 Git 提交挂载为文件夹,让用户可以像浏览目录一样探索旧的提交。
暂无内容
查看缓存全文
缓存时间: 2026/05/21 18:14
# 用 NFS 将 Git 提交挂载为文件夹
来源:https://jvns.ca/blog/2023/12/04/mounting-git-commits-as-folders-with-nfs/
你好!最近我忽然想到一个问题——有没有人做过一个 FUSE 文件系统,能把 Git 仓库中的每次提交都变成一个文件夹?结果答案是肯定的!有 [giblefs](https://github.com/fanzeyi/giblefs)、[GitMounter](https://belkadan.com/blog/2023/11/GitMounter/),以及 Plan 9 的 [git9](https://orib.dev/git9.html)。
但在 Mac 上使用 FUSE 相当麻烦——你需要安装内核扩展,而 macOS 出于安全原因似乎越来越难安装内核扩展了。另外,我对如何以不同于这些项目的方式来组织文件系统也有一些想法。
所以我觉得试试看除了 FUSE 之外,还能用什么方法在 macOS 上挂载文件系统会很有趣,于是我写了一个名叫 [git-commit-folders](https://github.com/jvns/git-commit-folders) 的项目来做这件事。它既支持 FUSE 也支持 NFS(至少在我的电脑上可以),另外还有一个坏掉的 WebDAV 实现。
这个项目目前还非常实验性(我也不确定这到底是一个有用的工具,还是一个关于 Git 工作原理的有趣玩具),不过写起来很有意思,我自己在小仓库里也用得很开心。下面是一些我在实现过程中遇到的问题。
### 目标:展示提交如何像文件夹一样
我主要想通过这个项目,让人们对 Git 的内部工作原理有一个直觉上的理解。毕竟,Git 提交确实*非常*像文件夹——每个 Git 提交都包含一个[目录列表](https://jvns.ca/blog/2023/09/14/in-a-git-repository--where-do-your-files-live-/#commit-step-2-look-at-the-tree),列出其中的文件,这个目录还可以包含子目录等等。
只是 Git 提交实际上*并没有*实现为文件夹,这是为了节省磁盘空间。
所以,在 `git-commit-folders` 中,每个提交都是一个文件夹。如果你想浏览旧提交,只需像浏览普通文件系统一样即可!例如,我博客的初始提交看起来是这样的:
```
$ ls commits/8d/8dc0/8dc0cb0b4b0de3c6f40674198cb2bd44aeee9b86/
README
```
再往后几个提交,就变成了这样:
```
$ ls /tmp/git-homepage/commits/c9/c94e/c94e6f531d02e658d96a3b6255bbf424367765e9/
_config.yml config.rb Rakefile rubypants.rb source
```
### 分支是符号链接
在 `git-commit-folders` 挂载的文件系统中,只有提交才是真正的文件夹——其他所有东西(分支、标签等)都是指向提交的符号链接。这正好反映了 Git 内部的工作原理。
```
$ ls -l branches/
lr-xr-xr-x 59 bork bazil-fuse -> ../commits/ff/ff56/ff563b089f9d952cd21ac4d68d8f13c94183dcd8
lr-xr-xr-x 59 bork follow-symlink -> ../commits/7f/7f73/7f73779a8ff79a2a1e21553c6c9cd5d195f33030
lr-xr-xr-x 59 bork go-mod-branch -> ../commits/91/912d/912da3150d9cfa74523b42fae028bbb320b6804f
lr-xr-xr-x 59 bork mac-version -> ../commits/30/3008/30082dcd702b59435f71969cf453828f60753e67
lr-xr-xr-x 59 bork mac-version-debugging -> ../commits/18/18c0/18c0db074ec9b70cb7a28ad9d3f9850082129ce0
lr-xr-xr-x 59 bork main -> ../commits/04/043e/043e90debbeb0fc6b4e28cf8776e874aa5b6e673
$ ls -l tags/
lr-xr-xr-x - bork 31 Dec 1969 test-tag -> ../commits/16/16a3/16a3d776dc163aa8286fb89fde51183ed90c71d0
```
这当然无法完全解释 Git 的工作原理(远不止“提交就像文件夹”这么简单),但我希望它能让“每个提交都像一个包含旧版本代码的文件夹”这个想法变得更具体一些。
### 为什么这可能有用?
在深入实现细节之前,我想聊聊拥有一个含每个 Git 提交文件夹的文件系统可能有什么用。我的很多项目最终自己都没怎么用过(比如 [dnspeep](https://github.com/jvns/dnspeep)),但我在做这个项目的过程中确实用它做了一点事。
目前为止我发现的主要用途有:
- 搜索我删除过的函数——可以运行 `grep someFunction branch_histories/main/*/commit.go` 来找到旧版本。
- 快速查看另一个分支上的某个文件,从中复制一行代码,比如 `vim branches/other-branch/go.mod`。
- 在所有分支中搜索某个函数,比如 `grep someFunction branches/*/commit.go`。
所有这些操作都是通过指向提交的符号链接来完成的,而不是直接引用提交。
这些都不是最高效的做法(你可以用 `git show` 和 `git log -S`,或者也许 `git grep` 来完成类似的事情),但个人而言我老记不住语法,浏览文件系统对我来说感觉更简单。`git worktree` 也允许你同时检出多个分支,但为了只看一个文件就设置一个完整的工作目录,感觉有点奇怪。
接下来我想聊聊遇到的一些问题。
### 问题 1:WebDAV 还是 NFS?
macOS 原生支持的两种文件系统是 WebDAV 和 NFS。我不确定哪个更容易实现,所以两种都试了。
开始觉得 WebDAV 更容易一些,结果发现 golang.org/x/net 有一个 [webdav 实现](https://pkg.go.dev/golang.org/x/net/webdav),设置起来相当容易。
但那个实现不支持符号链接,我想是因为它使用了 `io/fs` 接口,而 `io/fs` [还不支持符号链接](https://github.com/golang/go/issues/49580)。看起来这个功能正在开发中。所以我放弃了 WebDAV,转而专注于 NFS 实现,使用了 [go-nfs](https://github.com/willscott/go-nfs/) 这个 NFSv3 库。
也有人提到 macOS 上有 [FileProvider](https://developer.apple.com/documentation/fileprovider/),但我没深入研究。
### 问题 2:如何让所有实现保持一致?
我要实现三个不同的文件系统(FUSE、NFS、WebDAV),怎么避免大量重复代码呢?
我的朋友 Dave 建议先写一个核心实现,然后编写适配器(比如 `fuse2nfs` 和 `fuse2dav`)将其转换成 NFS 和 WebDAV 的版本。具体来说,我需要实现三个文件系统接口:
- `fs.FS`(用于 FUSE)
- `billy.Filesystem`(用于 NFS)
- `webdav.FileSystem`(用于 WebDAV)
所以我把所有核心逻辑都放在 `fs.FS` 接口中,然后写了两个函数:
- `func Fuse2Dav(fs fs.FS) webdav.FileSystem`
- `func Fuse2NFS(fs fs.FS) billy.Filesystem`
所有文件系统类型都差不多,转换起来不算太难,只是有一百万个烦人的 bug 要修。
### 问题 3:我不想列出每个提交
有些 Git 仓库有成千上万个提交。我最初的想法是让 `commits/` 目录看起来是空的,这样工作方式如下:
```
$ ls commits/
$ ls commits/80210c25a86f75440110e4bc280e388b2c098fbd/
fuse fuse2nfs go.mod go.sum main.go README.md
```
也就是说,每个提交都可以直接引用访问,但不能列出。这对于文件系统来说有点奇怪,但在 FUSE 中完全没问题。不过在 NFS 中我没能让它工作。我猜这里的问题是,如果你告诉 NFS 某个目录是空的,它就会认为目录真的就是空的,这也很合理。
最后我这样处理:
- 像 `.git/objects` 那样,按照提交哈希的前两个字符组织目录(这样 `ls commits` 显示为 `0b 03 05 06 07 09 1b 1e 3e 4a`),但做了两级目录,比如提交 `18d46e76d7c2eedd8577fae67e3f1d4db25018b0` 位于 `commits/18/18df/18d46e76d7c2eedd8577fae67e3f1d4db25018b0`
- 只在一开始列出所有已打包的提交哈希,缓存在内存中,之后只更新松散对象。思路是仓库中几乎所有提交都会被打包,而且 Git 不会频繁地重新打包提交。
这在 Linux 内核(约有 100 万个提交)上似乎运行得不错。初始加载在我的机器上大约需要一分钟,之后只需要快速的增量更新。
每个提交哈希只有 20 字节,所以缓存 100 万个提交哈希不是什么大问题,也就 20MB。
我觉得更聪明的方法是惰性加载提交列表——Git 的包文件是按提交 ID 排序的,所以你可以很容易地进行二分查找,找到所有以 `1b` 或 `1b8c` 开头的提交。不过我用的 [git 库](https://github.com/go-git/go-git) 对此支持不是很好,因为列出 Git 仓库中的所有提交是一件非常奇怪的事情。我花了大概几天[尝试实现](https://github.com/jvns/git-commit-folders/tree/fast-commits),但没达到想要的性能,于是就放弃了。
### 问题 4:“不是目录”
我一直收到这个错误:
```
"/tmp/mnt2/commits/59/59167d7d09fd7a1d64aa1d5be73bc484f6621894/": Not a directory (os error 20)
```
一开始这让我很困惑,但后来发现这只是表示列出目录时出错,而 NFS 库处理该错误的方式就是返回“Not a directory”。这种情况发生过很多次,每次我都需要去追踪具体的 bug。
还有各种各样奇怪的错误。我还遇到过 `cd: system call interrupted`,这让人很沮丧,但最后发现只是程序中某个其他的 bug。
后来我意识到可以用 Wireshark 来查看所有来回传输的 NFS 数据包,这让某些调试工作变得容易了一些。
### 问题 5:inode 编号
刚开始我不小心将所有目录的 inode 编号设成了 0。这很糟糕,因为如果你在目录上运行 `find`,而其中每个目录的 inode 编号都是 0,它就会抱怨文件系统循环并放弃,这也很合理。
我通过定义一个 `inode(string)` 函数来修复这个问题,该函数对字符串进行哈希以得到 inode 编号,并使用树 ID / blob ID 作为要哈希的字符串。
### 问题 6:失效的文件句柄
我一直收到“Stale NFS file handle”错误。问题在于我需要能够根据一个不透明的 64 字节 NFS“文件句柄”,映射到正确的目录。
我使用的 NFS 库的工作方式是:为每个文件生成一个文件句柄,并用一个固定大小的缓存来缓存这些引用。这对于小仓库没问题,但如果文件太多,缓存就会溢出,然后你就会开始收到失效文件句柄的错误。
这个问题仍然存在,我不确定如何解决。我不理解真实的 NFS 服务器是怎么做的,也许它们只是用了很大的缓存?
NFS 文件句柄是 64 字节(64 字节!不是比特!),这相当大,所以似乎很多时候你完全可以把整个文件路径编码在文件句柄中,而不需要缓存。也许以后我会尝试实现一下。
### 问题 7:分支历史
目前 `branch_histories/` 目录只列出了每个分支最近的 100 个提交。我不确定正确的做法是什么——如果能以某种方式列出分支的完整历史就好了。也许我可以使用类似 `commits/` 目录的子文件夹技巧。
### 问题 8:子模块
Git 仓库有时会有子模块。我对子模块一无所知,所以现在直接忽略了它们。所以这是个 bug。
### 问题 9:NFSv4 更好吗?
我用 NFSv3 构建这个项目,因为当时能找到的唯一 Go 库就是 NFSv3 的。等我做完之后,发现 buildbarn 项目里有一个 [NFSv4 服务器](https://github.com/buildbarn/bb-adrs/blob/master/0009-nfsv4.md)。用那个会不会更好?
我不知道这算不算一个问题,也不知道使用 NFSv4 会有多大优势。另外我也不太确定是否应该使用 buildbarn 的 NFS 库,因为不清楚他们是否希望别人使用它。
### 就这些了!
可能还有一些我忘了的问题,但目前只能想到这些了。我可能会修复 NFS 失效文件句柄的问题,也可能会修复“在 Linux 内核上启动需要 1 分钟”的问题,谁知道呢!
感谢我的朋友 [vasi](https://github.com/vasi),他给我解释了一百万个关于文件系统的东西。
相似文章
用Jujutsu战胜Git严谨疲劳
本文介绍了一种使用Jujutsu版本控制系统的工作流程,旨在克服在Git中保持严格提交纪律的疲劳感,允许开发者先进行杂乱提交,最后再整洁地重新组织它们。
本地 Git 远程仓库
关于如何使用裸仓库为个人项目设置本地 Git 远程仓库的教程,支持离线友好的推/拉工作流。
Git 2.54 亮点速览
Git 2.54 带来全新的实验性 `git history` 命令,可在不碰工作区的情况下重写或拆分提交,另有 137 位贡献者带来的其他改进。
[开源] 我用 Go 编写了一个完整的 Git MCP 服务器,不是简单地封装 bash。它使用了 tree-sitter,处理真正的底层操作(write-tree),并且 100% 本地运行。
git-courer 是一个用 Go 编写的完整 Git MCP 服务器,它使用 tree-sitter 进行语义代码分析,通过结构化 JSON 进行通信,支持 13 个客户端,并以本地优先的方式运行。
Rift:Git Worktrees 的更优替代方案
Rift 是一个命令行工具,提供比 Git worktrees 更好的替代方案,通过写时复制快照在 Linux 的 btrfs 和 macOS 的 APFS 上实现快速创建工作区。