180 lines
5.0 KiB
Go
180 lines
5.0 KiB
Go
package gitfs
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
goGit "github.com/go-git/go-git/v5"
|
|
goGitPlumbing "github.com/go-git/go-git/v5/plumbing"
|
|
goGitPlumbingFileMode "github.com/go-git/go-git/v5/plumbing/filemode"
|
|
goGitPlumbingObject "github.com/go-git/go-git/v5/plumbing/object"
|
|
)
|
|
|
|
// https://github.com/go-git/go-git/issues/296
|
|
|
|
// TODO something more clever for directories
|
|
|
|
func CommitHash(repo *goGit.Repository, commit string) (fs.FS, error) {
|
|
gitCommit, err := repo.CommitObject(goGitPlumbing.NewHash(commit))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gitFS{
|
|
commit: gitCommit,
|
|
}, nil
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#FS
|
|
type gitFS struct {
|
|
commit *goGitPlumbingObject.Commit
|
|
}
|
|
|
|
// apparently symlinks in "io/fs" are still a big TODO (https://github.com/golang/go/issues/49580, https://github.com/golang/go/issues/45470, etc related issues); all the existing interfaces assume symlinks don't exist
|
|
//
|
|
// if the File object passed to this function represents a symlink, this returns the (resolved) path that should be looked up instead; only relative symlinks are supported (and attempts to escape the repository with too many "../" *should* result in an error -- this is a convenience/sanity check, not a security boundary; subset of https://pkg.go.dev/io/fs#ValidPath)
|
|
//
|
|
// otherwise, it will return the empty string and nil
|
|
func resolveSymlink(f *goGitPlumbingObject.File) (target string, err error) {
|
|
if f.Mode != goGitPlumbingFileMode.Symlink {
|
|
return "", nil
|
|
}
|
|
|
|
target, err = f.Contents()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if target == "" {
|
|
return "", fmt.Errorf("unexpected: empty symlink %q", f.Name)
|
|
}
|
|
|
|
// we *could* implement this as absolute symlinks being relative to the root of the Git repository, but that wouldn't match the behavior of a normal repository that's been "git clone"'d on disk, so I think that would be a mistake and erroring out is saner here
|
|
if path.IsAbs(target) {
|
|
return "", fmt.Errorf("unsupported: %q is an absolute symlink (%q)", f.Name, target)
|
|
}
|
|
|
|
// symlinks are relative to the path they're in, so we need to prepend that
|
|
target = path.Join(path.Dir(f.Name), target)
|
|
|
|
// now let's use path.Clean to get rid of any excess ".." or "." entries in our end result
|
|
target = path.Clean(target)
|
|
|
|
// once we're cleaned, we should have a full path that's relative to the root of the Git repository, so if it still starts with "../", that's a problem that will error later when we try to read it, so let's error out now to bail earlier
|
|
if strings.HasPrefix(target, "../") {
|
|
return "", fmt.Errorf("unsupported: %q is a relative symlink outside the tree (%q)", f.Name, target)
|
|
}
|
|
|
|
return target, nil
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#FS
|
|
func (fs gitFS) Open(name string) (fs.File, error) {
|
|
f, err := fs.commit.File(name)
|
|
if err != nil {
|
|
// TODO if it's file-not-found, we need to check whether it's a directory
|
|
return nil, err
|
|
}
|
|
|
|
if target, err := resolveSymlink(f); err != nil {
|
|
return nil, err
|
|
} else if target != "" {
|
|
return fs.Open(target)
|
|
}
|
|
|
|
reader, err := f.Reader()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return gitFSFile{
|
|
stat: gitFSFileInfo{
|
|
file: f,
|
|
},
|
|
reader: reader,
|
|
}, nil
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#StatFS
|
|
func (fs gitFS) Stat(name string) (fs.FileInfo, error) {
|
|
f, err := fs.commit.File(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if target, err := resolveSymlink(f); err != nil {
|
|
return nil, err
|
|
} else if target != "" {
|
|
return fs.Stat(target)
|
|
}
|
|
|
|
return gitFSFileInfo{
|
|
file: f,
|
|
}, nil
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#File
|
|
type gitFSFile struct {
|
|
stat fs.FileInfo
|
|
reader io.ReadCloser
|
|
}
|
|
|
|
func (f gitFSFile) Stat() (fs.FileInfo, error) {
|
|
return f.stat, nil
|
|
}
|
|
func (f gitFSFile) Read(b []byte) (int, error) {
|
|
return f.reader.Read(b)
|
|
}
|
|
func (f gitFSFile) Close() error {
|
|
return f.reader.Close()
|
|
}
|
|
|
|
type gitFSFileInfo struct {
|
|
file *goGitPlumbingObject.File
|
|
}
|
|
|
|
// base name of the file
|
|
func (fi gitFSFileInfo) Name() string {
|
|
return path.Base(fi.file.Name)
|
|
}
|
|
|
|
// length in bytes for regular files; system-dependent for others
|
|
func (fi gitFSFileInfo) Size() int64 {
|
|
return fi.file.Size
|
|
}
|
|
|
|
// file mode bits
|
|
func (fi gitFSFileInfo) Mode() fs.FileMode {
|
|
// https://pkg.go.dev/github.com/go-git/go-git/v5@v5.4.2/plumbing/filemode#FileMode
|
|
// https://pkg.go.dev/io/fs#FileMode
|
|
switch fi.file.Mode {
|
|
case goGitPlumbingFileMode.Regular:
|
|
return 0644
|
|
case goGitPlumbingFileMode.Symlink:
|
|
return 0644 | fs.ModeSymlink
|
|
case goGitPlumbingFileMode.Executable:
|
|
return 0755
|
|
case goGitPlumbingFileMode.Dir:
|
|
return 0755 | fs.ModeDir
|
|
}
|
|
return 0 | fs.ModeIrregular // TODO what to do for files whose types we don't support? 😬
|
|
}
|
|
|
|
// modification time
|
|
func (fi gitFSFileInfo) ModTime() time.Time {
|
|
return time.Time{} // TODO maybe pass down whichever is more recent of commit.Author.When vs commit.Committer.When ?
|
|
}
|
|
|
|
// abbreviation for Mode().IsDir()
|
|
func (fi gitFSFileInfo) IsDir() bool {
|
|
return fi.file.Mode == goGitPlumbingFileMode.Dir
|
|
}
|
|
|
|
// underlying data source (can return nil)
|
|
func (fi gitFSFileInfo) Sys() interface{} {
|
|
return fi.file
|
|
}
|