aboutsummaryrefslogtreecommitdiff
path: root/object/fetch/treefs.go
diff options
context:
space:
mode:
Diffstat (limited to 'object/fetch/treefs.go')
-rw-r--r--object/fetch/treefs.go451
1 files changed, 451 insertions, 0 deletions
diff --git a/object/fetch/treefs.go b/object/fetch/treefs.go
new file mode 100644
index 00000000..d12e3dd6
--- /dev/null
+++ b/object/fetch/treefs.go
@@ -0,0 +1,451 @@
+package fetch
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "time"
+
+ oid "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/tree"
+ "lindenii.org/go/furgit/object/tree/mode"
+)
+
+// TreeFS exposes one Git tree as an fs.FS view backed by a Fetcher.
+//
+// TreeFS interprets names using io/fs path rules.
+// Those rules do not match raw Git tree entry naming exactly:
+// names are UTF-8, slash-separated, and must be valid fs.FS paths.
+// Tree entries that cannot be represented under those rules
+// are not addressable through this API.
+//
+// Labels: MT-Safe.
+type TreeFS struct {
+ fetcher *Fetcher
+ rootTree oid.ObjectID
+ rootEntry *tree.Entry
+}
+
+var (
+ _ fs.FS = (*TreeFS)(nil)
+ _ fs.ReadFileFS = (*TreeFS)(nil)
+ _ fs.ReadDirFS = (*TreeFS)(nil)
+ _ fs.StatFS = (*TreeFS)(nil)
+ _ fs.SubFS = (*TreeFS)(nil)
+)
+
+// ErrGitlinkNotFile reports that
+// a gitlink (submodule) entry was opened or read as a file.
+// It wraps [fs.ErrInvalid] so that
+// generic fs consumers classify it correctly.
+var ErrGitlinkNotFile = fmt.Errorf("%w: object/fetch: gitlink entries are not readable as files", fs.ErrInvalid)
+
+// ErrIsDirectory reports that
+// a directory entry was read as a file.
+// It wraps [fs.ErrInvalid] so that
+// generic fs consumers classify it correctly.
+var ErrIsDirectory = fmt.Errorf("%w: object/fetch: is a directory", fs.ErrInvalid)
+
+func splitPath(path string) [][]byte {
+ if len(path) == 0 {
+ return nil
+ }
+
+ return bytes.Split([]byte(path), []byte("/"))
+}
+
+type treeEntryValue struct {
+ name string
+ mode mode.Mode
+ objectID oid.ObjectID
+ treeID oid.ObjectID
+ treeEntry *tree.Entry
+}
+
+func (entry treeEntryValue) isDir() bool {
+ return entry.mode == mode.Directory
+}
+
+func (entry treeEntryValue) blobSize(fetcher *Fetcher) (int, error) {
+ return fetcher.Size(entry.objectID)
+}
+
+func (entry treeEntryValue) subtreeID() (oid.ObjectID, error) {
+ if entry.name == "." {
+ return entry.treeID, nil
+ }
+
+ if entry.mode != mode.Directory {
+ return oid.ObjectID{}, fmt.Errorf("object/fetch: path %q is not a tree", entry.name) //nolint:err113 // Never user-visible.
+ }
+
+ return entry.objectID, nil
+}
+
+type treeFSInfo struct {
+ name string
+ mode fs.FileMode
+ size int64
+ sys any
+ isDir bool
+}
+
+var (
+ _ fs.FileInfo = (*treeFSInfo)(nil)
+ _ fs.DirEntry = (*treeFSInfo)(nil)
+)
+
+func (info *treeFSInfo) Name() string { return info.name }
+func (info *treeFSInfo) Size() int64 { return info.size }
+func (info *treeFSInfo) Mode() fs.FileMode { return info.mode }
+func (info *treeFSInfo) Type() fs.FileMode { return info.mode.Type() }
+func (info *treeFSInfo) IsDir() bool { return info.isDir }
+func (info *treeFSInfo) ModTime() time.Time { return time.Time{} }
+func (info *treeFSInfo) Sys() any { return info.sys }
+func (info *treeFSInfo) Info() (fs.FileInfo, error) {
+ return info, nil
+}
+
+func treeFSEntryMode(mod mode.Mode) fs.FileMode {
+ switch mod {
+ case mode.Directory:
+ return fs.ModeDir | 0o555
+ case mode.Regular:
+ return 0o444
+ case mode.Executable:
+ return 0o555
+ case mode.Symlink:
+ return fs.ModeSymlink | 0o444
+ case mode.Gitlink:
+ return fs.ModeIrregular
+ default:
+ return fs.ModeIrregular
+ }
+}
+
+// TreeFS returns a new filesystem view rooted at root, which may be any
+// tree-ish object accepted by PeelToTreeID.
+//
+// Labels: Deps-Borrowed, Life-Parent.
+func (fetcher *Fetcher) TreeFS(root oid.ObjectID) (*TreeFS, error) {
+ rootTree, err := fetcher.PeelToTreeID(root)
+ if err != nil {
+ return nil, err
+ }
+
+ return &TreeFS{
+ fetcher: fetcher,
+ rootTree: rootTree,
+ }, nil
+}
+
+type treeFSOp uint8
+
+const (
+ treeFSOpOpen treeFSOp = iota
+ treeFSOpReadFile
+ treeFSOpReadDir
+ treeFSOpStat
+ treeFSOpSub
+)
+
+func (op treeFSOp) pathErrorOp() string {
+ switch op {
+ case treeFSOpOpen:
+ return "open"
+ case treeFSOpReadFile:
+ return "readfile"
+ case treeFSOpReadDir:
+ return "readdir"
+ case treeFSOpStat:
+ return "stat"
+ case treeFSOpSub:
+ return "sub"
+ default:
+ return "treefs"
+ }
+}
+
+// Open opens name for reading.
+//
+// Directories are returned as fs.ReadDirFile values. Gitlink entries are not
+// readable through TreeFS.
+func (treeFS *TreeFS) Open(name string) (fs.File, error) {
+ entry, err := treeFS.resolvePath(treeFSOpOpen, name)
+ if err != nil {
+ return nil, err
+ }
+
+ info, err := treeFS.statEntry(entry)
+ if err != nil {
+ return nil, treeFSPathError(treeFSOpOpen, name, err)
+ }
+
+ if entry.isDir() {
+ treeID, err := entry.subtreeID()
+ if err != nil {
+ return nil, treeFSPathError(treeFSOpOpen, name, err)
+ }
+
+ tree, err := treeFS.fetcher.ExactTree(treeID)
+ if err != nil {
+ return nil, treeFSPathError(treeFSOpOpen, name, err)
+ }
+
+ entries := make([]fs.DirEntry, 0, len(tree.Object().Entries()))
+ for _, child := range tree.Object().Entries() {
+ childEntry := treeEntryValue{
+ name: string(child.Name),
+ mode: child.Mode,
+ objectID: child.ID,
+ treeEntry: &child,
+ }
+
+ childInfo, err := treeFS.statEntry(childEntry)
+ if err != nil {
+ return nil, treeFSPathError(treeFSOpOpen, name, err)
+ }
+
+ entries = append(entries, childInfo)
+ }
+
+ return &treeFSDir{
+ info: info,
+ entries: entries,
+ }, nil
+ }
+
+ if entry.mode == mode.Gitlink {
+ return nil, treeFSPathError(treeFSOpOpen, name, ErrGitlinkNotFile)
+ }
+
+ reader, _, err := treeFS.fetcher.ExactBlobReader(entry.objectID)
+ if err != nil {
+ return nil, treeFSPathError(treeFSOpOpen, name, err)
+ }
+
+ return &treeFSBlob{
+ info: info,
+ reader: reader,
+ }, nil
+}
+
+type treeFSBlob struct {
+ info *treeFSInfo
+ reader io.ReadCloser
+}
+
+var _ fs.File = (*treeFSBlob)(nil)
+
+func (file *treeFSBlob) Stat() (fs.FileInfo, error) { return file.info, nil }
+func (file *treeFSBlob) Read(p []byte) (int, error) { return file.reader.Read(p) }
+func (file *treeFSBlob) Close() error { return file.reader.Close() }
+
+type treeFSDir struct {
+ info *treeFSInfo
+ entries []fs.DirEntry
+ offset int
+}
+
+var (
+ _ fs.File = (*treeFSDir)(nil)
+ _ fs.ReadDirFile = (*treeFSDir)(nil)
+)
+
+func (dir *treeFSDir) Stat() (fs.FileInfo, error) { return dir.info, nil }
+func (dir *treeFSDir) Close() error { return nil }
+
+func (dir *treeFSDir) Read(_ []byte) (int, error) {
+ return 0, fs.ErrInvalid
+}
+
+func (dir *treeFSDir) ReadDir(n int) ([]fs.DirEntry, error) {
+ if dir.offset >= len(dir.entries) && n > 0 {
+ return nil, io.EOF
+ }
+
+ if n <= 0 {
+ out := append([]fs.DirEntry(nil), dir.entries[dir.offset:]...)
+ dir.offset = len(dir.entries)
+
+ return out, nil
+ }
+
+ end := min(dir.offset+n, len(dir.entries))
+
+ out := append([]fs.DirEntry(nil), dir.entries[dir.offset:end]...)
+ dir.offset = end
+
+ return out, nil
+}
+
+func treeFSValidPath(name string) bool {
+ return name == "." || fs.ValidPath(name)
+}
+
+func treeFSPathError(op treeFSOp, path string, err error) error {
+ return &fs.PathError{Op: op.pathErrorOp(), Path: path, Err: err}
+}
+
+// ReadDir reads and returns all directory entries for name.
+func (treeFS *TreeFS) ReadDir(name string) ([]fs.DirEntry, error) {
+ file, err := treeFS.Open(name)
+ if err != nil {
+ return nil, err
+ }
+
+ defer func() { _ = file.Close() }()
+
+ readDirFile, ok := file.(fs.ReadDirFile)
+ if !ok {
+ return nil, treeFSPathError(treeFSOpReadDir, name, fs.ErrInvalid)
+ }
+
+ entries, err := readDirFile.ReadDir(-1)
+ if err != nil {
+ return nil, treeFSPathError(treeFSOpReadDir, name, err)
+ }
+
+ return entries, nil
+}
+
+// ReadFile reads the blob contents at name.
+//
+// Directories and gitlink entries are not readable through TreeFS.
+func (treeFS *TreeFS) ReadFile(name string) ([]byte, error) {
+ entry, err := treeFS.resolvePath(treeFSOpReadFile, name)
+ if err != nil {
+ return nil, err
+ }
+
+ if entry.isDir() {
+ return nil, treeFSPathError(treeFSOpReadFile, name, ErrIsDirectory)
+ }
+
+ if entry.mode == mode.Gitlink {
+ return nil, treeFSPathError(treeFSOpReadFile, name, ErrGitlinkNotFile)
+ }
+
+ reader, _, err := treeFS.fetcher.ExactBlobReader(entry.objectID)
+ if err != nil {
+ return nil, treeFSPathError(treeFSOpReadFile, name, err)
+ }
+
+ defer func() { _ = reader.Close() }()
+
+ data, err := io.ReadAll(reader)
+ if err != nil {
+ return nil, treeFSPathError(treeFSOpReadFile, name, err)
+ }
+
+ return data, nil
+}
+
+// Stat returns synthetic file metadata for name.
+//
+// TreeFS metadata reflects Git tree entry mode and blob size where applicable.
+// It does not represent filesystem stat metadata: ModTime is zero, ownership is
+// unavailable, and Sys returns the underlying tree.Entry when one exists.
+func (treeFS *TreeFS) Stat(name string) (fs.FileInfo, error) {
+ entry, err := treeFS.resolvePath(treeFSOpStat, name)
+ if err != nil {
+ return nil, err
+ }
+
+ info, err := treeFS.statEntry(entry)
+ if err != nil {
+ return nil, treeFSPathError(treeFSOpStat, name, err)
+ }
+
+ return info, nil
+}
+
+// Sub returns a new TreeFS rooted at dir.
+func (treeFS *TreeFS) Sub(dir string) (fs.FS, error) {
+ entry, err := treeFS.resolvePath(treeFSOpSub, dir)
+ if err != nil {
+ return nil, err
+ }
+
+ treeID, err := entry.subtreeID()
+ if err != nil {
+ return nil, treeFSPathError(treeFSOpSub, dir, fs.ErrInvalid)
+ }
+
+ return &TreeFS{
+ fetcher: treeFS.fetcher,
+ rootTree: treeID,
+ rootEntry: entry.treeEntry,
+ }, nil
+}
+
+func (treeFS *TreeFS) resolvePath(op treeFSOp, name string) (treeEntryValue, error) {
+ if !treeFSValidPath(name) {
+ return treeEntryValue{}, treeFSPathError(op, name, fs.ErrInvalid)
+ }
+
+ if name == "." {
+ return treeEntryValue{
+ name: ".",
+ mode: mode.Directory,
+ treeID: treeFS.rootTree,
+ treeEntry: treeFS.rootEntry,
+ }, nil
+ }
+
+ entry, err := treeFS.fetcher.Path(treeFS.rootTree, splitPath(name))
+ if err != nil {
+ return treeEntryValue{}, treeFS.pathResolveError(op, name, err)
+ }
+
+ return treeEntryValue{
+ name: string(entry.Name),
+ mode: entry.Mode,
+ objectID: entry.ID,
+ treeEntry: &entry,
+ }, nil
+}
+
+func (treeFS *TreeFS) pathResolveError(op treeFSOp, name string, err error) error {
+ if _, ok := errors.AsType[*PathNotFoundError](err); ok {
+ return treeFSPathError(op, name, fs.ErrNotExist)
+ }
+
+ if _, ok := errors.AsType[*PathNotTreeError](err); ok {
+ return treeFSPathError(op, name, fs.ErrInvalid)
+ }
+
+ if errors.Is(err, ErrPathInvalid) {
+ return treeFSPathError(op, name, fs.ErrInvalid)
+ }
+
+ return treeFSPathError(op, name, err)
+}
+
+func (treeFS *TreeFS) statEntry(entry treeEntryValue) (*treeFSInfo, error) {
+ size := int64(0)
+
+ if entry.mode == mode.Regular || entry.mode == mode.Executable || entry.mode == mode.Symlink {
+ sz, err := entry.blobSize(treeFS.fetcher)
+ if err != nil {
+ return nil, err
+ }
+
+ size = int64(sz)
+ }
+
+ var sys any
+ if entry.treeEntry != nil {
+ sys = *entry.treeEntry
+ }
+
+ return &treeFSInfo{
+ name: entry.name,
+ mode: treeFSEntryMode(entry.mode),
+ size: size,
+ sys: sys,
+ isDir: entry.isDir(),
+ }, nil
+}