用 NFS 将 Git 提交挂载为文件夹:项目开发问题与用途全揭秘

用 NFS 将 Git 提交挂载为文件夹:项目开发问题与用途全揭秘 用 NFS 将 Git 提交挂载为文件夹前几天有人想到一个问题有没有人做过一种 FUSE 文件系统能把 Git 仓库里的每个提交都当作一个文件夹呢结果发现还真有比如 giblefs、GitMounter还有适用于 Plan 9 的 git9。不过在 Mac 上使用 FUSE 挺麻烦的需要安装内核扩展而且出于安全考虑Mac OS 似乎越来越难安装内核扩展了。另外开发者对文件系统的组织方式也有一些和这些项目不同的想法。所以开发者尝试在 Mac OS 上用 FUSE 之外的方法挂载文件系统开发了一个名为 git-commit-folders 的项目。它至少在开发者的计算机上同时支持 FUSE 和 NFS还有一个不太完善的 WebDav 实现。这个项目还处于实验阶段开发者不确定它到底是一款实用的软件还是一个用来探索 Git 工作原理的有趣玩具但编写过程很有趣开发者自己在小仓库里用着也挺顺手。下面就来分享一下开发者在编写过程中遇到的一些问题。目标展示提交如何类似文件夹开发者做这个项目的主要原因是想让大家对 Git 的底层工作原理有更直观的认识。毕竟Git 提交实际上和文件夹非常相似——每个 Git 提交都包含一个目录列表列出了其中的文件而且这个目录还可以包含子目录等等。只不过为了节省磁盘空间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/ff563b089f9d952cd21ac4d68d8f13c94183dcd8lr-xr-xr-x 59 bork follow-symlink - ../commits/7f/7f73/7f73779a8ff79a2a1e21553c6c9cd5d195f33030lr-xr-xr-x 59 bork go-mod-branch - ../commits/91/912d/912da3150d9cfa74523b42fae028bbb320b6804flr-xr-xr-x 59 bork mac-version - ../commits/30/3008/30082dcd702b59435f71969cf453828f60753e67lr-xr-xr-x 59 bork mac-version-debugging - ../commits/18/18c0/18c0db074ec9b70cb7a28ad9d3f9850082129ce0lr-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 远不止“提交就像文件夹”这么简单但开发者希望能让“每个提交就像一个包含旧版本代码的文件夹”这个概念更加具体。为什么这可能有用在介绍实现细节之前先来谈谈把每个 Git 提交都当作文件夹的文件系统有什么用。开发者很多项目最后都没怎么用比如 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 也能让开发者同时检出多个分支但对开发者来说仅仅为了查看一个文件就设置整个工作树感觉有点麻烦。接下来要讲讲遇到的一些问题。问题 1WebDav 还是 NFSMac OS 原生支持的两种文件系统是 WebDav 和 NFS。开发者不确定哪个更容易实现所以就两个都试了试。一开始WebDav 看起来更容易而且 golang.org/x/net 有一个 WebDav 实现设置起来挺简单的。但这个实现不支持符号链接开发者觉得可能是因为它使用了 io/fs 接口而 io/fs 目前还不支持符号链接。不过这个问题好像正在解决中。所以开发者放弃了 WebDav决定专注于 NFS 实现使用 go-nfs 这个 NFSv3 库。也有人提到 Mac 上有 FileProvider但开发者没深入研究。问题 2如何保持所有实现同步开发者要实现三种不同的文件系统FUSE、NFS 和 WebDav但不清楚如何避免大量的代码重复。开发者的朋友 Dave 建议先写一个核心实现然后编写适配器比如 fuse2nfs 和 fuse2dav把它转换为 NFS 和 WebDav 版本。具体来说开发者需要实现三个文件系统接口用于 FUSE 的 fs.FS用于 NFS 的 billy.Filesystem用于 WebDav 的 webdav.Filesystem所以开发者把所有核心逻辑都放在 fs.FS 接口里然后编写了两个函数func Fuse2Dav(fs fs.FS) webdav.FileSystemfunc Fuse2NFS(fs fs.FS) billy.Filesystem这些文件系统都有相似之处所以转换起来不算太难但要修复的小问题可不少。问题 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 不常重新打包提交。在有大约 100 万个提交的 Linux 内核仓库上这个方法效果还不错。在开发者的机器上初始加载可能需要一分钟之后只需要进行快速的增量更新。每个提交哈希只有 20 字节缓存 100 万个提交哈希也不过 20MB不算什么。开发者觉得更聪明的做法是懒加载提交列表——Git 会按提交 ID 对打包文件进行排序所以可以很容易地用二分查找找到以 1b 或 1b8c 开头的所有提交。但开发者用的 Git 库在这方面支持不太好因为列出 Git 仓库里的所有提交是个挺奇怪的需求。开发者花了几天时间尝试实现但没达到想要的性能就放弃了。问题 4“不是目录”开发者总是遇到这个错误/tmp/mnt2/commits/59/59167d7d09fd7a1d64aa1d5be73bc484f6621894/: Not a directory (os error 20)一开始开发者被这个错误搞得晕头转向后来发现这其实意味着在列出目录时出错了而 NFS 库处理这个错误的方式就是返回“不是目录”。这种情况出现了很多次开发者每次都得去排查问题。还有很多类似的奇怪错误。开发者还遇到过 cd: system call interrupted这挺让人郁闷的但最后发现只是程序里的其他 bug。最后开发者意识到可以用 Wireshark 查看所有来回传输的 NFS 数据包这让调试变得容易了一些。问题 5inode 编号一开始开发者不小心把所有目录的 inode 编号都设成了 0。这可不行因为如果在一个所有目录 inode 编号都是 0 的目录里运行 find它会抱怨文件系统有循环然后放弃这也很合理。开发者通过定义一个 inode(string) 函数来解决这个问题这个函数会对字符串进行哈希运算得到 inode 编号然后用树 ID 或 blob ID 作为要哈希的字符串。问题 6过时的文件句柄开发者总是遇到“Stale NFS file handle”错误。问题在于开发者需要能够将一个不透明的 64 字节 NFS“文件句柄”映射到正确的目录。开发者用的 NFS 库的工作方式是为每个文件生成一个文件句柄并使用固定大小的缓存来存储这些引用。对于小仓库来说这样没问题但如果文件太多缓存就会溢出就会开始遇到过时的文件句柄错误。这仍然是个问题开发者还不知道怎么解决。开发者不明白真正的 NFS 服务器是怎么处理这个问题的也许它们有一个很大的缓存NFS 文件句柄有 64 字节注意是字节不是比特挺大的所以很多时候其实可以直接把整个文件路径编码在句柄里而不用缓存。也许开发者之后会试试实现这个方法。问题 7分支历史目前branch_histories/ 目录只列出每个分支的最新 100 个提交。开发者不确定该怎么处理这个问题——要是能列出分支的完整历史就好了。也许开发者可以采用和 commits/ 目录类似的子文件夹技巧。问题 8子模块Git 仓库有时会有子模块。开发者对这方面不太了解所以目前就直接忽略了。这算是个 bug。问题 9NFSv4 更好吗开发者用 NFSv3 构建了这个项目因为当时能找到的唯一 Go 库是 NFSv3 库。完成之后开发者发现 buildbarn 项目里有一个 NFSv4 服务器。用它会不会更好呢开发者不确定这到底是不是个问题也不清楚使用 NFSv4 会有多大优势。开发者也有点犹豫要不要用 buildbarn 的 NFS 库因为不清楚他们是否希望别人使用。就这些啦可能还有一些开发者忘记的问题但目前能想到的就这些了。开发者不确定会不会修复 NFS 过时文件句柄的问题或者“在 Linux 内核仓库上启动需要一分钟”的问题谁知道呢感谢开发者的朋友 vasi他给开发者讲解了很多关于文件系统的知识。