aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--REFACTOR6
-rw-r--r--TODO2
-rw-r--r--errs/doc.go2
-rw-r--r--errs/missing.go25
-rw-r--r--errs/type.go24
-rw-r--r--internal/iolimit/capped_capture_writer.go70
-rw-r--r--internal/iolimit/capped_capture_writer_test.go45
-rw-r--r--internal/iolimit/doc.go6
-rw-r--r--internal/iolimit/expect_length_reader.go91
-rw-r--r--internal/iolimit/expect_length_reader_test.go78
-rw-r--r--object/fetch/blob.go97
-rw-r--r--object/fetch/commit.go74
-rw-r--r--object/fetch/doc.go11
-rw-r--r--object/fetch/errors.go19
-rw-r--r--object/fetch/fetcher.go20
-rw-r--r--object/fetch/header.go30
-rw-r--r--object/fetch/object.go36
-rw-r--r--object/fetch/path.go91
-rw-r--r--object/fetch/reader.go26
-rw-r--r--object/fetch/tag.go26
-rw-r--r--object/fetch/tree.go86
-rw-r--r--object/fetch/treefs.go438
-rw-r--r--object/fetch/treefs_test.go172
-rw-r--r--object/stored/doc.go8
-rw-r--r--object/stored/stored.go28
25 files changed, 1505 insertions, 6 deletions
diff --git a/REFACTOR b/REFACTOR
index 69dd2661..24334886 100644
--- a/REFACTOR
+++ b/REFACTOR
@@ -2,7 +2,6 @@ commitquery
diff
diff/lines
diff/trees
-errors
format
format/commitgraph
format/commitgraph/bloom
@@ -10,11 +9,9 @@ format/commitgraph/read
format/packfile
format/packfile/delta
format/packfile/delta/apply
-internal
internal/bufpool
internal/iolimit
internal/lru
-internal/progress
network
network/protocol
network/protocol/pktline
@@ -25,15 +22,12 @@ network/protocol/v0v1/server/receivepack
network/receivepack
network/receivepack/hooks
network/receivepack/service
-object
-object/fetch
object/store/dual
object/store/loose
object/store/packed
object/store/packed/internal
object/store/packed/internal/ingest
object/store/packed/internal/reading
-object/stored
reachability
ref/store
ref/store/chain
diff --git a/TODO b/TODO
index 8cba50cd..777446f4 100644
--- a/TODO
+++ b/TODO
@@ -8,3 +8,5 @@
* Are refname.Tag really the same as tag names?
* Interactions with hash-function-transition
+
+* Check error wrapping in object/fetch
diff --git a/errs/doc.go b/errs/doc.go
new file mode 100644
index 00000000..f9891334
--- /dev/null
+++ b/errs/doc.go
@@ -0,0 +1,2 @@
+// Package errs defines error types shared across furgit.
+package errs
diff --git a/errs/missing.go b/errs/missing.go
new file mode 100644
index 00000000..0630846c
--- /dev/null
+++ b/errs/missing.go
@@ -0,0 +1,25 @@
+package errs
+
+import (
+ "fmt"
+
+ "lindenii.org/go/furgit/object/id"
+)
+
+// ObjectMissingError indicates that
+// a referenced object is absent from the repository object store.
+//
+// This should only be used
+// in situations where objects are being queried recursively
+// or otherwise by some chain that the caller may not be aware of.
+//
+// Failures on direct object access
+// should instead use [lindenii.org/go/furgit/object/store.ErrObjectNotFound].
+type ObjectMissingError struct {
+ OID id.ObjectID
+}
+
+// Error implements error.
+func (e *ObjectMissingError) Error() string {
+ return fmt.Sprintf("missing object %s", e.OID)
+}
diff --git a/errs/type.go b/errs/type.go
new file mode 100644
index 00000000..f726e89b
--- /dev/null
+++ b/errs/type.go
@@ -0,0 +1,24 @@
+package errs
+
+import (
+ "fmt"
+
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+// ObjectTypeError indicates that a referenced object
+// has a different type than what the operation expected.
+type ObjectTypeError struct {
+ OID id.ObjectID
+ Got typ.Type
+ Want typ.Type
+}
+
+// Error implements error.
+func (e *ObjectTypeError) Error() string {
+ return fmt.Sprintf(
+ "object %s has type %s, want %s",
+ e.OID, e.Got.Name(), e.Want.Name(),
+ )
+}
diff --git a/internal/iolimit/capped_capture_writer.go b/internal/iolimit/capped_capture_writer.go
new file mode 100644
index 00000000..e4a64590
--- /dev/null
+++ b/internal/iolimit/capped_capture_writer.go
@@ -0,0 +1,70 @@
+package iolimit
+
+import (
+ "bytes"
+ "fmt"
+
+ "lindenii.org/go/lgo/intconv"
+)
+
+// CappedCaptureWriter captures written bytes up to a fixed limit.
+//
+// Once the total written bytes would exceed the limit,
+// capture is disabled and Bytes returns nil.
+// Write still reports success for the full input length.
+type CappedCaptureWriter struct {
+ limit uint64
+ buf bytes.Buffer
+ full bool
+}
+
+// NewCappedCaptureWriter constructs one capped capture writer.
+func NewCappedCaptureWriter(limit uint64) *CappedCaptureWriter {
+ return &CappedCaptureWriter{limit: limit}
+}
+
+// Write captures up to the configured limit
+// and always reports len(src) bytes written.
+func (writer *CappedCaptureWriter) Write(src []byte) (int, error) {
+ if writer.full {
+ return len(src), nil
+ }
+
+ used, err := intconv.IntToUint64(writer.buf.Len())
+ if err != nil {
+ return 0, fmt.Errorf("iolimit: %w", err)
+ }
+
+ if used >= writer.limit {
+ writer.full = true
+
+ return len(src), nil
+ }
+
+ room := writer.limit - used
+ if uint64(len(src)) > room {
+ take, err := intconv.Uint64ToInt(room)
+ if err != nil {
+ return 0, fmt.Errorf("iolimit: %w", err)
+ }
+
+ _, _ = writer.buf.Write(src[:take])
+ writer.full = true
+
+ return len(src), nil
+ }
+
+ _, _ = writer.buf.Write(src)
+
+ return len(src), nil
+}
+
+// Bytes returns captured bytes,
+// or nil when capture exceeded the limit.
+func (writer *CappedCaptureWriter) Bytes() []byte {
+ if writer.full {
+ return nil
+ }
+
+ return writer.buf.Bytes()
+}
diff --git a/internal/iolimit/capped_capture_writer_test.go b/internal/iolimit/capped_capture_writer_test.go
new file mode 100644
index 00000000..2793f8cb
--- /dev/null
+++ b/internal/iolimit/capped_capture_writer_test.go
@@ -0,0 +1,45 @@
+package iolimit_test
+
+import (
+ "bytes"
+ "testing"
+
+ "lindenii.org/go/furgit/internal/iolimit"
+)
+
+func TestCappedCaptureWriterWithinLimit(t *testing.T) {
+ t.Parallel()
+
+ writer := iolimit.NewCappedCaptureWriter(8)
+
+ _, _ = writer.Write([]byte("hello"))
+ _, _ = writer.Write([]byte("!"))
+
+ if got := writer.Bytes(); !bytes.Equal(got, []byte("hello!")) {
+ t.Fatalf("Bytes() = %q, want %q", got, "hello!")
+ }
+}
+
+func TestCappedCaptureWriterExceededLimit(t *testing.T) {
+ t.Parallel()
+
+ writer := iolimit.NewCappedCaptureWriter(4)
+
+ _, _ = writer.Write([]byte("abcd"))
+ _, _ = writer.Write([]byte("x"))
+
+ if got := writer.Bytes(); got != nil {
+ t.Fatalf("Bytes() = %q, want nil after overflow", got)
+ }
+}
+
+func TestCappedCaptureWriterZeroLimit(t *testing.T) {
+ t.Parallel()
+
+ writer := iolimit.NewCappedCaptureWriter(0)
+
+ _, _ = writer.Write([]byte("x"))
+ if got := writer.Bytes(); got != nil {
+ t.Fatalf("Bytes() = %q, want nil at zero limit", got)
+ }
+}
diff --git a/internal/iolimit/doc.go b/internal/iolimit/doc.go
new file mode 100644
index 00000000..5eb72b2b
--- /dev/null
+++ b/internal/iolimit/doc.go
@@ -0,0 +1,6 @@
+// Package iolimit provides small internal I/O wrappers with bounded behavior.
+//
+// It includes helpers for both readers and writers
+// that enforce configured limits
+// (length checks, capped capture, etc.).
+package iolimit
diff --git a/internal/iolimit/expect_length_reader.go b/internal/iolimit/expect_length_reader.go
new file mode 100644
index 00000000..1efe2b75
--- /dev/null
+++ b/internal/iolimit/expect_length_reader.go
@@ -0,0 +1,91 @@
+package iolimit
+
+import (
+ "errors"
+ "fmt"
+ "io"
+
+ "lindenii.org/go/lgo/intconv"
+)
+
+// ErrExpectedLengthExceeded reports that a stream
+// produced bytes beyond the expected length.
+var ErrExpectedLengthExceeded = errors.New("iolimit: stream exceeded expected length")
+
+// ExpectLengthReader wraps src and enforces an expected byte length.
+//
+// It returns io.ErrUnexpectedEOF
+// if src ends before expected bytes are read.
+// It returns ErrExpectedLengthExceeded
+// if reads continue beyond the expected boundary
+// and src still produces bytes.
+//
+// This reader does not drain src on close or at the expected boundary.
+// As a result,
+// overlength streams are detected only
+// when a caller reads at or past the boundary.
+func ExpectLengthReader(src io.Reader, expected uint64) io.Reader {
+ return &expectLengthReader{
+ src: src,
+ remaining: expected,
+ }
+}
+
+type expectLengthReader struct {
+ src io.Reader
+ remaining uint64
+}
+
+func (reader *expectLengthReader) Read(dst []byte) (int, error) {
+ if len(dst) == 0 {
+ return 0, nil
+ }
+
+ if reader.remaining == 0 {
+ var probe [1]byte
+
+ n, err := reader.src.Read(probe[:])
+ if n > 0 {
+ return 0, ErrExpectedLengthExceeded
+ }
+
+ if err == nil {
+ return 0, nil
+ }
+
+ return 0, err //nolint:wrapcheck
+ }
+
+ if uint64(len(dst)) > reader.remaining {
+ limit, err := intconv.Uint64ToInt(reader.remaining)
+ if err != nil {
+ return 0, fmt.Errorf("iolimit: %w", err)
+ }
+
+ dst = dst[:limit]
+ }
+
+ n, err := reader.src.Read(dst)
+ if n > 0 {
+ read, convErr := intconv.IntToUint64(n)
+ if convErr != nil {
+ return n, fmt.Errorf("iolimit: %w", convErr)
+ }
+
+ reader.remaining -= read
+ }
+
+ if errors.Is(err, io.EOF) {
+ if reader.remaining > 0 {
+ return n, io.ErrUnexpectedEOF
+ }
+
+ if n > 0 {
+ return n, nil
+ }
+
+ return 0, io.EOF
+ }
+
+ return n, err //nolint:wrapcheck
+}
diff --git a/internal/iolimit/expect_length_reader_test.go b/internal/iolimit/expect_length_reader_test.go
new file mode 100644
index 00000000..6508e5eb
--- /dev/null
+++ b/internal/iolimit/expect_length_reader_test.go
@@ -0,0 +1,78 @@
+package iolimit_test
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "testing"
+
+ "lindenii.org/go/furgit/internal/iolimit"
+)
+
+func TestExpectLengthReaderExact(t *testing.T) {
+ t.Parallel()
+
+ r := iolimit.ExpectLengthReader(bytes.NewReader([]byte("hello")), 5)
+
+ got, err := io.ReadAll(r)
+ if err != nil {
+ t.Fatalf("ReadAll error: %v", err)
+ }
+
+ if !bytes.Equal(got, []byte("hello")) {
+ t.Fatalf("ReadAll = %q, want %q", got, "hello")
+ }
+
+ buf := make([]byte, 1)
+
+ n, err := r.Read(buf)
+ if n != 0 || !errors.Is(err, io.EOF) {
+ t.Fatalf("post-boundary Read = (%d,%v), want (0,EOF)", n, err)
+ }
+}
+
+func TestExpectLengthReaderShort(t *testing.T) {
+ t.Parallel()
+
+ r := iolimit.ExpectLengthReader(bytes.NewReader([]byte("hey")), 5)
+
+ _, err := io.ReadAll(r)
+ if !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("ReadAll error = %v, want ErrUnexpectedEOF", err)
+ }
+}
+
+func TestExpectLengthReaderLongDetectedOnNextRead(t *testing.T) {
+ t.Parallel()
+
+ r := iolimit.ExpectLengthReader(bytes.NewReader([]byte("hello!")), 5)
+ buf := make([]byte, 5)
+
+ n, err := io.ReadFull(r, buf)
+ if err != nil {
+ t.Fatalf("ReadFull error: %v", err)
+ }
+
+ if n != 5 || !bytes.Equal(buf, []byte("hello")) {
+ t.Fatalf("ReadFull = (%d,%q), want (5,hello)", n, buf)
+ }
+
+ probe := make([]byte, 1)
+
+ n, err = r.Read(probe)
+ if n != 0 || !errors.Is(err, iolimit.ErrExpectedLengthExceeded) {
+ t.Fatalf("overflow Read = (%d,%v), want (0,ErrExpectedLengthExceeded)", n, err)
+ }
+}
+
+func TestExpectLengthReaderEmptyExpected(t *testing.T) {
+ t.Parallel()
+
+ r := iolimit.ExpectLengthReader(bytes.NewReader(nil), 0)
+ buf := make([]byte, 1)
+
+ n, err := r.Read(buf)
+ if n != 0 || !errors.Is(err, io.EOF) {
+ t.Fatalf("Read = (%d,%v), want (0,EOF)", n, err)
+ }
+}
diff --git a/object/fetch/blob.go b/object/fetch/blob.go
new file mode 100644
index 00000000..581fff90
--- /dev/null
+++ b/object/fetch/blob.go
@@ -0,0 +1,97 @@
+package fetch
+
+import (
+ "io"
+
+ "lindenii.org/go/furgit/errs"
+ "lindenii.org/go/furgit/object/blob"
+ oid "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/stored"
+ "lindenii.org/go/furgit/object/tag"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+// ExactBlob reads, parses, and wraps the blob at id.
+//
+// Labels: Life-Parent.
+func (fetcher *Fetcher) ExactBlob(id oid.ObjectID) (*stored.Stored[*blob.Blob], error) {
+ parsed, err := fetcher.parseObject(id)
+ if err != nil {
+ return nil, err
+ }
+
+ blob, ok := parsed.(*blob.Blob)
+ if !ok {
+ return nil, &errs.ObjectTypeError{OID: id, Got: parsed.ObjectType(), Want: typ.TypeBlob}
+ }
+
+ return stored.New(id, blob), nil
+}
+
+// ExactBlobReader returns a reader for the content of the blob at id,
+// together with its content size in bytes.
+//
+// Labels: Life-Parent, Close-Caller.
+func (fetcher *Fetcher) ExactBlobReader(id oid.ObjectID) (io.ReadCloser, uint64, error) {
+ return fetcher.exactReader(id, typ.TypeBlob)
+}
+
+// PeelToBlob peels tags until it reaches a blob.
+//
+// Labels: Life-Parent.
+func (fetcher *Fetcher) PeelToBlob(id oid.ObjectID) (*stored.Stored[*blob.Blob], error) {
+ for {
+ obj, err := fetcher.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.TargetID
+ default:
+ return nil, &errs.ObjectTypeError{OID: id, Got: parsed.ObjectType(), Want: typ.TypeBlob}
+ }
+ }
+}
+
+// PeelToBlobID peels tags until it reaches a blob object ID.
+func (fetcher *Fetcher) PeelToBlobID(id oid.ObjectID) (oid.ObjectID, error) {
+ for {
+ ty, _, err := fetcher.Header(id)
+ if err != nil {
+ return oid.ObjectID{}, err
+ }
+
+ switch ty {
+ case typ.TypeBlob:
+ return id, nil
+ case typ.TypeTag:
+ tag, err := fetcher.ExactTag(id)
+ if err != nil {
+ return oid.ObjectID{}, err
+ }
+
+ id = tag.Object().TargetID
+ case typ.TypeUnknown, typ.TypeCommit, typ.TypeTree:
+ return oid.ObjectID{}, &errs.ObjectTypeError{OID: id, Got: ty, Want: typ.TypeBlob}
+ default:
+ return oid.ObjectID{}, &errs.ObjectTypeError{OID: id, Got: ty, Want: typ.TypeBlob}
+ }
+ }
+}
+
+// PeelToBlobReader returns a reader for the content of the peeled blob at id,
+// together with its content size in bytes.
+//
+// Labels: Life-Parent, Close-Caller.
+func (fetcher *Fetcher) PeelToBlobReader(id oid.ObjectID) (io.ReadCloser, uint64, error) {
+ blobID, err := fetcher.PeelToBlobID(id)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ return fetcher.ExactBlobReader(blobID)
+}
diff --git a/object/fetch/commit.go b/object/fetch/commit.go
new file mode 100644
index 00000000..5d5af892
--- /dev/null
+++ b/object/fetch/commit.go
@@ -0,0 +1,74 @@
+package fetch
+
+import (
+ "lindenii.org/go/furgit/errs"
+ "lindenii.org/go/furgit/object/commit"
+ oid "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/stored"
+ "lindenii.org/go/furgit/object/tag"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+// ExactCommit reads, parses, and wraps the commit at id.
+//
+// Labels: Life-Parent.
+func (fetcher *Fetcher) ExactCommit(id oid.ObjectID) (*stored.Stored[*commit.Commit], error) {
+ parsed, err := fetcher.parseObject(id)
+ if err != nil {
+ return nil, err
+ }
+
+ commit, ok := parsed.(*commit.Commit)
+ if !ok {
+ return nil, &errs.ObjectTypeError{OID: id, Got: parsed.ObjectType(), Want: typ.TypeCommit}
+ }
+
+ return stored.New(id, commit), nil
+}
+
+// PeelToCommit peels tags until it reaches a commit.
+//
+// Labels: Life-Parent.
+func (fetcher *Fetcher) PeelToCommit(id oid.ObjectID) (*stored.Stored[*commit.Commit], error) {
+ for {
+ obj, err := fetcher.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.TargetID
+ default:
+ return nil, &errs.ObjectTypeError{OID: id, Got: parsed.ObjectType(), Want: typ.TypeCommit}
+ }
+ }
+}
+
+// PeelToCommitID peels tags until it reaches a commit object ID.
+func (fetcher *Fetcher) PeelToCommitID(id oid.ObjectID) (oid.ObjectID, error) {
+ for {
+ ty, _, err := fetcher.Header(id)
+ if err != nil {
+ return oid.ObjectID{}, err
+ }
+
+ switch ty {
+ case typ.TypeCommit:
+ return id, nil
+ case typ.TypeTag:
+ tag, err := fetcher.ExactTag(id)
+ if err != nil {
+ return oid.ObjectID{}, err
+ }
+
+ id = tag.Object().TargetID
+ case typ.TypeUnknown, typ.TypeTree, typ.TypeBlob:
+ return oid.ObjectID{}, &errs.ObjectTypeError{OID: id, Got: ty, Want: typ.TypeCommit}
+ default:
+ return oid.ObjectID{}, &errs.ObjectTypeError{OID: id, Got: ty, Want: typ.TypeCommit}
+ }
+ }
+}
diff --git a/object/fetch/doc.go b/object/fetch/doc.go
new file mode 100644
index 00000000..b5c66f50
--- /dev/null
+++ b/object/fetch/doc.go
@@ -0,0 +1,11 @@
+// Package fetch loads typed Git objects from object storage
+// and provides higher-level object queries.
+//
+// Fetching is above [objectstore]:
+// it parses stored objects
+// into blobs, trees, commits, and tags,
+// exposes object metadata,
+// peels tree-ish or commit-ish objects,
+// resolves paths within trees,
+// and can expose one tree as an [io/fs].
+package fetch
diff --git a/object/fetch/errors.go b/object/fetch/errors.go
new file mode 100644
index 00000000..84a21bbb
--- /dev/null
+++ b/object/fetch/errors.go
@@ -0,0 +1,19 @@
+package fetch
+
+import (
+ "errors"
+
+ "lindenii.org/go/furgit/errs"
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/store"
+)
+
+// wrapObjectReadError maps raw object-store lookup failures to fetcher-level
+// object lookup errors.
+func wrapObjectReadError(id id.ObjectID, err error) error {
+ if errors.Is(err, store.ErrObjectNotFound) {
+ return &errs.ObjectMissingError{OID: id}
+ }
+
+ return err
+}
diff --git a/object/fetch/fetcher.go b/object/fetch/fetcher.go
new file mode 100644
index 00000000..653baf22
--- /dev/null
+++ b/object/fetch/fetcher.go
@@ -0,0 +1,20 @@
+package fetch
+
+import "lindenii.org/go/furgit/object/store"
+
+// Fetcher provides ordinary object access above an object store.
+//
+// It exposes object metadata, typed object loading, tree-ish and commit-ish
+// peeling, path resolution, one-tree fs views, and blob content streaming.
+//
+// Labels: MT-Safe.
+type Fetcher struct {
+ store store.ObjectReader
+}
+
+// New returns a Fetcher that reads objects from store.
+//
+// Labels: Deps-Borrowed, Life-Parent.
+func New(store store.ObjectReader) *Fetcher {
+ return &Fetcher{store: store}
+}
diff --git a/object/fetch/header.go b/object/fetch/header.go
new file mode 100644
index 00000000..d8cc7644
--- /dev/null
+++ b/object/fetch/header.go
@@ -0,0 +1,30 @@
+package fetch
+
+import (
+ oid "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+// Header returns the object type and content size at id.
+//
+// Labels: Life-Parent.
+func (fetcher *Fetcher) Header(id oid.ObjectID) (typ.Type, uint64, error) {
+ ty, size, err := fetcher.store.ReadHeader(id)
+ if err != nil {
+ return typ.TypeUnknown, 0, wrapObjectReadError(id, err)
+ }
+
+ return ty, size, nil
+}
+
+// Size returns the object content size at id.
+//
+// Labels: Life-Parent.
+func (fetcher *Fetcher) Size(id oid.ObjectID) (uint64, error) {
+ size, err := fetcher.store.ReadSize(id)
+ if err != nil {
+ return 0, wrapObjectReadError(id, err)
+ }
+
+ return size, nil
+}
diff --git a/object/fetch/object.go b/object/fetch/object.go
new file mode 100644
index 00000000..fdb46634
--- /dev/null
+++ b/object/fetch/object.go
@@ -0,0 +1,36 @@
+package fetch
+
+import (
+ "fmt"
+
+ "lindenii.org/go/furgit/object"
+ oid "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/stored"
+)
+
+// ExactObject reads, parses, and wraps the object at id without constraining
+// its concrete object kind.
+//
+// Labels: Life-Parent.
+func (fetcher *Fetcher) ExactObject(id oid.ObjectID) (*stored.Stored[object.Object], error) {
+ parsed, err := fetcher.parseObject(id)
+ if err != nil {
+ return nil, err
+ }
+
+ return stored.New(id, parsed), nil
+}
+
+func (fetcher *Fetcher) parseObject(id oid.ObjectID) (object.Object, error) { //nolint:ireturn
+ ty, content, err := fetcher.store.ReadBytesContent(id)
+ if err != nil {
+ return nil, wrapObjectReadError(id, err)
+ }
+
+ parsed, err := object.ParseWithoutHeader(ty, content, id.ObjectFormat())
+ if err != nil {
+ return nil, fmt.Errorf("object/fetch: parse object %s (%s): %w", id, ty.Name(), err)
+ }
+
+ return parsed, nil
+}
diff --git a/object/fetch/path.go b/object/fetch/path.go
new file mode 100644
index 00000000..f8eca507
--- /dev/null
+++ b/object/fetch/path.go
@@ -0,0 +1,91 @@
+package fetch
+
+import (
+ "errors"
+ "fmt"
+
+ oid "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/tree"
+ "lindenii.org/go/furgit/object/tree/mode"
+)
+
+var ErrPathInvalid = errors.New("object/fetch: invalid tree path")
+
+// 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.
+//
+// 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.
+//
+// Labels: Life-Parent.
+func (fetcher *Fetcher) Path(root oid.ObjectID, parts []string) (tree.Entry, error) {
+ if len(parts) == 0 {
+ return tree.Entry{}, ErrPathInvalid
+ }
+
+ current, err := fetcher.PeelToTree(root)
+ if err != nil {
+ return tree.Entry{}, err
+ }
+
+ for i, part := range parts {
+ if len(part) == 0 {
+ return tree.Entry{}, ErrPathInvalid
+ }
+
+ entry, ok := current.Object().Find(part)
+ if !ok {
+ return tree.Entry{}, &PathNotFoundError{
+ Index: i,
+ Name: append([]byte(nil), part...),
+ }
+ }
+
+ if i == len(parts)-1 {
+ return entry, nil
+ }
+
+ if entry.Mode != mode.Directory {
+ return tree.Entry{}, &PathNotTreeError{
+ Index: i,
+ Name: append([]byte(nil), part...),
+ }
+ }
+
+ current, err = fetcher.ExactTree(entry.ID)
+ if err != nil {
+ return tree.Entry{}, err
+ }
+ }
+
+ return tree.Entry{}, &PathNotFoundError{Index: len(parts) - 1}
+}
diff --git a/object/fetch/reader.go b/object/fetch/reader.go
new file mode 100644
index 00000000..8baf1119
--- /dev/null
+++ b/object/fetch/reader.go
@@ -0,0 +1,26 @@
+package fetch
+
+import (
+ "io"
+
+ "lindenii.org/go/furgit/errs"
+ oid "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+// exactReader reads one object's content stream
+// and verifies that its header type matches wantType.
+func (fetcher *Fetcher) exactReader(id oid.ObjectID, wantType typ.Type) (io.ReadCloser, uint64, error) {
+ gotType, size, rc, err := fetcher.store.ReadReaderContent(id)
+ if err != nil {
+ return nil, 0, wrapObjectReadError(id, err)
+ }
+
+ if gotType != wantType {
+ _ = rc.Close()
+
+ return nil, 0, &errs.ObjectTypeError{OID: id, Got: gotType, Want: wantType}
+ }
+
+ return rc, size, nil
+}
diff --git a/object/fetch/tag.go b/object/fetch/tag.go
new file mode 100644
index 00000000..326244d8
--- /dev/null
+++ b/object/fetch/tag.go
@@ -0,0 +1,26 @@
+package fetch
+
+import (
+ "lindenii.org/go/furgit/errs"
+ oid "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/stored"
+ "lindenii.org/go/furgit/object/tag"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+// ExactTag reads, parses, and wraps the tag at id.
+//
+// Labels: Life-Parent.
+func (fetcher *Fetcher) ExactTag(id oid.ObjectID) (*stored.Stored[*tag.Tag], error) {
+ parsed, err := fetcher.parseObject(id)
+ if err != nil {
+ return nil, err
+ }
+
+ tag, ok := parsed.(*tag.Tag)
+ if !ok {
+ return nil, &errs.ObjectTypeError{OID: id, Got: parsed.ObjectType(), Want: typ.TypeTag}
+ }
+
+ return stored.New(id, tag), nil
+}
diff --git a/object/fetch/tree.go b/object/fetch/tree.go
new file mode 100644
index 00000000..add41274
--- /dev/null
+++ b/object/fetch/tree.go
@@ -0,0 +1,86 @@
+package fetch
+
+import (
+ "lindenii.org/go/furgit/errs"
+ "lindenii.org/go/furgit/object/commit"
+ oid "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/stored"
+ "lindenii.org/go/furgit/object/tag"
+ "lindenii.org/go/furgit/object/tree"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+// ExactTree reads, parses, and wraps the tree at id.
+//
+// Labels: Life-Parent.
+func (fetcher *Fetcher) ExactTree(id oid.ObjectID) (*stored.Stored[*tree.Tree], error) {
+ parsed, err := fetcher.parseObject(id)
+ if err != nil {
+ return nil, err
+ }
+
+ tree, ok := parsed.(*tree.Tree)
+ if !ok {
+ return nil, &errs.ObjectTypeError{OID: id, Got: parsed.ObjectType(), Want: typ.TypeTree}
+ }
+
+ return stored.New(id, tree), nil
+}
+
+// PeelToTree peels tags until it reaches a tree or commit. If it reaches a
+// commit, it returns the commit's root tree.
+//
+// Labels: Life-Parent.
+func (fetcher *Fetcher) PeelToTree(id oid.ObjectID) (*stored.Stored[*tree.Tree], error) {
+ for {
+ obj, err := fetcher.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 fetcher.ExactTree(parsed.Tree)
+ case *tag.Tag:
+ id = parsed.TargetID
+ default:
+ return nil, &errs.ObjectTypeError{OID: id, Got: parsed.ObjectType(), Want: typ.TypeTree}
+ }
+ }
+}
+
+// PeelToTreeID peels tags until it reaches a tree object ID, or a commit whose
+// root tree object ID is then returned.
+func (fetcher *Fetcher) PeelToTreeID(id oid.ObjectID) (oid.ObjectID, error) {
+ for {
+ ty, _, err := fetcher.Header(id)
+ if err != nil {
+ return oid.ObjectID{}, err
+ }
+
+ switch ty {
+ case typ.TypeTree:
+ return id, nil
+ case typ.TypeCommit:
+ commit, err := fetcher.ExactCommit(id)
+ if err != nil {
+ return oid.ObjectID{}, err
+ }
+
+ return commit.Object().Tree, nil
+ case typ.TypeTag:
+ tag, err := fetcher.ExactTag(id)
+ if err != nil {
+ return oid.ObjectID{}, err
+ }
+
+ id = tag.Object().TargetID
+ case typ.TypeUnknown, typ.TypeBlob:
+ return oid.ObjectID{}, &errs.ObjectTypeError{OID: id, Got: ty, Want: typ.TypeTree}
+ default:
+ return oid.ObjectID{}, &errs.ObjectTypeError{OID: id, Got: ty, Want: typ.TypeTree}
+ }
+ }
+}
diff --git a/object/fetch/treefs.go b/object/fetch/treefs.go
new file mode 100644
index 00000000..6a70f98f
--- /dev/null
+++ b/object/fetch/treefs.go
@@ -0,0 +1,438 @@
+package fetch
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "strings"
+ "time"
+
+ oid "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/tree"
+ "lindenii.org/go/furgit/object/tree/mode"
+ "lindenii.org/go/lgo/intconv"
+)
+
+// 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)
+)
+
+func splitPath(path string) []string {
+ if len(path) == 0 {
+ return nil
+ }
+
+ return strings.Split(path, "/")
+}
+
+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) (uint64, 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)
+ }
+
+ 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: 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, fmt.Errorf("object/fetch: gitlink entries are not readable as files"))
+ }
+
+ 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)
+ }
+
+ return readDirFile.ReadDir(-1)
+}
+
+// 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 == mode.Gitlink {
+ return nil, treeFSPathError(treeFSOpReadFile, name, fmt.Errorf("object/fetch: gitlink entries are not readable as files"))
+ }
+
+ 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: 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, err = intconv.Uint64ToInt64(sz)
+ 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_test.go b/object/fetch/treefs_test.go
new file mode 100644
index 00000000..73f1c591
--- /dev/null
+++ b/object/fetch/treefs_test.go
@@ -0,0 +1,172 @@
+package fetch_test
+
+import (
+ "errors"
+ "io/fs"
+ "testing"
+
+ "lindenii.org/go/furgit/object/commit"
+ "lindenii.org/go/furgit/object/fetch"
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/signature"
+ "lindenii.org/go/furgit/object/store/memory"
+ "lindenii.org/go/furgit/object/tree"
+ "lindenii.org/go/furgit/object/tree/mode"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+func TestTreeFS(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ store := memory.New(objectFormat)
+
+ plainID, err := store.WriteBytesContent(typ.TypeBlob, []byte("plain\n"))
+ if err != nil {
+ t.Fatalf("WriteBytesContent(plain.txt): %v", err)
+ }
+
+ execID, err := store.WriteBytesContent(typ.TypeBlob, []byte("#!/bin/sh\nexit 0\n"))
+ if err != nil {
+ t.Fatalf("WriteBytesContent(exec.sh): %v", err)
+ }
+
+ subTreeID := writeTree(t, store, []tree.Entry{
+ {Mode: mode.Executable, Name: "exec.sh", ID: execID},
+ })
+
+ rootTreeID := writeTree(t, store, []tree.Entry{
+ {Mode: mode.Regular, Name: "plain.txt", ID: plainID},
+ {Mode: mode.Directory, Name: "dir", ID: subTreeID},
+ })
+
+ commitID := writeCommit(t, store, rootTreeID)
+
+ fetcher := fetch.New(store)
+
+ treeFS, err := fetcher.TreeFS(commitID)
+ if err != nil {
+ t.Fatalf("fetcher.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.Entry)
+ if !ok {
+ t.Fatalf("Stat(plain.txt).Sys() type = %T, want tree.Entry", info.Sys())
+ }
+
+ if entry.Mode != mode.Regular {
+ t.Fatalf("Stat(plain.txt).Sys().Mode = %o, want %o", entry.Mode, mode.Regular)
+ }
+
+ 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)
+ }
+ })
+ }
+}
+
+// writeTree builds a tree object from entries, writes it to store,
+// and returns its object ID.
+func writeTree(t *testing.T, store *memory.Memory, entries []tree.Entry) id.ObjectID {
+ t.Helper()
+
+ tr := new(tree.Tree)
+ for _, entry := range entries {
+ err := tr.Insert(entry)
+ if err != nil {
+ t.Fatalf("tree.Insert(%q): %v", entry.Name, err)
+ }
+ }
+
+ body, err := tr.AppendWithoutHeader(nil)
+ if err != nil {
+ t.Fatalf("tree.AppendWithoutHeader: %v", err)
+ }
+
+ treeID, err := store.WriteBytesContent(typ.TypeTree, body)
+ if err != nil {
+ t.Fatalf("WriteBytesContent(tree): %v", err)
+ }
+
+ return treeID
+}
+
+// writeCommit builds a commit object pointing at tree, writes it to store,
+// and returns its object ID.
+func writeCommit(t *testing.T, store *memory.Memory, tree id.ObjectID) id.ObjectID {
+ t.Helper()
+
+ who := signature.Signature{
+ Name: []byte("Test Author"),
+ Email: []byte("author@example.org"),
+ WhenUnix: 1234567890,
+ OffsetMinutes: 0,
+ }
+
+ body, err := (&commit.Commit{
+ Tree: tree,
+ Author: who,
+ Committer: who,
+ Message: []byte("treefs\n"),
+ }).AppendWithoutHeader(nil)
+ if err != nil {
+ t.Fatalf("commit.AppendWithoutHeader: %v", err)
+ }
+
+ commitID, err := store.WriteBytesContent(typ.TypeCommit, body)
+ if err != nil {
+ t.Fatalf("WriteBytesContent(commit): %v", err)
+ }
+
+ return commitID
+}
diff --git a/object/stored/doc.go b/object/stored/doc.go
new file mode 100644
index 00000000..93a6021b
--- /dev/null
+++ b/object/stored/doc.go
@@ -0,0 +1,8 @@
+// Package stored wraps parsed objects
+// with the object IDs they were loaded under.
+//
+// Parsed git object values do not carry storage identity on their own.
+// This package provides a small generic wrapper
+// for the common case where callers need
+// both the parsed object value and the object ID it was read from.
+package stored
diff --git a/object/stored/stored.go b/object/stored/stored.go
new file mode 100644
index 00000000..68a7bfd0
--- /dev/null
+++ b/object/stored/stored.go
@@ -0,0 +1,28 @@
+package stored
+
+import (
+ "lindenii.org/go/furgit/object"
+ "lindenii.org/go/furgit/object/id"
+)
+
+// Stored represents a stored object,
+// i.e., an object along with its object ID.
+type Stored[T object.Object] struct {
+ id id.ObjectID
+ obj T
+}
+
+// New creates one stored object wrapper.
+func New[T object.Object](id id.ObjectID, obj T) *Stored[T] {
+ return &Stored[T]{id: id, obj: obj}
+}
+
+// Object returns the wrapped object as itself.
+func (stored *Stored[T]) Object() T { //nolint:ireturn
+ return stored.obj
+}
+
+// ID returns the object ID.
+func (stored *Stored[T]) ID() id.ObjectID {
+ return stored.id
+}