shithub: furgit

Download patch

ref: 3c7add2cf4154c54c42d348bc462e29198e69338
parent: ba8c85ed2456c59269214f6e4f1203537fb3f6d4
author: Runxi Yu <runxiyu@umich.edu>
date: Thu Mar 19 12:19:24 EDT 2026

object/resolve: Object resolver

--- a/cmd/show-object/main.go
+++ b/cmd/show-object/main.go
@@ -51,7 +51,7 @@
 		return fmt.Errorf("resolve %q: %w", *name, err)
 	}
 
-	s, err := repo.ReadStored(id)
+	s, err := repo.Resolver().ExactObject(id)
 	if err != nil {
 		_ = repo.Close()
 
--- /dev/null
+++ b/object/resolve/doc.go
@@ -1,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 Resolver does not take ownership of the underlying object store.
+package resolve
--- /dev/null
+++ b/object/resolve/exact_blob.go
@@ -1,0 +1,24 @@
+package resolve
+
+import (
+	"fmt"
+
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/object/stored"
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// ExactBlob reads, parses, and wraps the blob at id.
+func (r *Resolver) ExactBlob(id objectid.ObjectID) (*stored.Stored[*object.Blob], error) {
+	parsed, err := r.parseObject(id)
+	if err != nil {
+		return nil, err
+	}
+
+	blob, ok := parsed.(*object.Blob)
+	if !ok {
+		return nil, fmt.Errorf("object/resolve: expected blob object %s, got %v", id, parsed.ObjectType())
+	}
+
+	return stored.New(id, blob), nil
+}
--- /dev/null
+++ b/object/resolve/exact_blob_reader.go
@@ -1,0 +1,14 @@
+package resolve
+
+import (
+	"io"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+// ExactBlobReader returns a reader for the content of the blob at id,
+// together with its content size in bytes.
+func (r *Resolver) ExactBlobReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+	return r.exactReader(id, objecttype.TypeBlob, "blob")
+}
--- /dev/null
+++ b/object/resolve/exact_commit.go
@@ -1,0 +1,24 @@
+package resolve
+
+import (
+	"fmt"
+
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/object/stored"
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// ExactCommit reads, parses, and wraps the commit at id.
+func (r *Resolver) ExactCommit(id objectid.ObjectID) (*stored.Stored[*object.Commit], error) {
+	parsed, err := r.parseObject(id)
+	if err != nil {
+		return nil, err
+	}
+
+	commit, ok := parsed.(*object.Commit)
+	if !ok {
+		return nil, fmt.Errorf("object/resolve: expected commit object %s, got %v", id, parsed.ObjectType())
+	}
+
+	return stored.New(id, commit), nil
+}
--- /dev/null
+++ b/object/resolve/exact_commit_reader.go
@@ -1,0 +1,16 @@
+package resolve
+
+import (
+	"io"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+// 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 *Resolver) ExactCommitReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+	return r.exactReader(id, objecttype.TypeCommit, "commit")
+}
--- /dev/null
+++ b/object/resolve/exact_object.go
@@ -1,0 +1,18 @@
+package resolve
+
+import (
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/object/stored"
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// ExactObject reads, parses, and wraps the object at id without constraining
+// its concrete object kind.
+func (r *Resolver) 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
+}
--- /dev/null
+++ b/object/resolve/exact_reader.go
@@ -1,0 +1,26 @@
+package resolve
+
+import (
+	"fmt"
+	"io"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+// exactReader reads one object's content stream and verifies that its header
+// type matches wantType.
+func (r *Resolver) 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/resolve: expected %s object %s, got %v", wantName, id, gotType)
+	}
+
+	return rc, size, nil
+}
--- /dev/null
+++ b/object/resolve/exact_tag.go
@@ -1,0 +1,24 @@
+package resolve
+
+import (
+	"fmt"
+
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/object/stored"
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// ExactTag reads, parses, and wraps the tag at id.
+func (r *Resolver) ExactTag(id objectid.ObjectID) (*stored.Stored[*object.Tag], error) {
+	parsed, err := r.parseObject(id)
+	if err != nil {
+		return nil, err
+	}
+
+	tag, ok := parsed.(*object.Tag)
+	if !ok {
+		return nil, fmt.Errorf("object/resolve: expected tag object %s, got %v", id, parsed.ObjectType())
+	}
+
+	return stored.New(id, tag), nil
+}
--- /dev/null
+++ b/object/resolve/exact_tag_reader.go
@@ -1,0 +1,16 @@
+package resolve
+
+import (
+	"io"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+// 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 *Resolver) ExactTagReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+	return r.exactReader(id, objecttype.TypeTag, "tag")
+}
--- /dev/null
+++ b/object/resolve/exact_tree.go
@@ -1,0 +1,24 @@
+package resolve
+
+import (
+	"fmt"
+
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/object/stored"
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// ExactTree reads, parses, and wraps the tree at id.
+func (r *Resolver) ExactTree(id objectid.ObjectID) (*stored.Stored[*object.Tree], error) {
+	parsed, err := r.parseObject(id)
+	if err != nil {
+		return nil, err
+	}
+
+	tree, ok := parsed.(*object.Tree)
+	if !ok {
+		return nil, fmt.Errorf("object/resolve: expected tree object %s, got %v", id, parsed.ObjectType())
+	}
+
+	return stored.New(id, tree), nil
+}
--- /dev/null
+++ b/object/resolve/exact_tree_reader.go
@@ -1,0 +1,16 @@
+package resolve
+
+import (
+	"io"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+// 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 *Resolver) ExactTreeReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+	return r.exactReader(id, objecttype.TypeTree, "tree")
+}
--- /dev/null
+++ b/object/resolve/object_parse.go
@@ -1,0 +1,28 @@
+package resolve
+
+import (
+	"fmt"
+
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+func (r *Resolver) 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/resolve: parse object %s (%s): %w", id, tyName, err)
+	}
+
+	return parsed, nil
+}
--- /dev/null
+++ b/object/resolve/path.go
@@ -1,0 +1,53 @@
+package resolve
+
+import (
+	"fmt"
+
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// 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.
+func (r *Resolver) Path(root objectid.ObjectID, parts [][]byte) (object.TreeEntry, error) {
+	if len(parts) == 0 {
+		return object.TreeEntry{}, fmt.Errorf("object/resolve: empty tree path")
+	}
+
+	current, err := r.PeelToTree(root)
+	if err != nil {
+		return object.TreeEntry{}, err
+	}
+
+	for i, part := range parts {
+		if len(part) == 0 {
+			return object.TreeEntry{}, fmt.Errorf("object/resolve: empty tree path segment")
+		}
+
+		entry := current.Object().Entry(part)
+		if entry == nil {
+			return object.TreeEntry{}, fmt.Errorf("object/resolve: tree entry %q not found", part)
+		}
+
+		if i == len(parts)-1 {
+			return *entry, nil
+		}
+
+		if entry.Mode != object.FileModeDir {
+			return object.TreeEntry{}, fmt.Errorf("object/resolve: path segment %q is not a tree", part)
+		}
+
+		current, err = r.ExactTree(entry.ID)
+		if err != nil {
+			return object.TreeEntry{}, err
+		}
+	}
+
+	return object.TreeEntry{}, fmt.Errorf("object/resolve: tree entry not found")
+}
--- /dev/null
+++ b/object/resolve/peel_to_blob.go
@@ -1,0 +1,28 @@
+package resolve
+
+import (
+	"fmt"
+
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/object/stored"
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// PeelToBlob peels tags until it reaches a blob.
+func (r *Resolver) PeelToBlob(id objectid.ObjectID) (*stored.Stored[*object.Blob], error) {
+	for {
+		obj, err := r.ExactObject(id)
+		if err != nil {
+			return nil, err
+		}
+
+		switch parsed := obj.Object().(type) {
+		case *object.Blob:
+			return stored.New(id, parsed), nil
+		case *object.Tag:
+			id = parsed.Target
+		default:
+			return nil, fmt.Errorf("object/resolve: expected blob-ish object %s, got %v", id, parsed.ObjectType())
+		}
+	}
+}
--- /dev/null
+++ b/object/resolve/peel_to_blob_id.go
@@ -1,0 +1,32 @@
+package resolve
+
+import (
+	"fmt"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+// PeelToBlobID peels tags until it reaches a blob object ID.
+func (r *Resolver) 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
+		default:
+			return objectid.ObjectID{}, fmt.Errorf("object/resolve: expected blob-ish object %s, got %v", id, ty)
+		}
+	}
+}
--- /dev/null
+++ b/object/resolve/peel_to_blob_reader.go
@@ -1,0 +1,18 @@
+package resolve
+
+import (
+	"io"
+
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// PeelToBlobReader returns a reader for the content of the peeled blob at id,
+// together with its content size in bytes.
+func (r *Resolver) PeelToBlobReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+	blobID, err := r.PeelToBlobID(id)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	return r.ExactBlobReader(blobID)
+}
--- /dev/null
+++ b/object/resolve/peel_to_commit.go
@@ -1,0 +1,28 @@
+package resolve
+
+import (
+	"fmt"
+
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/object/stored"
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// PeelToCommit peels tags until it reaches a commit.
+func (r *Resolver) PeelToCommit(id objectid.ObjectID) (*stored.Stored[*object.Commit], error) {
+	for {
+		obj, err := r.ExactObject(id)
+		if err != nil {
+			return nil, err
+		}
+
+		switch parsed := obj.Object().(type) {
+		case *object.Commit:
+			return stored.New(id, parsed), nil
+		case *object.Tag:
+			id = parsed.Target
+		default:
+			return nil, fmt.Errorf("object/resolve: expected commit-ish object %s, got %v", id, parsed.ObjectType())
+		}
+	}
+}
--- /dev/null
+++ b/object/resolve/peel_to_commit_id.go
@@ -1,0 +1,32 @@
+package resolve
+
+import (
+	"fmt"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+// PeelToCommitID peels tags until it reaches a commit object ID.
+func (r *Resolver) 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
+		default:
+			return objectid.ObjectID{}, fmt.Errorf("object/resolve: expected commit-ish object %s, got %v", id, ty)
+		}
+	}
+}
--- /dev/null
+++ b/object/resolve/peel_to_commit_reader.go
@@ -1,0 +1,20 @@
+package resolve
+
+import (
+	"io"
+
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// 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 *Resolver) PeelToCommitReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+	commitID, err := r.PeelToCommitID(id)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	return r.ExactCommitReader(commitID)
+}
--- /dev/null
+++ b/object/resolve/peel_to_tag.go
@@ -1,0 +1,12 @@
+package resolve
+
+import (
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/object/stored"
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// PeelToTag returns the tag at id without further peeling.
+func (r *Resolver) PeelToTag(id objectid.ObjectID) (*stored.Stored[*object.Tag], error) {
+	return r.ExactTag(id)
+}
--- /dev/null
+++ b/object/resolve/peel_to_tag_id.go
@@ -1,0 +1,8 @@
+package resolve
+
+import "codeberg.org/lindenii/furgit/objectid"
+
+// PeelToTagID returns id unchanged.
+func (r *Resolver) PeelToTagID(id objectid.ObjectID) (objectid.ObjectID, error) {
+	return id, nil
+}
--- /dev/null
+++ b/object/resolve/peel_to_tag_reader.go
@@ -1,0 +1,20 @@
+package resolve
+
+import (
+	"io"
+
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// 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 *Resolver) PeelToTagReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+	tagID, err := r.PeelToTagID(id)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	return r.ExactTagReader(tagID)
+}
--- /dev/null
+++ b/object/resolve/peel_to_tree.go
@@ -1,0 +1,31 @@
+package resolve
+
+import (
+	"fmt"
+
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/object/stored"
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// PeelToTree peels tags until it reaches a tree or commit. If it reaches a
+// commit, it returns the commit's root tree.
+func (r *Resolver) PeelToTree(id objectid.ObjectID) (*stored.Stored[*object.Tree], error) {
+	for {
+		obj, err := r.ExactObject(id)
+		if err != nil {
+			return nil, err
+		}
+
+		switch parsed := obj.Object().(type) {
+		case *object.Tree:
+			return stored.New(id, parsed), nil
+		case *object.Commit:
+			return r.ExactTree(parsed.Tree)
+		case *object.Tag:
+			id = parsed.Target
+		default:
+			return nil, fmt.Errorf("object/resolve: expected tree-ish object %s, got %v", id, parsed.ObjectType())
+		}
+	}
+}
--- /dev/null
+++ b/object/resolve/peel_to_tree_id.go
@@ -1,0 +1,40 @@
+package resolve
+
+import (
+	"fmt"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+// PeelToTreeID peels tags until it reaches a tree object ID, or a commit whose
+// root tree object ID is then returned.
+func (r *Resolver) 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
+		default:
+			return objectid.ObjectID{}, fmt.Errorf("object/resolve: expected tree-ish object %s, got %v", id, ty)
+		}
+	}
+}
--- /dev/null
+++ b/object/resolve/peel_to_tree_reader.go
@@ -1,0 +1,20 @@
+package resolve
+
+import (
+	"io"
+
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// 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 *Resolver) PeelToTreeReader(id objectid.ObjectID) (io.ReadCloser, int64, error) {
+	treeID, err := r.PeelToTreeID(id)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	return r.ExactTreeReader(treeID)
+}
--- /dev/null
+++ b/object/resolve/resolver.go
@@ -1,0 +1,17 @@
+package resolve
+
+import "codeberg.org/lindenii/furgit/objectstore"
+
+// Resolver resolves parsed and streamed objects from an object store.
+//
+// A Resolver does not take ownership of the store and does not close it.
+type Resolver struct {
+	store objectstore.Store
+}
+
+// New returns a Resolver that reads objects from store.
+//
+// The returned Resolver does not take ownership of store.
+func New(store objectstore.Store) *Resolver {
+	return &Resolver{store: store}
+}
--- /dev/null
+++ b/object/type.go
@@ -1,0 +1,20 @@
+package object
+
+import "codeberg.org/lindenii/furgit/objecttype"
+
+// TypeFor returns the Git object type for T when T is one of the standard
+// parsed object types.
+func TypeFor[T Object]() (objecttype.Type, bool) {
+	switch any(*new(T)).(type) {
+	case *Blob:
+		return objecttype.TypeBlob, true
+	case *Tree:
+		return objecttype.TypeTree, true
+	case *Commit:
+		return objecttype.TypeCommit, true
+	case *Tag:
+		return objecttype.TypeTag, true
+	default:
+		return 0, false
+	}
+}
--- /dev/null
+++ b/repository/resolver.go
@@ -1,0 +1,11 @@
+package repository
+
+import "codeberg.org/lindenii/furgit/object/resolve"
+
+// Resolver returns an object resolver backed by the repository's object store.
+//
+// The returned resolver is ready for use and does not take ownership of the
+// repository or its underlying object store.
+func (repo *Repository) Resolver() *resolve.Resolver {
+	return resolve.New(repo.objects)
+}
--- a/repository/stored.go
+++ /dev/null
@@ -1,76 +1,0 @@
-package repository
-
-import (
-	"fmt"
-
-	"codeberg.org/lindenii/furgit/object"
-	"codeberg.org/lindenii/furgit/object/stored"
-	"codeberg.org/lindenii/furgit/objectid"
-	"codeberg.org/lindenii/furgit/objecttype"
-)
-
-// ReadStored reads, parses, and wraps one object by ID.
-func (repo *Repository) ReadStored(id objectid.ObjectID) (*stored.Stored[object.Object], error) {
-	parsed, err := repo.readParsedObject(id)
-	if err != nil {
-		return nil, err
-	}
-
-	return stored.New(id, parsed), nil
-}
-
-// ReadStoredBlob reads and parses a blob object by ID.
-func (repo *Repository) ReadStoredBlob(id objectid.ObjectID) (*stored.Stored[*object.Blob], error) {
-	return readStoredAs[*object.Blob](repo, id)
-}
-
-// ReadStoredTree reads and parses a tree object by ID.
-func (repo *Repository) ReadStoredTree(id objectid.ObjectID) (*stored.Stored[*object.Tree], error) {
-	return readStoredAs[*object.Tree](repo, id)
-}
-
-// ReadStoredCommit reads and parses a commit object by ID.
-func (repo *Repository) ReadStoredCommit(id objectid.ObjectID) (*stored.Stored[*object.Commit], error) {
-	return readStoredAs[*object.Commit](repo, id)
-}
-
-// ReadStoredTag reads and parses a tag object by ID.
-func (repo *Repository) ReadStoredTag(id objectid.ObjectID) (*stored.Stored[*object.Tag], error) {
-	return readStoredAs[*object.Tag](repo, id)
-}
-
-// readParsedObject reads bytes content from storage and parses one object.
-//
-//nolint:ireturn
-func (repo *Repository) readParsedObject(id objectid.ObjectID) (object.Object, error) {
-	ty, content, err := repo.objects.ReadBytesContent(id)
-	if err != nil {
-		return nil, err
-	}
-
-	parsed, err := object.ParseObjectWithoutHeader(ty, content, repo.algo)
-	if err != nil {
-		tyName, ok := objecttype.Name(ty)
-		if !ok {
-			tyName = fmt.Sprintf("type %d", ty)
-		}
-
-		return nil, fmt.Errorf("repository: parse object %s (%s): %w", id, tyName, err)
-	}
-
-	return parsed, nil
-}
-
-func readStoredAs[T object.Object](repo *Repository, id objectid.ObjectID) (*stored.Stored[T], error) {
-	parsed, err := repo.readParsedObject(id)
-	if err != nil {
-		return nil, err
-	}
-
-	typed, ok := parsed.(T)
-	if !ok {
-		return nil, fmt.Errorf("repository: expected %T object %s, got %v", *new(T), id, parsed.ObjectType())
-	}
-
-	return stored.New(id, typed), nil
-}
--- a/repository/stored_test.go
+++ b/repository/stored_test.go
@@ -24,9 +24,9 @@
 
 		repo := repoHarness.OpenRepository(t)
 
-		blob, err := repo.ReadStoredBlob(blobID)
+		blob, err := repo.Resolver().ExactBlob(blobID)
 		if err != nil {
-			t.Fatalf("ReadStoredBlob: %v", err)
+			t.Fatalf("ExactBlob: %v", err)
 		}
 
 		if blob.ID() != blobID {
@@ -37,9 +37,9 @@
 			t.Fatalf("blob body = %q, want %q", blob.Object().Data, "commit-body\n")
 		}
 
-		tree, err := repo.ReadStoredTree(treeID)
+		tree, err := repo.Resolver().ExactTree(treeID)
 		if err != nil {
-			t.Fatalf("ReadStoredTree: %v", err)
+			t.Fatalf("ExactTree: %v", err)
 		}
 
 		if tree.ID() != treeID {
@@ -50,9 +50,9 @@
 			t.Fatalf("tree entries = %d, want 1", len(tree.Object().Entries))
 		}
 
-		commit, err := repo.ReadStoredCommit(commitID)
+		commit, err := repo.Resolver().ExactCommit(commitID)
 		if err != nil {
-			t.Fatalf("ReadStoredCommit: %v", err)
+			t.Fatalf("ExactCommit: %v", err)
 		}
 
 		if commit.ID() != commitID {
@@ -65,7 +65,7 @@
 	})
 }
 
