使用 NFS 將 Git 提交記錄顯示成文件目錄
大家好!某天,我突發(fā)奇想 —— 是否能把 Git 存儲庫制作成一個 FUSE 文件系統(tǒng),然后把所有的提交記錄做成文件夾呢?答案是肯定的!有 giblefs、 GitMounter 和用于 Plan 9 號的 git9。
但在 Mac 上使用 FUSE 實在很煩人 —— 你需要安裝一個內(nèi)核擴(kuò)展,但由于安全的原因,Mac OS 上安裝內(nèi)核擴(kuò)展看起來越來越難了。此外,我還有一些想法,希望能用與這些項目不同的方式來組織文件系統(tǒng)。
因此,我想在 Mac OS 上嘗試 FUSE 以外的掛載文件系統(tǒng)的方法會很有趣,因此我創(chuàng)建了一個名為 git-commit-folders 的項目來做這個事。它可以同時使用 FUSE 和 NFS(至少在我的電腦上),WebDav 的實現(xiàn)起來還有點問題。
這個項目很有實驗性(我不確定這究竟是一個有用的軟件,還是一個思考 Git 如何工作的有趣玩具),但寫起來很有趣,我自己也很喜歡在小型存儲庫中使用它,下面是我在寫這個項目時遇到的一些問題。
目標(biāo):像文件夾一樣顯示提交記錄
我做這個事的主要目的是給大家一些啟發(fā):Git 核心是如何運行的??偨Y(jié)來說,Git 提交記錄實際上和文件夾非常類似 —— 每個 Git 提交都包含一個目錄,其中 列出了文件,這個目錄也可以有子目錄,依此類推。
只是為了節(jié)省磁盤空間,Git 提交實際上并不是以文件夾的形式實現(xiàn)的。
而在 git-commit-folders
,所有的提交記錄實際上看起來就是一個文件夾,如果你想瀏覽歷史提交記錄,你可以像瀏覽文件系統(tǒng)一樣瀏覽它們!例如如果你像查看我的博客的初始提交記錄,你可以如下操作:
$ 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
掛載的文件系統(tǒng)中,提交是唯一真正的文件夾 —— 其他一切(分支、標(biāo)簽等)都是提交記錄的符號鏈接。這反映了 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
這個并不能完全呈現(xiàn) Git 的所有工作機(jī)理(相比簡單的類似文件夾的提交,還有很多復(fù)雜的細(xì)節(jié)),但是我希望大家對“每個提交如同一個文件夾,里面有你的舊版本代碼”有一個直觀的認(rèn)識。
這么做有什么好處呢?
在我深入介紹它的實現(xiàn)之前,我想說下為什么把 Git 提交記錄變成擁有文件夾的文件系統(tǒng)很有用。我的很多項目最終都沒有真正使用過(比如 dnspeep),但我發(fā)現(xiàn)自己在做這個項目的時候確實使用到了一些。
目前為止我發(fā)現(xiàn)主要用處是:
- 查找已經(jīng)刪除的函數(shù) - 可以用
grep someFunction branch_histories/main/*/commit.go
查找它的舊版本 - 快速查看其他分支的一個文件并從其拷貝一行,如
vim branches/other-branch/go.mod
- 在每個分支中搜索某個函數(shù),如
grep someFunction branches/*/commit.go
所有這些操作都通過提交記錄的符號鏈接,來替代提交記錄的直接引用。
這些都不是最有效的方法(你可以用 git show
和 git log -S
或者 git grep
來完成類似操作),但是對我個人來說,我經(jīng)常忘記 Git 語法,而瀏覽文件系統(tǒng)對我來說更簡單。git worktree
還允許你同時簽出多個分支,但對我來說,為了看一個文件而設(shè)置整個工作樹感覺很奇怪。
接下來我想談?wù)勎矣龅降囊恍﹩栴}。
問題 1: 用 WebDav 還是 NFS?
Mac OS 原生支持的兩個文件系統(tǒng)是 WebDav 和 NFS。我說不出那個更新容易實現(xiàn),所以我就索性嘗試兩個都支持。
起初,WebDav 的實現(xiàn)看起來更容易一些,在 golang.org/x/net 上有一個 WebDav 實現(xiàn),這個很好配置。
但這個實現(xiàn)不支持符號鏈接,我想可能原因是它用的是 io/fs
接口,而 io/fs
還不支持 符號鏈接。不過看起來正在進(jìn)行中。所以我放棄了 WebDav,而決定重點放在 NFS 實現(xiàn)上了,用 go-nfs NFSv3 的庫文件來實現(xiàn)。
有人也提到了 Mac 上的 FileProvider,我還沒有深入了解這個。
問題 2: 如何確保所有的實現(xiàn)保持一致?
我已經(jīng)實現(xiàn)了三個不同的文件系統(tǒng)(FUSE、NFS 和 WebDav),但對我來說還是沒搞清楚如何避免大量的重復(fù)代碼。
我的朋友 Dave 建議寫一個核心實現(xiàn),然后寫一個適配器(如 fuse2nfs
和 fuse2dav
)來轉(zhuǎn)換成 NFS 和 WebDav 版本。這個看起來需要我著手實現(xiàn)三個文件系統(tǒng)的接口:
- 對應(yīng) FUSE 的
fs.FS
- 對應(yīng) NFS 的
billy.Filesystem
- 對應(yīng) WebDav 的
webdav.Filesystem
因此我把所有的核心邏輯放到 fs.FS
接口上,然后寫兩個函數(shù):
func Fuse2Dav(fs fs.FS) webdav.FileSystem
func Fuse2NFS(fs fs.FS) billy.Filesystem
所有的文件系統(tǒng)都比較類似,因此轉(zhuǎn)換起來不是很難,但就是有大量的煩人的問題需要修復(fù)。
問題 3: 我不想羅列所有的提交記錄怎么辦
一些 Git 存儲庫有成千上萬的提交記錄。我的第一個想法是如何讓 commits/
看起來是空的,這樣就可以如下展示:
$ ls commits/
$ ls commits/80210c25a86f75440110e4bc280e388b2c098fbd/
fuse fuse2nfs go.mod go.sum main.go README.md
因此所有的提交記錄可以直接查看,但是又不能羅列它們。這個對文件系統(tǒng)是一個奇怪的事情,實際上 FUSE 可以做到。但我在 NFS 上無法實現(xiàn)。我認(rèn)為這里的原因是,如果你告訴 NFS 某個目錄是空的,它就會認(rèn)為該目錄實際上是空的,這是合理的。
我們最終是這樣處理的:
- 按照
.git/objects
的方式,以前兩個字符組織管理提交記錄(因此ls commits
會顯示0b 03 05 06 07 09 1b 1e 3e 4a
),但這樣做會分為兩層,這樣18d46e76d7c2eedd8577fae67e3f1d4db25018b0
則為commits/18/18df/18d46e76d7c2eedd8577fae67e3f1d4db25018b0
- 開始只羅列一次所有的已經(jīng)打包的提交哈希,將它們緩存在內(nèi)存中,然后后面僅更新稀疏對象。主要思路是版本庫中幾乎所有的提交都應(yīng)該打包,而且 Git 不會經(jīng)常重新打包提交
這個看起來在擁有百萬提交記錄的 Linux 內(nèi)核的 Git 存儲庫上似乎效果不錯。在我的機(jī)器上實測它初始化大概需要一分鐘,之后只需快速增量更新即可。
每個提交哈希只有 20 個字節(jié),因此緩存 1 百萬個提交哈希也不是很大,大約 20MB。
我認(rèn)為更聰明的做法是延遲加載提交列表 —— Git 會按提交 ID 對其打包文件進(jìn)行排序,所以你可以很容易地進(jìn)行二叉樹搜索,找到所有以 1b
或 1b8c
開始的提交。我用的 Git 庫 對此并不支持,因為羅列出來 Git 存儲庫所有的提交記錄確實一個奇怪的事情。我花了 幾天時間 嘗試實現(xiàn)它,但沒有達(dá)到我想要的性能,所以就放棄了。
問題 4: 不是目錄
我常遇到下面這個錯誤:
"/tmp/mnt2/commits/59/59167d7d09fd7a1d64aa1d5be73bc484f6621894/": Not a directory (os error 20)
這起初真的把我嚇了一跳,但事實證明,這只是表示在列出目錄時出現(xiàn)了錯誤,而 NFS 庫處理該錯誤的方式就是顯示 “Not a directory”(不是目錄)。這個錯誤遇到了很多次,我需要每次跟蹤這個錯誤的根源。
有很多類似錯誤。我也遇到 cd: system call interrupted
,令人沮喪的是,但最終也只是程序中的其他錯誤。
我意識到終極大法是用 Wireshark 查看 NFS 發(fā)送和接受的數(shù)據(jù)包,很多問題便可迎刃而解。
問題 5: inode 編號
在開始的時候我不小心將所有的文件夾的 inode 設(shè)為 0。這很糟糕,因為如果在每個目錄的 inode 都為 0 的目錄上運行查找,它就會抱怨文件系統(tǒng)循環(huán)并放棄,這個也是符合邏輯的。
我通過定義一個 inode(string)
來修復(fù)這個問題,通過散列字符串來獲取 inode 編號,并使用樹 ID / blob ID 作為散列字符串。
問題 6: 過期文件句柄
我一直遇到這個“Stale NFS file handle”(過期文件句柄)錯誤。問題是,我需要獲取未知的 64 字節(jié) NFS “文件句柄”,并將其映射到正確的目錄。
我使用的 NFS 庫的工作方式是為每個文件生成一個文件句柄,并通過固定大小的緩存來緩存這些引用。這對小型存儲庫來說沒問題,但是如果對于擁有海量的文件的存儲庫來說,由于緩存就會溢出,就會導(dǎo)致“stale file handle” 錯誤。
這仍然是個問題,我不知道如何解決。我不明白真正的 NFS 服務(wù)器是如何做到這一點的,也許它們只是有一個非常大的緩存?
NFS 文件句柄占用 64 個字節(jié)(不是比特),確實很大,所以很多時候似乎可以將整個文件路徑編碼到句柄中,根本不需要緩存。也許我會在某個時候嘗試實現(xiàn)這一點。
問題 7: 分支歷史
branch_histories/
目錄目前僅羅列對應(yīng)分支的最近 100 個提交記錄。我不知道該怎么做,如果能以某種方式列出分支的全部歷史就更好了。也許我可以使用 commits/
目錄中類似的子文件夾技巧。
問題 8: 子模塊
Git 存儲庫有時包含了子模塊。由于目前我對子模塊的理解還不深入,我先忽略它吧。因此這個算是一個問題。
問題 9: NFSv4 是否更好?
我構(gòu)建這個項目使用的是 NFSv3 庫,因為我當(dāng)時只能找到一個 NFSv3 的 Go 庫文件??僧?dāng)我搞完的時候才發(fā)現(xiàn)了一個名叫 buildbarn 的項目里有 NFSv4 服務(wù)器。有沒有可能用它會更好一些?
我不知道這樣做有什么問題,或者用 NFSv4 有哪些優(yōu)點?我還有點不確定是否要使用 buildbarn NFS 庫,因為不清楚他們是否希望其他人使用它。
就這些吧
之前已經(jīng)解決了很多問題我都忘記了,這是我目前能回想起來的。我未來有可能解決或根本解決不了 NFS 的“過期文件句柄” 錯誤,或者“在 Linux 內(nèi)核的存儲庫上啟動需要 1 分鐘”的問題,就這樣吧。
感謝我的朋友 vasi,他給我了很多文件系統(tǒng)方面的幫助。