aboutsummaryrefslogtreecommitdiff
path: root/object/fetch
diff options
context:
space:
mode:
Diffstat (limited to 'object/fetch')
-rw-r--r--object/fetch/doc.go5
-rw-r--r--object/fetch/exact_blob.go24
-rw-r--r--object/fetch/exact_blob_reader.go14
-rw-r--r--object/fetch/exact_commit.go24
-rw-r--r--object/fetch/exact_commit_reader.go16
-rw-r--r--object/fetch/exact_object.go18
-rw-r--r--object/fetch/exact_reader.go26
-rw-r--r--object/fetch/exact_tag.go24
-rw-r--r--object/fetch/exact_tag_reader.go16
-rw-r--r--object/fetch/exact_tree.go24
-rw-r--r--object/fetch/exact_tree_reader.go16
-rw-r--r--object/fetch/fetcher.go17
-rw-r--r--object/fetch/object_parse.go28
-rw-r--r--object/fetch/path.go103
-rw-r--r--object/fetch/peel_to_blob.go29
-rw-r--r--object/fetch/peel_to_blob_id.go39
-rw-r--r--object/fetch/peel_to_blob_reader.go18
-rw-r--r--object/fetch/peel_to_commit.go29
-rw-r--r--object/fetch/peel_to_commit_id.go39
-rw-r--r--object/fetch/peel_to_commit_reader.go20
-rw-r--r--object/fetch/peel_to_tag.go12
-rw-r--r--object/fetch/peel_to_tag_id.go8
-rw-r--r--object/fetch/peel_to_tag_reader.go20
-rw-r--r--object/fetch/peel_to_tree.go33
-rw-r--r--object/fetch/peel_to_tree_id.go46
-rw-r--r--object/fetch/peel_to_tree_reader.go20
-rw-r--r--object/fetch/treefs.go30
-rw-r--r--object/fetch/treefs_entry.go90
-rw-r--r--object/fetch/treefs_info.go75
-rw-r--r--object/fetch/treefs_new.go17
-rw-r--r--object/fetch/treefs_op.go28
-rw-r--r--object/fetch/treefs_open.go122
-rw-r--r--object/fetch/treefs_path.go29
-rw-r--r--object/fetch/treefs_readdir.go20
-rw-r--r--object/fetch/treefs_readfile.go40
-rw-r--r--object/fetch/treefs_stat.go22
-rw-r--r--object/fetch/treefs_sub.go22
-rw-r--r--object/fetch/treefs_test.go111
38 files changed, 1274 insertions, 0 deletions
diff --git a/object/fetch/doc.go b/object/fetch/doc.go
new file mode 100644
index 00000000..e5ca33a9
--- /dev/null
+++ b/object/fetch/doc.go
@@ -0,0 +1,5 @@
+// Package resolve resolves stored Git objects by exact type, by peeling
+// tree-ish or commit-ish references, and by path within trees.
+//
+// A Fetcher does not take ownership of the underlying object store.
+package fetch
diff --git a/object/fetch/exact_blob.go b/object/fetch/exact_blob.go
new file mode 100644
index 00000000..96ededdc
--- /dev/null
+++ b/object/fetch/exact_blob.go
@@ -0,0 +1,24 @@
+package fetch
+
+import (
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/object/blob"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/stored"
+)
+
+// ExactBlob reads, parses, and wraps the blob at id.
+func (r *Fetcher) ExactBlob(id objectid.ObjectID) (*stored.Stored[*blob.Blob], error) {
+ parsed, err := r.parseObject(id)
+ if err != nil {
+ return nil, err
+ }
+
+ blob, ok := parsed.(*blob.Blob)
+ if !ok {
+ return nil, fmt.Errorf("object/fetch: expected blob object %s, got %v", id, parsed.ObjectType())
+ }
+
+ return stored.New(id, blob), nil
+}
diff --git a/object/fetch/exact_blob_reader.go b/object/fetch/exact_blob_reader.go
new file mode 100644
index 00000000..95f5e1aa
--- /dev/null
+++ b/object/fetch/exact_blob_reader.go
@@ -0,0 +1,14 @@
+package fetch
+
+import (
+ "io"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ objecttype "codeberg.org/lindenii/furgit/object/type"
+)
+
+// ExactBlobReader returns a reader for the content of the blob at id,
+// together with its content size in bytes.
+func (r *Fetcher) ExactBlobReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+ return r.exactReader(id, objecttype.TypeBlob, "blob")
+}
diff --git a/object/fetch/exact_commit.go b/object/fetch/exact_commit.go
new file mode 100644
index 00000000..d3f9dee1
--- /dev/null
+++ b/object/fetch/exact_commit.go
@@ -0,0 +1,24 @@
+package fetch
+
+import (
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/object/commit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/stored"
+)
+
+// ExactCommit reads, parses, and wraps the commit at id.
+func (r *Fetcher) ExactCommit(id objectid.ObjectID) (*stored.Stored[*commit.Commit], error) {
+ parsed, err := r.parseObject(id)
+ if err != nil {
+ return nil, err
+ }
+
+ commit, ok := parsed.(*commit.Commit)
+ if !ok {
+ return nil, fmt.Errorf("object/fetch: expected commit object %s, got %v", id, parsed.ObjectType())
+ }
+
+ return stored.New(id, commit), nil
+}
diff --git a/object/fetch/exact_commit_reader.go b/object/fetch/exact_commit_reader.go
new file mode 100644
index 00000000..5ccae4b1
--- /dev/null
+++ b/object/fetch/exact_commit_reader.go
@@ -0,0 +1,16 @@
+package fetch
+
+import (
+ "io"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ objecttype "codeberg.org/lindenii/furgit/object/type"
+)
+
+// ExactCommitReader returns a reader for the content of the commit at id,
+// together with its content size in bytes.
+//
+// Usage of this method is unusual.
+func (r *Fetcher) ExactCommitReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+ return r.exactReader(id, objecttype.TypeCommit, "commit")
+}
diff --git a/object/fetch/exact_object.go b/object/fetch/exact_object.go
new file mode 100644
index 00000000..869d811f
--- /dev/null
+++ b/object/fetch/exact_object.go
@@ -0,0 +1,18 @@
+package fetch
+
+import (
+ "codeberg.org/lindenii/furgit/object"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/stored"
+)
+
+// ExactObject reads, parses, and wraps the object at id without constraining
+// its concrete object kind.
+func (r *Fetcher) ExactObject(id objectid.ObjectID) (*stored.Stored[object.Object], error) {
+ parsed, err := r.parseObject(id)
+ if err != nil {
+ return nil, err
+ }
+
+ return stored.New(id, parsed), nil
+}
diff --git a/object/fetch/exact_reader.go b/object/fetch/exact_reader.go
new file mode 100644
index 00000000..54cf246c
--- /dev/null
+++ b/object/fetch/exact_reader.go
@@ -0,0 +1,26 @@
+package fetch
+
+import (
+ "fmt"
+ "io"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ objecttype "codeberg.org/lindenii/furgit/object/type"
+)
+
+// exactReader reads one object's content stream and verifies that its header
+// type matches wantType.
+func (r *Fetcher) exactReader(id objectid.ObjectID, wantType objecttype.Type, wantName string) (io.ReadCloser, int64, error) {
+ gotType, size, rc, err := r.store.ReadReaderContent(id)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ if gotType != wantType {
+ _ = rc.Close()
+
+ return nil, 0, fmt.Errorf("object/fetch: expected %s object %s, got %v", wantName, id, gotType)
+ }
+
+ return rc, size, nil
+}
diff --git a/object/fetch/exact_tag.go b/object/fetch/exact_tag.go
new file mode 100644
index 00000000..03c66019
--- /dev/null
+++ b/object/fetch/exact_tag.go
@@ -0,0 +1,24 @@
+package fetch
+
+import (
+ "fmt"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/stored"
+ "codeberg.org/lindenii/furgit/object/tag"
+)
+
+// ExactTag reads, parses, and wraps the tag at id.
+func (r *Fetcher) ExactTag(id objectid.ObjectID) (*stored.Stored[*tag.Tag], error) {
+ parsed, err := r.parseObject(id)
+ if err != nil {
+ return nil, err
+ }
+
+ tag, ok := parsed.(*tag.Tag)
+ if !ok {
+ return nil, fmt.Errorf("object/fetch: expected tag object %s, got %v", id, parsed.ObjectType())
+ }
+
+ return stored.New(id, tag), nil
+}
diff --git a/object/fetch/exact_tag_reader.go b/object/fetch/exact_tag_reader.go
new file mode 100644
index 00000000..79a31a4c
--- /dev/null
+++ b/object/fetch/exact_tag_reader.go
@@ -0,0 +1,16 @@
+package fetch
+
+import (
+ "io"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ objecttype "codeberg.org/lindenii/furgit/object/type"
+)
+
+// ExactTagReader returns a reader for the content of the tag at id,
+// together with its content size in bytes.
+//
+// Usage of this method is unusual.
+func (r *Fetcher) ExactTagReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+ return r.exactReader(id, objecttype.TypeTag, "tag")
+}
diff --git a/object/fetch/exact_tree.go b/object/fetch/exact_tree.go
new file mode 100644
index 00000000..cab10ba2
--- /dev/null
+++ b/object/fetch/exact_tree.go
@@ -0,0 +1,24 @@
+package fetch
+
+import (
+ "fmt"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/stored"
+ "codeberg.org/lindenii/furgit/object/tree"
+)
+
+// ExactTree reads, parses, and wraps the tree at id.
+func (r *Fetcher) ExactTree(id objectid.ObjectID) (*stored.Stored[*tree.Tree], error) {
+ parsed, err := r.parseObject(id)
+ if err != nil {
+ return nil, err
+ }
+
+ tree, ok := parsed.(*tree.Tree)
+ if !ok {
+ return nil, fmt.Errorf("object/fetch: expected tree object %s, got %v", id, parsed.ObjectType())
+ }
+
+ return stored.New(id, tree), nil
+}
diff --git a/object/fetch/exact_tree_reader.go b/object/fetch/exact_tree_reader.go
new file mode 100644
index 00000000..4660bd34
--- /dev/null
+++ b/object/fetch/exact_tree_reader.go
@@ -0,0 +1,16 @@
+package fetch
+
+import (
+ "io"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ objecttype "codeberg.org/lindenii/furgit/object/type"
+)
+
+// ExactTreeReader returns a reader for the content of the tree at id,
+// together with its content size in bytes.
+//
+// Usage of this method is unusual.
+func (r *Fetcher) ExactTreeReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+ return r.exactReader(id, objecttype.TypeTree, "tree")
+}
diff --git a/object/fetch/fetcher.go b/object/fetch/fetcher.go
new file mode 100644
index 00000000..ed4e74a1
--- /dev/null
+++ b/object/fetch/fetcher.go
@@ -0,0 +1,17 @@
+package fetch
+
+import objectstorer "codeberg.org/lindenii/furgit/object/storer"
+
+// Fetcher resolves parsed and streamed objects from an object store.
+//
+// A Fetcher does not take ownership of the store and does not close it.
+type Fetcher struct {
+ store objectstorer.Store
+}
+
+// New returns a Fetcher that reads objects from store.
+//
+// The returned Fetcher does not take ownership of store.
+func New(store objectstorer.Store) *Fetcher {
+ return &Fetcher{store: store}
+}
diff --git a/object/fetch/object_parse.go b/object/fetch/object_parse.go
new file mode 100644
index 00000000..a29e17c0
--- /dev/null
+++ b/object/fetch/object_parse.go
@@ -0,0 +1,28 @@
+package fetch
+
+import (
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/object"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ objecttype "codeberg.org/lindenii/furgit/object/type"
+)
+
+func (r *Fetcher) parseObject(id objectid.ObjectID) (object.Object, error) {
+ ty, content, err := r.store.ReadBytesContent(id)
+ if err != nil {
+ return nil, err
+ }
+
+ parsed, err := object.ParseObjectWithoutHeader(ty, content, id.Algorithm())
+ if err != nil {
+ tyName, ok := objecttype.Name(ty)
+ if !ok {
+ tyName = fmt.Sprintf("type %d", ty)
+ }
+
+ return nil, fmt.Errorf("object/fetch: parse object %s (%s): %w", id, tyName, err)
+ }
+
+ return parsed, nil
+}
diff --git a/object/fetch/path.go b/object/fetch/path.go
new file mode 100644
index 00000000..f26379c3
--- /dev/null
+++ b/object/fetch/path.go
@@ -0,0 +1,103 @@
+package fetch
+
+import (
+ "fmt"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/tree"
+)
+
+// PathEmptyError indicates that Path received no segments.
+type PathEmptyError struct{}
+
+func (err *PathEmptyError) Error() string {
+ return "object/fetch: empty tree path"
+}
+
+// PathSegmentEmptyError indicates that one path segment is empty.
+type PathSegmentEmptyError struct {
+ Index int
+}
+
+func (err *PathSegmentEmptyError) Error() string {
+ return fmt.Sprintf("object/fetch: empty tree path segment at index %d", err.Index)
+}
+
+// PathNotFoundError indicates that one tree path segment was not found.
+type PathNotFoundError struct {
+ Index int
+ Name []byte
+}
+
+func (err *PathNotFoundError) Error() string {
+ return fmt.Sprintf("object/fetch: tree entry %q not found at index %d", err.Name, err.Index)
+}
+
+// PathNotTreeError indicates that one intermediate path segment was not a tree.
+type PathNotTreeError struct {
+ Index int
+ Name []byte
+}
+
+func (err *PathNotTreeError) Error() string {
+ return fmt.Sprintf("object/fetch: path segment %q at index %d is not a tree", err.Name, err.Index)
+}
+
+// Path resolves parts within the tree identified by root and returns the final
+// tree entry.
+//
+// The root object may be any tree-ish object accepted by PeelToTree.
+//
+// parts must contain at least one path segment. Intermediate path segments
+// must resolve to tree entries. The final entry is returned without loading
+// its object. Path segments may not contain \x00.
+//
+// The path cannot be accurately represented as a string or a single []byte
+// because Git tree entry names may include slashes. While []string is
+// technically possible (since Go strings are not necessarily UTF-8), they
+// do often imply UTF-8 in practice, which would be undesirable.
+//
+// If your entry names are valid UTF-8 and uses / solely as segment separators,
+// it may be convenient to use TreeFS for an io/fs.FS-like interface.
+func (r *Fetcher) Path(root objectid.ObjectID, parts [][]byte) (tree.TreeEntry, error) {
+ if len(parts) == 0 {
+ return tree.TreeEntry{}, &PathEmptyError{}
+ }
+
+ current, err := r.PeelToTree(root)
+ if err != nil {
+ return tree.TreeEntry{}, err
+ }
+
+ for i, part := range parts {
+ if len(part) == 0 {
+ return tree.TreeEntry{}, &PathSegmentEmptyError{Index: i}
+ }
+
+ entry := current.Object().Entry(part)
+ if entry == nil {
+ return tree.TreeEntry{}, &PathNotFoundError{
+ Index: i,
+ Name: append([]byte(nil), part...),
+ }
+ }
+
+ if i == len(parts)-1 {
+ return *entry, nil
+ }
+
+ if entry.Mode != tree.FileModeDir {
+ return tree.TreeEntry{}, &PathNotTreeError{
+ Index: i,
+ Name: append([]byte(nil), part...),
+ }
+ }
+
+ current, err = r.ExactTree(entry.ID)
+ if err != nil {
+ return tree.TreeEntry{}, err
+ }
+ }
+
+ return tree.TreeEntry{}, &PathNotFoundError{Index: len(parts) - 1}
+}
diff --git a/object/fetch/peel_to_blob.go b/object/fetch/peel_to_blob.go
new file mode 100644
index 00000000..b0a1ad1c
--- /dev/null
+++ b/object/fetch/peel_to_blob.go
@@ -0,0 +1,29 @@
+package fetch
+
+import (
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/object/blob"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/stored"
+ "codeberg.org/lindenii/furgit/object/tag"
+)
+
+// PeelToBlob peels tags until it reaches a blob.
+func (r *Fetcher) PeelToBlob(id objectid.ObjectID) (*stored.Stored[*blob.Blob], error) {
+ for {
+ obj, err := r.ExactObject(id)
+ if err != nil {
+ return nil, err
+ }
+
+ switch parsed := obj.Object().(type) {
+ case *blob.Blob:
+ return stored.New(id, parsed), nil
+ case *tag.Tag:
+ id = parsed.Target
+ default:
+ return nil, fmt.Errorf("object/fetch: expected blob-ish object %s, got %v", id, parsed.ObjectType())
+ }
+ }
+}
diff --git a/object/fetch/peel_to_blob_id.go b/object/fetch/peel_to_blob_id.go
new file mode 100644
index 00000000..9d13f640
--- /dev/null
+++ b/object/fetch/peel_to_blob_id.go
@@ -0,0 +1,39 @@
+package fetch
+
+import (
+ "fmt"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ objecttype "codeberg.org/lindenii/furgit/object/type"
+)
+
+// PeelToBlobID peels tags until it reaches a blob object ID.
+func (r *Fetcher) PeelToBlobID(id objectid.ObjectID) (objectid.ObjectID, error) {
+ for {
+ ty, _, err := r.store.ReadHeader(id)
+ if err != nil {
+ return objectid.ObjectID{}, err
+ }
+
+ switch ty {
+ case objecttype.TypeBlob:
+ return id, nil
+ case objecttype.TypeTag:
+ tag, err := r.ExactTag(id)
+ if err != nil {
+ return objectid.ObjectID{}, err
+ }
+
+ id = tag.Object().Target
+ case objecttype.TypeInvalid,
+ objecttype.TypeCommit,
+ objecttype.TypeTree,
+ objecttype.TypeFuture,
+ objecttype.TypeOfsDelta,
+ objecttype.TypeRefDelta:
+ return objectid.ObjectID{}, fmt.Errorf("object/fetch: expected blob-ish object %s, got %v", id, ty)
+ default:
+ return objectid.ObjectID{}, fmt.Errorf("object/fetch: expected blob-ish object %s, got %v", id, ty)
+ }
+ }
+}
diff --git a/object/fetch/peel_to_blob_reader.go b/object/fetch/peel_to_blob_reader.go
new file mode 100644
index 00000000..d7072531
--- /dev/null
+++ b/object/fetch/peel_to_blob_reader.go
@@ -0,0 +1,18 @@
+package fetch
+
+import (
+ "io"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+// PeelToBlobReader returns a reader for the content of the peeled blob at id,
+// together with its content size in bytes.
+func (r *Fetcher) PeelToBlobReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+ blobID, err := r.PeelToBlobID(id)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ return r.ExactBlobReader(blobID)
+}
diff --git a/object/fetch/peel_to_commit.go b/object/fetch/peel_to_commit.go
new file mode 100644
index 00000000..5ec66411
--- /dev/null
+++ b/object/fetch/peel_to_commit.go
@@ -0,0 +1,29 @@
+package fetch
+
+import (
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/object/commit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/stored"
+ "codeberg.org/lindenii/furgit/object/tag"
+)
+
+// PeelToCommit peels tags until it reaches a commit.
+func (r *Fetcher) PeelToCommit(id objectid.ObjectID) (*stored.Stored[*commit.Commit], error) {
+ for {
+ obj, err := r.ExactObject(id)
+ if err != nil {
+ return nil, err
+ }
+
+ switch parsed := obj.Object().(type) {
+ case *commit.Commit:
+ return stored.New(id, parsed), nil
+ case *tag.Tag:
+ id = parsed.Target
+ default:
+ return nil, fmt.Errorf("object/fetch: expected commit-ish object %s, got %v", id, parsed.ObjectType())
+ }
+ }
+}
diff --git a/object/fetch/peel_to_commit_id.go b/object/fetch/peel_to_commit_id.go
new file mode 100644
index 00000000..b6b257cf
--- /dev/null
+++ b/object/fetch/peel_to_commit_id.go
@@ -0,0 +1,39 @@
+package fetch
+
+import (
+ "fmt"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ objecttype "codeberg.org/lindenii/furgit/object/type"
+)
+
+// PeelToCommitID peels tags until it reaches a commit object ID.
+func (r *Fetcher) PeelToCommitID(id objectid.ObjectID) (objectid.ObjectID, error) {
+ for {
+ ty, _, err := r.store.ReadHeader(id)
+ if err != nil {
+ return objectid.ObjectID{}, err
+ }
+
+ switch ty {
+ case objecttype.TypeCommit:
+ return id, nil
+ case objecttype.TypeTag:
+ tag, err := r.ExactTag(id)
+ if err != nil {
+ return objectid.ObjectID{}, err
+ }
+
+ id = tag.Object().Target
+ case objecttype.TypeInvalid,
+ objecttype.TypeTree,
+ objecttype.TypeBlob,
+ objecttype.TypeFuture,
+ objecttype.TypeOfsDelta,
+ objecttype.TypeRefDelta:
+ return objectid.ObjectID{}, fmt.Errorf("object/fetch: expected commit-ish object %s, got %v", id, ty)
+ default:
+ return objectid.ObjectID{}, fmt.Errorf("object/fetch: expected commit-ish object %s, got %v", id, ty)
+ }
+ }
+}
diff --git a/object/fetch/peel_to_commit_reader.go b/object/fetch/peel_to_commit_reader.go
new file mode 100644
index 00000000..02bf9f6f
--- /dev/null
+++ b/object/fetch/peel_to_commit_reader.go
@@ -0,0 +1,20 @@
+package fetch
+
+import (
+ "io"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+// PeelToCommitReader returns a reader for the content of the peeled commit at
+// id, together with its content size in bytes.
+//
+// Usage of this method is unusual.
+func (r *Fetcher) PeelToCommitReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+ commitID, err := r.PeelToCommitID(id)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ return r.ExactCommitReader(commitID)
+}
diff --git a/object/fetch/peel_to_tag.go b/object/fetch/peel_to_tag.go
new file mode 100644
index 00000000..60693069
--- /dev/null
+++ b/object/fetch/peel_to_tag.go
@@ -0,0 +1,12 @@
+package fetch
+
+import (
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/stored"
+ "codeberg.org/lindenii/furgit/object/tag"
+)
+
+// PeelToTag returns the tag at id without further peeling.
+func (r *Fetcher) PeelToTag(id objectid.ObjectID) (*stored.Stored[*tag.Tag], error) {
+ return r.ExactTag(id)
+}
diff --git a/object/fetch/peel_to_tag_id.go b/object/fetch/peel_to_tag_id.go
new file mode 100644
index 00000000..dc02c60a
--- /dev/null
+++ b/object/fetch/peel_to_tag_id.go
@@ -0,0 +1,8 @@
+package fetch
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// PeelToTagID returns id unchanged.
+func (r *Fetcher) PeelToTagID(id objectid.ObjectID) (objectid.ObjectID, error) {
+ return id, nil
+}
diff --git a/object/fetch/peel_to_tag_reader.go b/object/fetch/peel_to_tag_reader.go
new file mode 100644
index 00000000..562ef9e8
--- /dev/null
+++ b/object/fetch/peel_to_tag_reader.go
@@ -0,0 +1,20 @@
+package fetch
+
+import (
+ "io"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+// PeelToTagReader returns a reader for the content of the tag at id,
+// together with its content size in bytes.
+//
+// Usage of this method is unusual.
+func (r *Fetcher) PeelToTagReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+ tagID, err := r.PeelToTagID(id)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ return r.ExactTagReader(tagID)
+}
diff --git a/object/fetch/peel_to_tree.go b/object/fetch/peel_to_tree.go
new file mode 100644
index 00000000..119e76ac
--- /dev/null
+++ b/object/fetch/peel_to_tree.go
@@ -0,0 +1,33 @@
+package fetch
+
+import (
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/object/commit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/stored"
+ "codeberg.org/lindenii/furgit/object/tag"
+ "codeberg.org/lindenii/furgit/object/tree"
+)
+
+// PeelToTree peels tags until it reaches a tree or commit. If it reaches a
+// commit, it returns the commit's root tree.
+func (r *Fetcher) PeelToTree(id objectid.ObjectID) (*stored.Stored[*tree.Tree], error) {
+ for {
+ obj, err := r.ExactObject(id)
+ if err != nil {
+ return nil, err
+ }
+
+ switch parsed := obj.Object().(type) {
+ case *tree.Tree:
+ return stored.New(id, parsed), nil
+ case *commit.Commit:
+ return r.ExactTree(parsed.Tree)
+ case *tag.Tag:
+ id = parsed.Target
+ default:
+ return nil, fmt.Errorf("object/fetch: expected tree-ish object %s, got %v", id, parsed.ObjectType())
+ }
+ }
+}
diff --git a/object/fetch/peel_to_tree_id.go b/object/fetch/peel_to_tree_id.go
new file mode 100644
index 00000000..21dcfde4
--- /dev/null
+++ b/object/fetch/peel_to_tree_id.go
@@ -0,0 +1,46 @@
+package fetch
+
+import (
+ "fmt"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ objecttype "codeberg.org/lindenii/furgit/object/type"
+)
+
+// PeelToTreeID peels tags until it reaches a tree object ID, or a commit whose
+// root tree object ID is then returned.
+func (r *Fetcher) PeelToTreeID(id objectid.ObjectID) (objectid.ObjectID, error) {
+ for {
+ ty, _, err := r.store.ReadHeader(id)
+ if err != nil {
+ return objectid.ObjectID{}, err
+ }
+
+ switch ty {
+ case objecttype.TypeTree:
+ return id, nil
+ case objecttype.TypeCommit:
+ commit, err := r.ExactCommit(id)
+ if err != nil {
+ return objectid.ObjectID{}, err
+ }
+
+ return commit.Object().Tree, nil
+ case objecttype.TypeTag:
+ tag, err := r.ExactTag(id)
+ if err != nil {
+ return objectid.ObjectID{}, err
+ }
+
+ id = tag.Object().Target
+ case objecttype.TypeInvalid,
+ objecttype.TypeBlob,
+ objecttype.TypeFuture,
+ objecttype.TypeOfsDelta,
+ objecttype.TypeRefDelta:
+ return objectid.ObjectID{}, fmt.Errorf("object/fetch: expected tree-ish object %s, got %v", id, ty)
+ default:
+ return objectid.ObjectID{}, fmt.Errorf("object/fetch: expected tree-ish object %s, got %v", id, ty)
+ }
+ }
+}
diff --git a/object/fetch/peel_to_tree_reader.go b/object/fetch/peel_to_tree_reader.go
new file mode 100644
index 00000000..fe1c5b93
--- /dev/null
+++ b/object/fetch/peel_to_tree_reader.go
@@ -0,0 +1,20 @@
+package fetch
+
+import (
+ "io"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+// PeelToTreeReader returns a reader for the content of the peeled tree at id,
+// together with its content size in bytes.
+//
+// Usage of this method is unusual.
+func (r *Fetcher) PeelToTreeReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+ treeID, err := r.PeelToTreeID(id)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ return r.ExactTreeReader(treeID)
+}
diff --git a/object/fetch/treefs.go b/object/fetch/treefs.go
new file mode 100644
index 00000000..06b6ad25
--- /dev/null
+++ b/object/fetch/treefs.go
@@ -0,0 +1,30 @@
+package fetch
+
+import (
+ "io/fs"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/tree"
+)
+
+// 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 Fetcher.
+type TreeFS struct {
+ resolver *Fetcher
+ rootTree objectid.ObjectID
+ rootEntry *tree.TreeEntry
+}
+
+var (
+ _ fs.FS = (*TreeFS)(nil)
+ _ fs.ReadFileFS = (*TreeFS)(nil)
+ _ fs.ReadDirFS = (*TreeFS)(nil)
+ _ fs.StatFS = (*TreeFS)(nil)
+ _ fs.SubFS = (*TreeFS)(nil)
+)
diff --git a/object/fetch/treefs_entry.go b/object/fetch/treefs_entry.go
new file mode 100644
index 00000000..4aa7730a
--- /dev/null
+++ b/object/fetch/treefs_entry.go
@@ -0,0 +1,90 @@
+package fetch
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/tree"
+)
+
+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: tree.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 _, 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 _, ok := errors.AsType[*PathEmptyError](err); ok {
+ return treeFSPathError(op, name, fs.ErrInvalid)
+ }
+
+ if _, ok := errors.AsType[*PathSegmentEmptyError](err); ok {
+ return treeFSPathError(op, name, fs.ErrInvalid)
+ }
+
+ return treeFSPathError(op, name, err)
+}
+
+type treeEntryValue struct {
+ name string
+ mode tree.FileMode
+ objectID objectid.ObjectID
+ treeID objectid.ObjectID
+ treeEntry *tree.TreeEntry
+}
+
+func (entry treeEntryValue) isDir() bool {
+ return entry.mode == tree.FileModeDir
+}
+
+func (entry treeEntryValue) blobSize(resolve *Fetcher) (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 != tree.FileModeDir {
+ return objectid.ObjectID{}, fmt.Errorf("object/fetch: path %q is not a tree", entry.name)
+ }
+
+ return entry.objectID, nil
+}
diff --git a/object/fetch/treefs_info.go b/object/fetch/treefs_info.go
new file mode 100644
index 00000000..eecf183d
--- /dev/null
+++ b/object/fetch/treefs_info.go
@@ -0,0 +1,75 @@
+package fetch
+
+import (
+ "io/fs"
+ "time"
+
+ "codeberg.org/lindenii/furgit/object/tree"
+)
+
+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 tree.FileMode) fs.FileMode {
+ switch mode {
+ case tree.FileModeDir:
+ return fs.ModeDir | 0o555
+ case tree.FileModeRegular:
+ return 0o444
+ case tree.FileModeExecutable:
+ return 0o555
+ case tree.FileModeSymlink:
+ return fs.ModeSymlink | 0o444
+ case tree.FileModeGitlink:
+ return fs.ModeIrregular
+ default:
+ return fs.ModeIrregular
+ }
+}
+
+func (treeFS *TreeFS) statEntry(entry treeEntryValue) (*treeFSInfo, error) {
+ size := int64(0)
+
+ if entry.mode == tree.FileModeRegular || entry.mode == tree.FileModeExecutable || entry.mode == tree.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
+}
diff --git a/object/fetch/treefs_new.go b/object/fetch/treefs_new.go
new file mode 100644
index 00000000..d2224fcf
--- /dev/null
+++ b/object/fetch/treefs_new.go
@@ -0,0 +1,17 @@
+package fetch
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// TreeFS returns a new filesystem view rooted at root, which may be any
+// tree-ish object accepted by PeelToTreeID.
+func (r *Fetcher) TreeFS(root objectid.ObjectID) (*TreeFS, error) {
+ rootTree, err := r.PeelToTreeID(root)
+ if err != nil {
+ return nil, err
+ }
+
+ return &TreeFS{
+ resolver: r,
+ rootTree: rootTree,
+ }, nil
+}
diff --git a/object/fetch/treefs_op.go b/object/fetch/treefs_op.go
new file mode 100644
index 00000000..f0472923
--- /dev/null
+++ b/object/fetch/treefs_op.go
@@ -0,0 +1,28 @@
+package fetch
+
+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"
+ }
+}
diff --git a/object/fetch/treefs_open.go b/object/fetch/treefs_open.go
new file mode 100644
index 00000000..59c6ec5d
--- /dev/null
+++ b/object/fetch/treefs_open.go
@@ -0,0 +1,122 @@
+package fetch
+
+import (
+ "fmt"
+ "io"
+ "io/fs"
+
+ "codeberg.org/lindenii/furgit/object/tree"
+)
+
+// 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.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 == tree.FileModeGitlink {
+ return nil, treeFSPathError(treeFSOpOpen, name, fmt.Errorf("object/fetch: 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 := min(dir.offset+n, len(dir.entries))
+
+ out := append([]fs.DirEntry(nil), dir.entries[dir.offset:end]...)
+ dir.offset = end
+
+ return out, nil
+}
diff --git a/object/fetch/treefs_path.go b/object/fetch/treefs_path.go
new file mode 100644
index 00000000..7aa31c7f
--- /dev/null
+++ b/object/fetch/treefs_path.go
@@ -0,0 +1,29 @@
+package fetch
+
+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}
+}
diff --git a/object/fetch/treefs_readdir.go b/object/fetch/treefs_readdir.go
new file mode 100644
index 00000000..7518c607
--- /dev/null
+++ b/object/fetch/treefs_readdir.go
@@ -0,0 +1,20 @@
+package fetch
+
+import "io/fs"
+
+// 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)
+ }
+
+ return readDirFile.ReadDir(-1)
+}
diff --git a/object/fetch/treefs_readfile.go b/object/fetch/treefs_readfile.go
new file mode 100644
index 00000000..d3671f07
--- /dev/null
+++ b/object/fetch/treefs_readfile.go
@@ -0,0 +1,40 @@
+package fetch
+
+import (
+ "fmt"
+ "io"
+
+ "codeberg.org/lindenii/furgit/object/tree"
+)
+
+// 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, fmt.Errorf("is a directory"))
+ }
+
+ if entry.mode == tree.FileModeGitlink {
+ return nil, treeFSPathError(treeFSOpReadFile, name, fmt.Errorf("object/fetch: 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
+}
diff --git a/object/fetch/treefs_stat.go b/object/fetch/treefs_stat.go
new file mode 100644
index 00000000..7d7a6418
--- /dev/null
+++ b/object/fetch/treefs_stat.go
@@ -0,0 +1,22 @@
+package fetch
+
+import "io/fs"
+
+// 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.TreeEntry 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
+}
diff --git a/object/fetch/treefs_sub.go b/object/fetch/treefs_sub.go
new file mode 100644
index 00000000..faf47092
--- /dev/null
+++ b/object/fetch/treefs_sub.go
@@ -0,0 +1,22 @@
+package fetch
+
+import "io/fs"
+
+// 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{
+ resolver: treeFS.resolver,
+ rootTree: treeID,
+ rootEntry: entry.treeEntry,
+ }, nil
+}
diff --git a/object/fetch/treefs_test.go b/object/fetch/treefs_test.go
new file mode 100644
index 00000000..067283f7
--- /dev/null
+++ b/object/fetch/treefs_test.go
@@ -0,0 +1,111 @@
+package fetch_test
+
+import (
+ "errors"
+ "io/fs"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/fetch"
+ "codeberg.org/lindenii/furgit/object/tree"
+ "codeberg.org/lindenii/furgit/repository"
+)
+
+func TestTreeFS(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ 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 := fetch.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().(tree.TreeEntry)
+ if !ok {
+ t.Fatalf("Stat(plain.txt).Sys() type = %T, want tree.TreeEntry", info.Sys())
+ }
+
+ if entry.Mode != tree.FileModeRegular {
+ t.Fatalf("Stat(plain.txt).Sys().Mode = %o, want %o", entry.Mode, tree.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")
+ }
+
+ if _, ok := errors.AsType[*fs.PathError](err); !ok {
+ t.Fatalf("ReadFile(dir) err type = %T, want *fs.PathError", err)
+ }
+ })
+}