-func TestResolveTreeEntry(t *testing.T) {
+func TestResolverPath(t *testing.T) {
 	t.Parallel()
 
 	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
@@ -81,27 +81,22 @@
 
 		repo := repoHarness.OpenRepository(t)
 
-		rootTree, err := repo.ReadStoredTree(rootTreeID)
+		entry, err := repo.Resolver().Path(rootTreeID, [][]byte{[]byte("dir"), []byte("leaf.txt")})
 		if err != nil {
-			t.Fatalf("ReadStoredTree(root): %v", err)
+			t.Fatalf("Path: %v", err)
 		}
 
-		entry, err := repo.ResolveTreeEntry(rootTree, [][]byte{[]byte("dir"), []byte("leaf.txt")})
-		if err != nil {
-			t.Fatalf("ResolveTreeEntry: %v", err)
-		}
-
 		if entry.Mode != object.FileModeRegular {
-			t.Fatalf("ResolveTreeEntry mode = %o, want %o", entry.Mode, object.FileModeRegular)
+			t.Fatalf("Path mode = %o, want %o", entry.Mode, object.FileModeRegular)
 		}
 
 		if entry.ID != blobID {
-			t.Fatalf("ResolveTreeEntry id = %s, want %s", entry.ID, blobID)
+			t.Fatalf("Path id = %s, want %s", entry.ID, blobID)
 		}
 	})
 }
 
