shithub: furgit

Download patch

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)
+		}
+	})
+}
--