From fb05da102f42dd730d3e30869294dd44591c7459 Mon Sep 17 00:00:00 2001 From: Tianon Gravi Date: Tue, 12 Aug 2025 13:26:58 -0700 Subject: [PATCH] Add support for submodules in gitfs For now, these emulate the support that `git archive` has for them (namely, that they present as empty directories, which are otherwise impossible to create/represent in Git). --- pkg/gitfs/fs.go | 22 ++++++++++++++ pkg/gitfs/fs_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/pkg/gitfs/fs.go b/pkg/gitfs/fs.go index 547166d4..cf48f80c 100644 --- a/pkg/gitfs/fs.go +++ b/pkg/gitfs/fs.go @@ -158,6 +158,10 @@ func (f gitFS) statEntry(name string, entry *goGitPlumbingObject.TreeEntry, foll fi.entry = entry fi.name = path.Join(fi.name, name) + if fi.isSubmodule() { + return fi, nil + } + if fi.IsDir() { fi.tree, err = goGitPlumbingObject.GetTree(f.storer, entry.Hash) // see https://github.com/go-git/go-git/blob/v5.11.0/plumbing/object/tree.go#L103 if err != nil { @@ -309,6 +313,16 @@ func (f gitFS) ReadDir(n int) ([]fs.DirEntry, error) { return nil, fmt.Errorf("%q not open (or not a directory)", f.name) } ret := []fs.DirEntry{} + if f.isSubmodule() { + // https://pkg.go.dev/io/fs#ReadDirFile + // "If n > 0, ... At the end of a directory, the error is io.EOF." + // "If n <= 0, ... it returns the slice and a nil error." + err := io.EOF + if n <= 0 { + err = nil + } + return ret, err + } for i := 0; n <= 0 || i < n; i++ { name, entry, err := f.walker.Next() if err != nil { @@ -354,10 +368,18 @@ func (f gitFS) Mode() fs.FileMode { return 0775 case goGitPlumbingFileMode.Dir: return 0775 | fs.ModeDir + case goGitPlumbingFileMode.Submodule: + // TODO handle submodules better / more explicitly ("git archive" presents them as empty directories, so we do too, for now) + return 0775 | fs.ModeDir } return 0 | fs.ModeIrregular // TODO what to do for files whose types we don't support? 😬 } +func (f gitFS) isSubmodule() bool { + // TODO handle submodules better / more explicitly ("git archive" presents them as empty directories, so we do too, for now) + return f.entry != nil && f.entry.Mode == goGitPlumbingFileMode.Submodule +} + // https://pkg.go.dev/io/fs#FileInfo: modification time func (f gitFS) ModTime() time.Time { return f.Mod diff --git a/pkg/gitfs/fs_test.go b/pkg/gitfs/fs_test.go index f3369607..71e9d648 100644 --- a/pkg/gitfs/fs_test.go +++ b/pkg/gitfs/fs_test.go @@ -2,6 +2,7 @@ package gitfs_test import ( "io" + "io/fs" "testing" "testing/fstest" @@ -171,3 +172,70 @@ func TestSubdirSymlinkFS(t *testing.T) { } }) } + +func TestSubmoduleFS(t *testing.T) { + // TODO instead of cloning a remote repository, synthesize a very simple Git repository right in the test here (benefit of the remote repository is that it's much larger, so fstest.TestFS has a lot more data to test against) + // Init + CreateRemoteAnonymous + Fetch because Clone doesn't support fetch-by-commit + repo, err := git.Init(memory.NewStorage(), nil) + if err != nil { + t.Fatal(err) + } + remote, err := repo.CreateRemoteAnonymous(&goGitConfig.RemoteConfig{ + Name: "anonymous", + URLs: []string{"https://github.com/debuerreotype/debuerreotype.git"}, // just a repository with a known submodule (`./validate/`) + }) + if err != nil { + t.Fatal(err) + } + commit := "d12af8e5556e39f82082b44628288e2eb27d4c34" + err = remote.Fetch(&git.FetchOptions{ + RefSpecs: []goGitConfig.RefSpec{goGitConfig.RefSpec(commit + ":FETCH_HEAD")}, + Tags: git.NoTags, + }) + if err != nil { + t.Fatal(err) + } + f, err := gitfs.CommitHash(repo, commit) + if err != nil { + t.Fatal(err) + } + + t.Run("Stat", func(t *testing.T) { + fi, err := fs.Stat(f, "validate") + if err != nil { + t.Fatal(err) + } + if fi.Mode().IsRegular() { + t.Fatal("validate should not be a regular file") + } + if !fi.IsDir() { + t.Fatal("validate should be a directory but isn't") + } + }) + t.Run("ReadDir", func(t *testing.T) { + entries, err := fs.ReadDir(f, "validate") + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("validate should have 0 entries, not %d\n\n%#v", len(entries), entries) + } + }) + + // might as well run fstest again, now that we have a new filesystem tree 😅 + t.Run("fstest.TestFS", func(t *testing.T) { + if err := fstest.TestFS(f, "Dockerfile"); err != nil { + t.Fatal(err) + } + }) + t.Run("Sub+fstest.TestFS", func(t *testing.T) { + sub, err := fs.Sub(f, "validate") + if err != nil { + t.Fatal(err) + } + // "As a special case, if no expected files are listed, fsys must be empty." + if err := fstest.TestFS(sub); err != nil { + t.Fatal(err) + } + }) +}