ref: 3011c5e84e9c05bfabe0a5f24b8b267b4bd23912
parent: b7f2a4c02012af6f08aa74199e29aacd6d3712d9
author: Runxi Yu <me@runxiyu.org>
date: Sat Feb 21 09:56:56 EST 2026
repository, objectstored: Add Stored interface and implementations
--- /dev/null
+++ b/objectstored/stored.go
@@ -1,0 +1,119 @@
+// Package objectstored wraps parsed objects with their storage object IDs.
+package objectstored
+
+import (
+ "codeberg.org/lindenii/furgit/object"
+ "codeberg.org/lindenii/furgit/objectid"
+)
+
+// StoredObject is a parsed object paired with its storage ID.
+type StoredObject interface {+ // ID returns the object ID the object was loaded from.
+ ID() objectid.ObjectID
+ // Object returns the parsed object value.
+ Object() object.Object
+}
+
+// StoredBlob is a parsed blob paired with its storage ID.
+type StoredBlob struct {+ id objectid.ObjectID
+ blob *object.Blob
+}
+
+// NewStoredBlob creates one stored blob wrapper.
+func NewStoredBlob(id objectid.ObjectID, blob *object.Blob) *StoredBlob {+ return &StoredBlob{id: id, blob: blob}+}
+
+// ID returns the object ID this blob was loaded from.
+func (stored *StoredBlob) ID() objectid.ObjectID {+ return stored.id
+}
+
+// Object returns the parsed blob as the generic object interface.
+func (stored *StoredBlob) Object() object.Object {+ return stored.blob
+}
+
+// Blob returns the parsed blob value.
+func (stored *StoredBlob) Blob() *object.Blob {+ return stored.blob
+}
+
+// StoredTree is a parsed tree paired with its storage ID.
+type StoredTree struct {+ id objectid.ObjectID
+ tree *object.Tree
+}
+
+// NewStoredTree creates one stored tree wrapper.
+func NewStoredTree(id objectid.ObjectID, tree *object.Tree) *StoredTree {+ return &StoredTree{id: id, tree: tree}+}
+
+// ID returns the object ID this tree was loaded from.
+func (stored *StoredTree) ID() objectid.ObjectID {+ return stored.id
+}
+
+// Object returns the parsed tree as the generic object interface.
+func (stored *StoredTree) Object() object.Object {+ return stored.tree
+}
+
+// Tree returns the parsed tree value.
+func (stored *StoredTree) Tree() *object.Tree {+ return stored.tree
+}
+
+// StoredCommit is a parsed commit paired with its storage ID.
+type StoredCommit struct {+ id objectid.ObjectID
+ commit *object.Commit
+}
+
+// NewStoredCommit creates one stored commit wrapper.
+func NewStoredCommit(id objectid.ObjectID, commit *object.Commit) *StoredCommit {+ return &StoredCommit{id: id, commit: commit}+}
+
+// ID returns the object ID this commit was loaded from.
+func (stored *StoredCommit) ID() objectid.ObjectID {+ return stored.id
+}
+
+// Object returns the parsed commit as the generic object interface.
+func (stored *StoredCommit) Object() object.Object {+ return stored.commit
+}
+
+// Commit returns the parsed commit value.
+func (stored *StoredCommit) Commit() *object.Commit {+ return stored.commit
+}
+
+// StoredTag is a parsed tag paired with its storage ID.
+type StoredTag struct {+ id objectid.ObjectID
+ tag *object.Tag
+}
+
+// NewStoredTag creates one stored tag wrapper.
+func NewStoredTag(id objectid.ObjectID, tag *object.Tag) *StoredTag {+ return &StoredTag{id: id, tag: tag}+}
+
+// ID returns the object ID this tag was loaded from.
+func (stored *StoredTag) ID() objectid.ObjectID {+ return stored.id
+}
+
+// Object returns the parsed tag as the generic object interface.
+func (stored *StoredTag) Object() object.Object {+ return stored.tag
+}
+
+// Tag returns the parsed tag value.
+func (stored *StoredTag) Tag() *object.Tag {+ return stored.tag
+}
--- /dev/null
+++ b/repository/read_stored.go
@@ -1,0 +1,99 @@
+package repository
+
+import (
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/object"
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/objectstored"
+ "codeberg.org/lindenii/furgit/objecttype"
+)
+
+// ReadStored reads, parses, and wraps one object by ID.
+func (repo *Repository) ReadStored(id objectid.ObjectID) (objectstored.StoredObject, error) {+ parsed, err := repo.readParsedObject(id)
+ if err != nil {+ return nil, err
+ }
+ switch parsed := parsed.(type) {+ case *object.Blob:
+ return objectstored.NewStoredBlob(id, parsed), nil
+ case *object.Tree:
+ return objectstored.NewStoredTree(id, parsed), nil
+ case *object.Commit:
+ return objectstored.NewStoredCommit(id, parsed), nil
+ case *object.Tag:
+ return objectstored.NewStoredTag(id, parsed), nil
+ default:
+ return nil, fmt.Errorf("repository: unsupported parsed object type %T", parsed)+ }
+}
+
+// ReadStoredBlob reads and parses a blob object by ID.
+func (repo *Repository) ReadStoredBlob(id objectid.ObjectID) (*objectstored.StoredBlob, error) {+ stored, err := repo.ReadStored(id)
+ if err != nil {+ return nil, err
+ }
+ blob, ok := stored.(*objectstored.StoredBlob)
+ if !ok {+ return nil, fmt.Errorf("repository: expected blob object %s, got %v", id, stored.Object().ObjectType())+ }
+ return blob, nil
+}
+
+// ReadStoredTree reads and parses a tree object by ID.
+func (repo *Repository) ReadStoredTree(id objectid.ObjectID) (*objectstored.StoredTree, error) {+ stored, err := repo.ReadStored(id)
+ if err != nil {+ return nil, err
+ }
+ tree, ok := stored.(*objectstored.StoredTree)
+ if !ok {+ return nil, fmt.Errorf("repository: expected tree object %s, got %v", id, stored.Object().ObjectType())+ }
+ return tree, nil
+}
+
+// ReadStoredCommit reads and parses a commit object by ID.
+func (repo *Repository) ReadStoredCommit(id objectid.ObjectID) (*objectstored.StoredCommit, error) {+ stored, err := repo.ReadStored(id)
+ if err != nil {+ return nil, err
+ }
+ commit, ok := stored.(*objectstored.StoredCommit)
+ if !ok {+ return nil, fmt.Errorf("repository: expected commit object %s, got %v", id, stored.Object().ObjectType())+ }
+ return commit, nil
+}
+
+// ReadStoredTag reads and parses a tag object by ID.
+func (repo *Repository) ReadStoredTag(id objectid.ObjectID) (*objectstored.StoredTag, error) {+ stored, err := repo.ReadStored(id)
+ if err != nil {+ return nil, err
+ }
+ tag, ok := stored.(*objectstored.StoredTag)
+ if !ok {+ return nil, fmt.Errorf("repository: expected tag object %s, got %v", id, stored.Object().ObjectType())+ }
+ return tag, nil
+}
+
+// readParsedObject reads bytes content from storage and parses one object.
+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
+}
--- /dev/null
+++ b/repository/stored_test.go
@@ -1,0 +1,163 @@
+package repository_test
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ "codeberg.org/lindenii/furgit/object"
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/repository"
+)
+
+func TestReadStoredTyped(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ repoHarness := testgit.NewRepo(t, testgit.RepoOptions{+ ObjectFormat: algo,
+ Bare: true,
+ RefFormat: "files",
+ })
+
+ blobID, treeID, commitID := repoHarness.MakeCommit(t, "stored types")
+
+ repo, err := repository.Open(repoHarness.Dir())
+ if err != nil {+ t.Fatalf("repository.Open: %v", err)+ }
+ defer func() { _ = repo.Close() }()+
+ blob, err := repo.ReadStoredBlob(blobID)
+ if err != nil {+ t.Fatalf("ReadStoredBlob: %v", err)+ }
+ if blob.ID() != blobID {+ t.Fatalf("blob ID = %s, want %s", blob.ID(), blobID)+ }
+ if string(blob.Blob().Data) != "commit-body\n" {+ t.Fatalf("blob body = %q, want %q", blob.Blob().Data, "commit-body\n")+ }
+
+ tree, err := repo.ReadStoredTree(treeID)
+ if err != nil {+ t.Fatalf("ReadStoredTree: %v", err)+ }
+ if tree.ID() != treeID {+ t.Fatalf("tree ID = %s, want %s", tree.ID(), treeID)+ }
+ if len(tree.Tree().Entries) != 1 {+ t.Fatalf("tree entries = %d, want 1", len(tree.Tree().Entries))+ }
+
+ commit, err := repo.ReadStoredCommit(commitID)
+ if err != nil {+ t.Fatalf("ReadStoredCommit: %v", err)+ }
+ if commit.ID() != commitID {+ t.Fatalf("commit ID = %s, want %s", commit.ID(), commitID)+ }
+ if commit.Commit().Tree != treeID {+ t.Fatalf("commit tree = %s, want %s", commit.Commit().Tree, treeID)+ }
+ })
+}
+
+func TestResolveTreeEntry(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ repoHarness := testgit.NewRepo(t, testgit.RepoOptions{+ ObjectFormat: algo,
+ Bare: true,
+ RefFormat: "files",
+ })
+
+ blobID := repoHarness.HashObject(t, "blob", []byte("nested-file\n"))+ childTreeID := repoHarness.Mktree(t, fmt.Sprintf("100644 blob %s\tleaf.txt\n", blobID))+ rootTreeID := repoHarness.Mktree(t, fmt.Sprintf("040000 tree %s\tdir\n", childTreeID))+
+ repo, err := repository.Open(repoHarness.Dir())
+ if err != nil {+ t.Fatalf("repository.Open: %v", err)+ }
+ defer func() { _ = repo.Close() }()+
+ rootTree, err := repo.ReadStoredTree(rootTreeID)
+ if err != nil {+ t.Fatalf("ReadStoredTree(root): %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)+ }
+ if entry.ID != blobID {+ t.Fatalf("ResolveTreeEntry id = %s, want %s", entry.ID, blobID)+ }
+ })
+}
+
+func TestResolveTreeEntryErrors(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ t.Run("missing path component", func(t *testing.T) {+ t.Parallel()
+ repoHarness := testgit.NewRepo(t, testgit.RepoOptions{+ ObjectFormat: algo,
+ Bare: true,
+ RefFormat: "files",
+ })
+ blobID := repoHarness.HashObject(t, "blob", []byte("body\n"))+ rootTreeID := repoHarness.Mktree(t, fmt.Sprintf("100644 blob %s\tfile.txt\n", blobID))+
+ repo, err := repository.Open(repoHarness.Dir())
+ if err != nil {+ t.Fatalf("repository.Open: %v", err)+ }
+ defer func() { _ = repo.Close() }()+
+ rootTree, err := repo.ReadStoredTree(rootTreeID)
+ if err != nil {+ t.Fatalf("ReadStoredTree(root): %v", err)+ }
+
+ _, err = repo.ResolveTreeEntry(rootTree, [][]byte{[]byte("missing")})+ if err == nil || !strings.Contains(err.Error(), "not found") {+ t.Fatalf("ResolveTreeEntry missing: err = %v, want not found error", err)+ }
+ })
+
+ t.Run("non-tree intermediate", func(t *testing.T) {+ t.Parallel()
+ repoHarness := testgit.NewRepo(t, testgit.RepoOptions{+ ObjectFormat: algo,
+ Bare: true,
+ RefFormat: "files",
+ })
+ blobID := repoHarness.HashObject(t, "blob", []byte("body\n"))+ rootTreeID := repoHarness.Mktree(t, fmt.Sprintf("100644 blob %s\tdir\n", blobID))+
+ repo, err := repository.Open(repoHarness.Dir())
+ if err != nil {+ t.Fatalf("repository.Open: %v", err)+ }
+ defer func() { _ = repo.Close() }()+
+ rootTree, err := repo.ReadStoredTree(rootTreeID)
+ if err != nil {+ t.Fatalf("ReadStoredTree(root): %v", err)+ }
+
+ _, err = repo.ResolveTreeEntry(rootTree, [][]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)+ }
+ })
+ })
+}
--- /dev/null
+++ b/repository/tree_resolve.go
@@ -1,0 +1,48 @@
+package repository
+
+import (
+ "errors"
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/object"
+ "codeberg.org/lindenii/furgit/objectstored"
+)
+
+// 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 *objectstored.StoredTree, 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.Tree().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")+}
--
⑨