-func TestResolveTreeEntryErrors(t *testing.T) {
+func TestResolverPathErrors(t *testing.T) {
 	t.Parallel()
 
 	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
@@ -117,14 +112,9 @@
 
 			repo := repoHarness.OpenRepository(t)
 
-			rootTree, err := repo.ReadStoredTree(rootTreeID)
-			if err != nil {
-				t.Fatalf("ReadStoredTree(root): %v", err)
-			}
-
-			_, err = repo.ResolveTreeEntry(rootTree, [][]byte{[]byte("missing")})
+			_, err := repo.Resolver().Path(rootTreeID, [][]byte{[]byte("missing")})
 			if err == nil || !strings.Contains(err.Error(), "not found") {
-				t.Fatalf("ResolveTreeEntry missing: err = %v, want not found error", err)
+				t.Fatalf("Path missing: err = %v, want not found error", err)
 			}
 		})
 
@@ -140,20 +130,15 @@
 
 			repo := repoHarness.OpenRepository(t)
 
-			rootTree, err := repo.ReadStoredTree(rootTreeID)
-			if err != nil {
-				t.Fatalf("ReadStoredTree(root): %v", err)
-			}
-
-			_, err = repo.ResolveTreeEntry(rootTree, [][]byte{[]byte("dir"), []byte("leaf")})
+			_, err := repo.Resolver().Path(rootTreeID, [][]byte{[]byte("dir"), []byte("leaf")})
 			if err == nil || !strings.Contains(err.Error(), "is not a tree") {
-				t.Fatalf("ResolveTreeEntry non-tree: err = %v, want non-tree error", err)
+				t.Fatalf("Path non-tree: err = %v, want non-tree error", err)
 			}
 		})
 	})
 }
 
