使用 NFS 將 Git 提交記錄顯示成文件目錄
大家好!某天,我突發奇想 —— 是否能把 Git 存儲庫製作成一個 FUSE 文件系統,然後把所有的提交記錄做成文件夾呢?答案是肯定的!有 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/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),但我發現自己在做這個項目的時候確實使用到了一些。
目前為止我發現主要用處是:
- 查找已經刪除的函數 - 可以用
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 語法,而瀏覽文件系統對我來說更簡單。git worktree
還允許你同時簽出多個分支,但對我來說,為了看一個文件而設置整個工作樹感覺很奇怪。
接下來我想談談我遇到的一些問題。
問題 1: 用 WebDav 還是 NFS?
Mac 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.FileSystem
func 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 不會經常重新打包提交
這個看起來在擁有百萬提交記錄的 Linux 內核的 Git 存儲庫上似乎效果不錯。在我的機器上實測它初始化大概需要一分鐘,之後只需快速增量更新即可。
每個提交哈希只有 20 個位元組,因此緩存 1 百萬個提交哈希也不是很大,大約 20MB。
我認為更聰明的做法是延遲載入提交列表 —— Git 會按提交 ID 對其打包文件進行排序,所以你可以很容易地進行二叉樹搜索,找到所有以 1b
或 1b8c
開始的提交。我用的 Git 庫 對此並不支持,因為羅列出來 Git 存儲庫所有的提交記錄確實一個奇怪的事情。我花了 幾天時間 嘗試實現它,但沒有達到我想要的性能,所以就放棄了。
問題 4: 不是目錄
我常遇到下面這個錯誤:
"/tmp/mnt2/commits/59/59167d7d09fd7a1d64aa1d5be73bc484f6621894/": Not a directory (os error 20)
這起初真的把我嚇了一跳,但事實證明,這只是表示在列出目錄時出現了錯誤,而 NFS 庫處理該錯誤的方式就是顯示 「Not a directory」(不是目錄)。這個錯誤遇到了很多次,我需要每次跟蹤這個錯誤的根源。
有很多類似錯誤。我也遇到 cd: system call interrupted
,令人沮喪的是,但最終也只是程序中的其他錯誤。
我意識到終極大法是用 Wireshark 查看 NFS 發送和接受的數據包,很多問題便可迎刃而解。
問題 5: inode 編號
在開始的時候我不小心將所有的文件夾的 inode 設為 0。這很糟糕,因為如果在每個目錄的 inode 都為 0 的目錄上運行查找,它就會抱怨文件系統循環並放棄,這個也是符合邏輯的。
我通過定義一個 inode(string)
來修復這個問題,通過散列字元串來獲取 inode 編號,並使用樹 ID / blob ID 作為散列字元串。
問題 6: 過期文件句柄
我一直遇到這個「Stale NFS file handle」(過期文件句柄)錯誤。問題是,我需要獲取未知的 64 位元組 NFS 「文件句柄」,並將其映射到正確的目錄。
我使用的 NFS 庫的工作方式是為每個文件生成一個文件句柄,並通過固定大小的緩存來緩存這些引用。這對小型存儲庫來說沒問題,但是如果對於擁有海量的文件的存儲庫來說,由於緩存就會溢出,就會導致「stale file handle」 錯誤。
這仍然是個問題,我不知道如何解決。我不明白真正的 NFS 伺服器是如何做到這一點的,也許它們只是有一個非常大的緩存?
NFS 文件句柄佔用 64 個位元組(不是比特),確實很大,所以很多時候似乎可以將整個文件路徑編碼到句柄中,根本不需要緩存。也許我會在某個時候嘗試實現這一點。
問題 7: 分支歷史
branch_histories/
目錄目前僅羅列對應分支的最近 100 個提交記錄。我不知道該怎麼做,如果能以某種方式列出分支的全部歷史就更好了。也許我可以使用 commits/
目錄中類似的子文件夾技巧。
問題 8: 子模塊
Git 存儲庫有時包含了子模塊。由於目前我對子模塊的理解還不深入,我先忽略它吧。因此這個算是一個問題。
問題 9: NFSv4 是否更好?
我構建這個項目使用的是 NFSv3 庫,因為我當時只能找到一個 NFSv3 的 Go 庫文件。可當我搞完的時候才發現了一個名叫 buildbarn 的項目里有 NFSv4 伺服器。有沒有可能用它會更好一些?
我不知道這樣做有什麼問題,或者用 NFSv4 有哪些優點?我還有點不確定是否要使用 buildbarn NFS 庫,因為不清楚他們是否希望其他人使用它。
就這些吧
之前已經解決了很多問題我都忘記了,這是我目前能回想起來的。我未來有可能解決或根本解決不了 NFS 的「過期文件句柄」 錯誤,或者「在 Linux 內核的存儲庫上啟動需要 1 分鐘」的問題,就這樣吧。
感謝我的朋友 vasi,他給我了很多文件系統方面的幫助。
(題圖:DA/d22b1c01-e80a-4529-b88a-419ceef74b5e)
via: https://jvns.ca/blog/2023/12/04/mounting-git-commits-as-folders-with-nfs/
作者:Julia Evans 選題:lujun9972 譯者:guevaraya 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive