shithub: furgit

Download patch

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