ref: 8d555a5aae15017c3c3332605bdf4fd33e20aaa0
parent: f6a087d759e7b5319432bc79cd3941d1bd3639c9
author: Runxi Yu <runxiyu@umich.edu>
date: Mon Mar 23 02:06:53 EDT 2026
object/resolve: Add TreeFS
--- /dev/null
+++ b/object/resolve/treefs.go
@@ -1,0 +1,30 @@
+package resolve
+
+import (
+ "io/fs"
+
+ "codeberg.org/lindenii/furgit/object"
+ "codeberg.org/lindenii/furgit/objectid"
+)
+
+// TreeFS exposes one Git tree as an fs.FS.
+//
+// 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.
+//
+// TreeFS does not take ownership of its Resolver.
+type TreeFS struct {+ resolver *Resolver
+ rootTree objectid.ObjectID
+ rootEntry *object.TreeEntry
+}
+
+var (
+ _ fs.FS = (*TreeFS)(nil)
+ _ fs.ReadFileFS = (*TreeFS)(nil)
+ _ fs.ReadDirFS = (*TreeFS)(nil)
+ _ fs.StatFS = (*TreeFS)(nil)
+ _ fs.SubFS = (*TreeFS)(nil)
+)
--- /dev/null
+++ b/object/resolve/treefs_entry.go
@@ -1,0 +1,82 @@
+package resolve
+
+import (
+ "fmt"
+ "io/fs"
+ "strings"
+
+ "codeberg.org/lindenii/furgit/object"
+ "codeberg.org/lindenii/furgit/objectid"
+)
+
+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: object.FileModeDir,
+ treeID: treeFS.rootTree,
+ treeEntry: treeFS.rootEntry,
+ }, nil
+ }
+
+ entry, err := treeFS.resolver.Path(treeFS.rootTree, treeFSSplitPath(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 err != nil && strings.Contains(err.Error(), "not found") {+ return treeFSPathError(op, name, fs.ErrNotExist)
+ }
+
+ if err != nil && strings.Contains(err.Error(), "is not a tree") {+ return treeFSPathError(op, name, fs.ErrInvalid)
+ }
+
+ return treeFSPathError(op, name, err)
+}
+
+type treeEntryValue struct {+ name string
+ mode object.FileMode
+ objectID objectid.ObjectID
+ treeID objectid.ObjectID
+ treeEntry *object.TreeEntry
+}
+
+func (entry treeEntryValue) isDir() bool {+ return entry.mode == object.FileModeDir
+}
+
+func (entry treeEntryValue) blobSize(resolve *Resolver) (int64, error) {+ _, size, err := resolve.store.ReadHeader(entry.objectID)
+ if err != nil {+ return 0, err
+ }
+
+ return size, nil
+}
+
+func (entry treeEntryValue) subtreeID() (objectid.ObjectID, error) {+ if entry.name == "." {+ return entry.treeID, nil
+ }
+
+ if entry.mode != object.FileModeDir {+ return objectid.ObjectID{}, fmt.Errorf("object/resolve: path %q is not a tree", entry.name)+ }
+
+ return entry.objectID, nil
+}
--- /dev/null
+++ b/object/resolve/treefs_info.go
@@ -1,0 +1,73 @@
+package resolve
+
+import (
+ "io/fs"
+ "time"
+
+ "codeberg.org/lindenii/furgit/object"
+)
+
+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(mode object.FileMode) fs.FileMode {+ switch mode {+ case object.FileModeDir:
+ return fs.ModeDir | 0o555
+ case object.FileModeRegular:
+ return 0o444
+ case object.FileModeExecutable:
+ return 0o555
+ case object.FileModeSymlink:
+ return fs.ModeSymlink | 0o444
+ case object.FileModeGitlink:
+ return fs.ModeIrregular
+ default:
+ return fs.ModeIrregular
+ }
+}
+
+func (treeFS *TreeFS) statEntry(entry treeEntryValue) (*treeFSInfo, error) {+ size := int64(0)
+ if entry.mode == object.FileModeRegular || entry.mode == object.FileModeExecutable || entry.mode == object.FileModeSymlink {+ var err error
+ size, err = entry.blobSize(treeFS.resolver)
+ if err != nil {+ return nil, err
+ }
+ }
+
+ 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
+}
--- /dev/null
+++ b/object/resolve/treefs_new.go
@@ -1,0 +1,17 @@
+package resolve
+
+import "codeberg.org/lindenii/furgit/objectid"
+
+// TreeFS returns one new filesystem view rooted at root, which may be any
+// tree-ish object accepted by PeelToTreeID.
+func (r *Resolver) TreeFS(root objectid.ObjectID) (*TreeFS, error) {+ rootTree, err := r.PeelToTreeID(root)
+ if err != nil {+ return nil, err
+ }
+
+ return &TreeFS{+ resolver: r,
+ rootTree: rootTree,
+ }, nil
+}
--- /dev/null
+++ b/object/resolve/treefs_op.go
@@ -1,0 +1,28 @@
+package resolve
+
+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"
+ }
+}
--- /dev/null
+++ b/object/resolve/treefs_open.go
@@ -1,0 +1,121 @@
+package resolve
+
+import (
+ "fmt"
+ "io"
+ "io/fs"
+
+ "codeberg.org/lindenii/furgit/object"
+)
+
+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.resolver.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 == object.FileModeGitlink {+ return nil, treeFSPathError(treeFSOpOpen, name, fmt.Errorf("object/resolve: gitlink entries are not readable as files"))+ }
+
+ reader, _, err := treeFS.resolver.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 := dir.offset + n
+ if end > len(dir.entries) {+ end = len(dir.entries)
+ }
+
+ out := append([]fs.DirEntry(nil), dir.entries[dir.offset:end]...)
+ dir.offset = end
+
+ return out, nil
+}
--- /dev/null
+++ b/object/resolve/treefs_path.go
@@ -1,0 +1,28 @@
+package resolve
+
+import (
+ "io/fs"
+ "strings"
+)
+
+func treeFSValidPath(name string) bool {+ return name == "." || fs.ValidPath(name)
+}
+
+func treeFSSplitPath(name string) [][]byte {+ if name == "." {+ return nil
+ }
+
+ parts := strings.Split(name, "/")
+ out := make([][]byte, len(parts))
+ for i, part := range parts {+ out[i] = []byte(part)
+ }
+
+ return out
+}
+
+func treeFSPathError(op treeFSOp, path string, err error) error {+ return &fs.PathError{Op: op.pathErrorOp(), Path: path, Err: err}+}
--- /dev/null
+++ b/object/resolve/treefs_readdir.go
@@ -1,0 +1,19 @@
+package resolve
+
+import "io/fs"
+
+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)
+ }
+
+ return readDirFile.ReadDir(-1)
+}
--- /dev/null
+++ b/object/resolve/treefs_readfile.go
@@ -1,0 +1,37 @@
+package resolve
+
+import (
+ "fmt"
+ "io"
+
+ "codeberg.org/lindenii/furgit/object"
+)
+
+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, fmt.Errorf("is a directory"))+ }
+
+ if entry.mode == object.FileModeGitlink {+ return nil, treeFSPathError(treeFSOpReadFile, name, fmt.Errorf("object/resolve: gitlink entries are not readable as files"))+ }
+
+ reader, _, err := treeFS.resolver.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
+}
--- /dev/null
+++ b/object/resolve/treefs_stat.go
@@ -1,0 +1,17 @@
+package resolve
+
+import "io/fs"
+
+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
+}
--- /dev/null
+++ b/object/resolve/treefs_sub.go
@@ -1,0 +1,21 @@
+package resolve
+
+import "io/fs"
+
+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{+ resolver: treeFS.resolver,
+ rootTree: treeID,
+ rootEntry: entry.treeEntry,
+ }, nil
+}
--- /dev/null
+++ b/object/resolve/treefs_test.go
@@ -1,0 +1,109 @@
+package resolve_test
+
+import (
+ "errors"
+ "io/fs"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ "codeberg.org/lindenii/furgit/object"
+ "codeberg.org/lindenii/furgit/object/resolve"
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/repository"
+)
+
+func TestTreeFS(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ repoData := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})+ repoData.WriteFile(t, "plain.txt", []byte("plain\n"), 0o644)+ repoData.WriteFileAll(t, "dir/exec.sh", []byte("#!/bin/sh\nexit 0\n"), 0o755, 0o755)+ repoData.SymbolicRef(t, "HEAD", "refs/heads/main")
+ _ = repoData.Run(t, "add", ".")
+ treeHex := repoData.Run(t, "write-tree")
+ treeID, err := objectid.ParseHex(algo, treeHex)
+ if err != nil {+ t.Fatalf("ParseHex(write-tree): %v", err)+ }
+
+ commitID := repoData.CommitTree(t, treeID, "treefs")
+
+ root := repoData.OpenGitRoot(t)
+
+ repo, err := repository.Open(root)
+ if err != nil {+ t.Fatalf("repository.Open: %v", err)+ }
+ defer func() { _ = repo.Close() }()+
+ resolver := resolve.New(repo.Objects())
+ treeFS, err := resolver.TreeFS(commitID)
+ if err != nil {+ t.Fatalf("resolver.TreeFS: %v", err)+ }
+
+ content, err := treeFS.ReadFile("plain.txt")+ if err != nil {+ t.Fatalf("ReadFile(plain.txt): %v", err)+ }
+
+ if string(content) != "plain\n" {+ t.Fatalf("ReadFile(plain.txt) = %q, want %q", string(content), "plain\n")+ }
+
+ entries, err := treeFS.ReadDir(".")+ if err != nil {+ t.Fatalf("ReadDir(.): %v", err)+ }
+
+ if len(entries) != 2 {+ t.Fatalf("len(ReadDir(.)) = %d, want 2", len(entries))+ }
+
+ info, err := treeFS.Stat("plain.txt")+ if err != nil {+ t.Fatalf("Stat(plain.txt): %v", err)+ }
+
+ entry, ok := info.Sys().(object.TreeEntry)
+ if !ok {+ t.Fatalf("Stat(plain.txt).Sys() type = %T, want object.TreeEntry", info.Sys())+ }
+
+ if entry.Mode != object.FileModeRegular {+ t.Fatalf("Stat(plain.txt).Sys().Mode = %o, want %o", entry.Mode, object.FileModeRegular)+ }
+
+ subFS, err := treeFS.Sub("dir")+ if err != nil {+ t.Fatalf("Sub(dir): %v", err)+ }
+
+ subReadFileFS, ok := subFS.(fs.ReadFileFS)
+ if !ok {+ t.Fatalf("Sub(dir) type does not implement fs.ReadFileFS")+ }
+
+ subContent, err := subReadFileFS.ReadFile("exec.sh")+ if err != nil {+ t.Fatalf("Sub(dir).ReadFile(exec.sh): %v", err)+ }
+
+ if string(subContent) != "#!/bin/sh\nexit 0\n" {+ t.Fatalf("Sub(dir).ReadFile(exec.sh) = %q", string(subContent))+ }
+
+ _, err = treeFS.ReadFile("dir")+ if err == nil {+ t.Fatal("ReadFile(dir) unexpectedly succeeded")+ }
+
+ var pathErr *fs.PathError
+ if !errors.As(err, &pathErr) {+ t.Fatalf("ReadFile(dir) err type = %T, want *fs.PathError", err)+ }
+ })
+}
--
⑨