-func TestResolveTreeEntryDeepPath(t *testing.T) {
+func TestResolverPathDeepPath(t *testing.T) {
 	t.Parallel()
 
 	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
@@ -179,22 +164,17 @@
 
 		repo := repoHarness.OpenRepository(t)
 
-		rootTree, err := repo.ReadStoredTree(currentTree)
+		entry, err := repo.Resolver().Path(currentTree, parts)
 		if err != nil {
-			t.Fatalf("ReadStoredTree(root): %v", err)
+			t.Fatalf("Path(deep): %v", err)
 		}
 
-		entry, err := repo.ResolveTreeEntry(rootTree, parts)
-		if err != nil {
-			t.Fatalf("ResolveTreeEntry(deep): %v", err)
-		}
-
 		if entry.Mode != object.FileModeRegular {
-			t.Fatalf("ResolveTreeEntry(deep) mode = %o, want %o", entry.Mode, object.FileModeRegular)
+			t.Fatalf("Path(deep) mode = %o, want %o", entry.Mode, object.FileModeRegular)
 		}
 
 		if entry.ID != leafBlobID {
-			t.Fatalf("ResolveTreeEntry(deep) id = %s, want %s", entry.ID, leafBlobID)
+			t.Fatalf("Path(deep) id = %s, want %s", entry.ID, leafBlobID)
 		}
 	})
 }
