shithub: furgit

Download patch

ref: b7f2a4c02012af6f08aa74199e29aacd6d3712d9
parent: 94482cb2c97aa215f83940643c5d4c0933727dcb
author: Runxi Yu <me@runxiyu.org>
date: Sat Feb 21 09:45:05 EST 2026

repository: Add Repository abstraction

--- /dev/null
+++ b/repository/repository.go
@@ -1,0 +1,288 @@
+// Package repository wires object and ref storage for one Git repository.
+package repository
+
+import (
+	"errors"
+	"fmt"
+	"os"
+
+	"codeberg.org/lindenii/furgit/config"
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objectstore"
+	objectchain "codeberg.org/lindenii/furgit/objectstore/chain"
+	objectloose "codeberg.org/lindenii/furgit/objectstore/loose"
+	objectpacked "codeberg.org/lindenii/furgit/objectstore/packed"
+	"codeberg.org/lindenii/furgit/refstore"
+	refchain "codeberg.org/lindenii/furgit/refstore/chain"
+	refloose "codeberg.org/lindenii/furgit/refstore/loose"
+	refpacked "codeberg.org/lindenii/furgit/refstore/packed"
+	reftable "codeberg.org/lindenii/furgit/refstore/reftable"
+)
+
+// Repository is a thin composition root for repository-local stores.
+//
+// Open expects path to be the Git directory itself:
+// a bare repository root or a non-bare ".git" directory.
+type Repository struct {
+	root         *os.Root
+	objectsRoot  *os.Root
+	packRoot     *os.Root
+	reftableRoot *os.Root
+
+	config *config.Config
+	algo   objectid.Algorithm
+
+	objects objectstore.Store
+	refs    refstore.Store
+}
+
+// Open opens a repository and wires object/ref stores from its on-disk format.
+func Open(path string) (repo *Repository, err error) {
+	root, err := os.OpenRoot(path)
+	if err != nil {
+		return nil, err
+	}
+
+	repo = &Repository{root: root}
+	defer func() {
+		if err != nil {
+			_ = repo.Close()
+		}
+	}()
+
+	cfg, err := parseRepositoryConfig(root)
+	if err != nil {
+		return nil, err
+	}
+	repo.config = cfg
+
+	algo, err := detectObjectAlgorithm(cfg)
+	if err != nil {
+		return nil, err
+	}
+	repo.algo = algo
+
+	objects, objectsRoot, packRoot, err := openObjectStore(root, algo)
+	if err != nil {
+		return nil, err
+	}
+	repo.objects = objects
+	repo.objectsRoot = objectsRoot
+	repo.packRoot = packRoot
+
+	refs, reftableRoot, err := openRefStore(root, algo)
+	if err != nil {
+		return nil, err
+	}
+	repo.refs = refs
+	repo.reftableRoot = reftableRoot
+
+	return repo, nil
+}
+
+// Algorithm returns the repository object ID algorithm.
+func (repo *Repository) Algorithm() objectid.Algorithm {
+	return repo.algo
+}
+
+// Config returns the parsed repository configuration snapshot.
+//
+// The returned pointer is owned by Repository. Callers should treat it as
+// read-only.
+func (repo *Repository) Config() *config.Config {
+	return repo.config
+}
+
+// Objects returns the configured object store.
+func (repo *Repository) Objects() objectstore.Store {
+	return repo.objects
+}
+
+// Refs returns the configured ref store.
+func (repo *Repository) Refs() refstore.Store {
+	return repo.refs
+}
+
+// Close closes owned stores and filesystem roots.
+// The behavior of the repo after Close is undefined.
+func (repo *Repository) Close() error {
+	var errs []error
+
+	if repo.refs != nil {
+		if err := repo.refs.Close(); err != nil {
+			errs = append(errs, err)
+		}
+		repo.refs = nil
+	}
+	if repo.objects != nil {
+		if err := repo.objects.Close(); err != nil {
+			errs = append(errs, err)
+		}
+		repo.objects = nil
+	}
+
+	if repo.reftableRoot != nil {
+		if err := repo.reftableRoot.Close(); err != nil {
+			errs = append(errs, err)
+		}
+		repo.reftableRoot = nil
+	}
+	if repo.packRoot != nil {
+		if err := repo.packRoot.Close(); err != nil {
+			errs = append(errs, err)
+		}
+		repo.packRoot = nil
+	}
+	if repo.objectsRoot != nil {
+		if err := repo.objectsRoot.Close(); err != nil {
+			errs = append(errs, err)
+		}
+		repo.objectsRoot = nil
+	}
+	if repo.root != nil {
+		if err := repo.root.Close(); err != nil {
+			errs = append(errs, err)
+		}
+		repo.root = nil
+	}
+
+	return errors.Join(errs...)
+}
+
+func parseRepositoryConfig(root *os.Root) (*config.Config, error) {
+	configFile, err := root.Open("config")
+	if err != nil {
+		return nil, fmt.Errorf("repository: open config: %w", err)
+	}
+	defer func() { _ = configFile.Close() }()
+
+	cfg, err := config.ParseConfig(configFile)
+	if err != nil {
+		return nil, fmt.Errorf("repository: parse config: %w", err)
+	}
+	return cfg, nil
+}
+
+func detectObjectAlgorithm(cfg *config.Config) (objectid.Algorithm, error) {
+	algoName := cfg.Get("extensions", "", "objectformat")
+	if algoName == "" {
+		algoName = objectid.AlgorithmSHA1.String()
+	}
+	algo, ok := objectid.ParseAlgorithm(algoName)
+	if !ok {
+		return objectid.AlgorithmUnknown, fmt.Errorf("repository: unsupported object format %q", algoName)
+	}
+	return algo, nil
+}
+
+func openObjectStore(root *os.Root, algo objectid.Algorithm) (out objectstore.Store, objectsRoot *os.Root, packRoot *os.Root, err error) {
+	objectsRoot, err = root.OpenRoot("objects")
+	if err != nil {
+		return nil, nil, nil, fmt.Errorf("repository: open objects: %w", err)
+	}
+	defer func() {
+		if err != nil {
+			if out != nil {
+				_ = out.Close()
+			}
+			if packRoot != nil {
+				_ = packRoot.Close()
+			}
+			_ = objectsRoot.Close()
+		}
+	}()
+
+	looseStore, err := objectloose.New(objectsRoot, algo)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+	backends := []objectstore.Store{looseStore}
+
+	packRoot, err = objectsRoot.OpenRoot("pack")
+	if err == nil {
+		var packedStore *objectpacked.Store
+		packedStore, err = objectpacked.New(packRoot, algo)
+		if err != nil {
+			return nil, nil, nil, err
+		}
+		backends = append(backends, packedStore)
+	} else if !errors.Is(err, os.ErrNotExist) {
+		return nil, nil, nil, fmt.Errorf("repository: open objects/pack: %w", err)
+	}
+	err = nil
+	out = objectchain.New(backends...)
+
+	return out, objectsRoot, packRoot, nil
+}
+
+func openRefStore(root *os.Root, algo objectid.Algorithm) (out refstore.Store, reftableRoot *os.Root, err error) {
+	var closePackedStore refstore.Store
+	defer func() {
+		if err != nil {
+			if out != nil {
+				_ = out.Close()
+			}
+			if closePackedStore != nil {
+				_ = closePackedStore.Close()
+			}
+			if reftableRoot != nil {
+				_ = reftableRoot.Close()
+			}
+		}
+	}()
+
+	hasReftable, err := hasReftableStack(root)
+	if err != nil {
+		return nil, nil, err
+	}
+	if hasReftable {
+		reftableRoot, err = root.OpenRoot("reftable")
+		if err != nil {
+			return nil, nil, fmt.Errorf("repository: open reftable: %w", err)
+		}
+		var reftableStore *reftable.Store
+		reftableStore, err = reftable.New(reftableRoot, algo)
+		if err != nil {
+			return nil, nil, err
+		}
+		err = nil
+		out = reftableStore
+		return reftableStore, reftableRoot, nil
+	}
+
+	looseStore, err := refloose.New(root, algo)
+	if err != nil {
+		return nil, nil, err
+	}
+	backends := []refstore.Store{looseStore}
+
+	packedRefsFile, err := root.Open("packed-refs")
+	if err == nil {
+		packedStore, packedErr := refpacked.New(packedRefsFile, algo)
+		_ = packedRefsFile.Close()
+		if packedErr != nil {
+			err = packedErr
+			return nil, nil, err
+		}
+		closePackedStore = packedStore
+		backends = append(backends, packedStore)
+	} else if !errors.Is(err, os.ErrNotExist) {
+		return nil, nil, fmt.Errorf("repository: open packed-refs: %w", err)
+	}
+	err = nil
+	out = refchain.New(backends...)
+	closePackedStore = nil
+
+	return out, nil, nil
+}
+
+func hasReftableStack(root *os.Root) (bool, error) {
+	_, err := root.Stat("reftable/tables.list")
+	if err == nil {
+		return true, nil
+	}
+	if errors.Is(err, os.ErrNotExist) {
+		return false, nil
+	}
+	return false, fmt.Errorf("repository: stat reftable/tables.list: %w", err)
+}
--- /dev/null
+++ b/repository/repository_test.go
@@ -1,0 +1,124 @@
+package repository_test
+
+import (
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+	"codeberg.org/lindenii/furgit/ref"
+	"codeberg.org/lindenii/furgit/repository"
+)
+
+func TestOpenFilesRefFormat(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",
+		})
+
+		_, _, commitID := repoHarness.MakeCommit(t, "files refs")
+		repoHarness.UpdateRef(t, "refs/heads/main", commitID)
+		repoHarness.SymbolicRef(t, "HEAD", "refs/heads/main")
+
+		repo, err := repository.Open(repoHarness.Dir())
+		if err != nil {
+			t.Fatalf("repository.Open: %v", err)
+		}
+		defer func() { _ = repo.Close() }()
+
+		if repo.Algorithm() != algo {
+			t.Fatalf("Algorithm = %v, want %v", repo.Algorithm(), algo)
+		}
+
+		headerType, headerSize, err := repo.Objects().ReadHeader(commitID)
+		if err != nil {
+			t.Fatalf("ReadHeader(commit): %v", err)
+		}
+		if headerType != objecttype.TypeCommit {
+			t.Fatalf("ReadHeader(commit) type = %v, want %v", headerType, objecttype.TypeCommit)
+		}
+		if headerSize <= 0 {
+			t.Fatalf("ReadHeader(commit) size = %d, want > 0", headerSize)
+		}
+
+		resolved, err := repo.Refs().Resolve("refs/heads/main")
+		if err != nil {
+			t.Fatalf("Resolve(refs/heads/main): %v", err)
+		}
+		detached, ok := resolved.(ref.Detached)
+		if !ok {
+			t.Fatalf("Resolve(refs/heads/main) type = %T, want ref.Detached", resolved)
+		}
+		if detached.ID != commitID {
+			t.Fatalf("Resolve(refs/heads/main) id = %s, want %s", detached.ID, commitID)
+		}
+
+		head, err := repo.Refs().ResolveFully("HEAD")
+		if err != nil {
+			t.Fatalf("ResolveFully(HEAD): %v", err)
+		}
+		if head.ID != commitID {
+			t.Fatalf("ResolveFully(HEAD) id = %s, want %s", head.ID, commitID)
+		}
+	})
+}
+
+func TestOpenFilesWithPackedRefs(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+		repoHarness := newRepoForRefs(t, algo, "files")
+		commitID := writeMainAndHead(t, repoHarness)
+		repoHarness.PackRefs(t, "--all", "--prune")
+		assertResolveFully(t, repoHarness, "refs/heads/main", commitID)
+	})
+}
+
+func TestOpenReftableRefFormat(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+		repoHarness := newRepoForRefs(t, algo, "reftable")
+		commitID := writeMainAndHead(t, repoHarness)
+		assertResolveFully(t, repoHarness, "HEAD", commitID)
+	})
+}
+
+func newRepoForRefs(t *testing.T, algo objectid.Algorithm, refFormat string) *testgit.TestRepo {
+	t.Helper()
+	return testgit.NewRepo(t, testgit.RepoOptions{
+		ObjectFormat: algo,
+		Bare:         true,
+		RefFormat:    refFormat,
+	})
+}
+
+func writeMainAndHead(t *testing.T, repoHarness *testgit.TestRepo) objectid.ObjectID {
+	t.Helper()
+	_, _, commitID := repoHarness.MakeCommit(t, "refs")
+	repoHarness.UpdateRef(t, "refs/heads/main", commitID)
+	repoHarness.SymbolicRef(t, "HEAD", "refs/heads/main")
+	return commitID
+}
+
+func assertResolveFully(t *testing.T, repoHarness *testgit.TestRepo, name string, want objectid.ObjectID) {
+	t.Helper()
+
+	repo, err := repository.Open(repoHarness.Dir())
+	if err != nil {
+		t.Fatalf("repository.Open: %v", err)
+	}
+	defer func() { _ = repo.Close() }()
+
+	resolved, err := repo.Refs().ResolveFully(name)
+	if err != nil {
+		t.Fatalf("ResolveFully(%s): %v", name, err)
+	}
+	if resolved.ID != want {
+		t.Fatalf("ResolveFully(%s) id = %s, want %s", name, resolved.ID, want)
+	}
+}
--