@@ -227,9 +207,9 @@
 
 		repo := repoHarness.OpenRepository(t)
 
-		rootTree, err := repo.ReadStoredTree(rootTreeID)
+		rootTree, err := repo.Resolver().ExactTree(rootTreeID)
 		if err != nil {
-			t.Fatalf("ReadStoredTree(root): %v", err)
+			t.Fatalf("ExactTree(root): %v", err)
 		}
 
 		expect := map[string]object.FileMode{
--- a/repository/traversal_test.go
+++ b/repository/traversal_test.go
@@ -175,7 +175,7 @@
 
 		visited[id] = struct{}{}
 
-		stored, err := repo.ReadStored(id)
+		stored, err := repo.Resolver().ExactObject(id)
 		if err != nil {
 			return 0, err
 		}
--- a/repository/tree.go
+++ /dev/null
@@ -1,53 +1,0 @@
-package repository
-
-import (
-	"errors"
-	"fmt"
-
-	"codeberg.org/lindenii/furgit/object"
-	"codeberg.org/lindenii/furgit/object/stored"
-)
-
-// ResolveTreeEntry resolves one path within a stored root tree.
-//
-// parts must contain at least one path segment. Intermediate segments must be
-// tree entries.
-func (repo *Repository) ResolveTreeEntry(tree *stored.Stored[*object.Tree], parts [][]byte) (object.TreeEntry, error) {
-	if tree == nil {
-		return object.TreeEntry{}, errors.New("repository: nil root tree")
-	}
-
-	if len(parts) == 0 {
-		return object.TreeEntry{}, errors.New("repository: empty tree path")
-	}
-
-	current := tree
-
-	for i, part := range parts {
-		if len(part) == 0 {
-			return object.TreeEntry{}, errors.New("repository: empty tree path segment")
-		}
-
-		entry := current.Object().Entry(part)
-		if entry == nil {
-			return object.TreeEntry{}, fmt.Errorf("repository: tree entry %q not found", part)
-		}
-
-		if i == len(parts)-1 {
-			return *entry, nil
-		}
-
-		if entry.Mode != object.FileModeDir {
-			return object.TreeEntry{}, fmt.Errorf("repository: path segment %q is not a tree", part)
-		}
-
-		next, err := repo.ReadStoredTree(entry.ID)
-		if err != nil {
-			return object.TreeEntry{}, err
-		}
-
-		current = next
-	}
-
-	return object.TreeEntry{}, fmt.Errorf("repository: tree entry not found")
-}
--