shithub: furgit

Download patch

ref: 5682de102bdd28741d0b7e371e8ee9bbd003d045
parent: 54baeb0ec3778b63a8088d0f5ef4ac9aa90d077d
author: Runxi Yu <me@runxiyu.org>
date: Sat Feb 21 06:25:50 EST 2026

refstore/loose: Add loose refs implementation

--- /dev/null
+++ b/refstore/loose/list.go
@@ -1,0 +1,96 @@
+package loose
+
+import (
+	"errors"
+	"os"
+	"path"
+	"slices"
+
+	"codeberg.org/lindenii/furgit/ref"
+	"codeberg.org/lindenii/furgit/refstore"
+)
+
+// List lists loose references matching pattern.
+//
+// Pattern uses path.Match syntax against full reference names.
+// Empty pattern matches all references.
+func (store *Store) List(pattern string) ([]ref.Ref, error) {
+	matchAll := pattern == ""
+	if !matchAll {
+		if _, err := path.Match(pattern, "HEAD"); err != nil {
+			return nil, err
+		}
+	}
+
+	names, err := store.collectLooseRefNames()
+	if err != nil {
+		return nil, err
+	}
+	slices.Sort(names)
+
+	refs := make([]ref.Ref, 0, len(names))
+	for _, name := range names {
+		if !matchAll {
+			matched, err := path.Match(pattern, name)
+			if err != nil {
+				return nil, err
+			}
+			if !matched {
+				continue
+			}
+		}
+		resolved, err := store.resolveOne(name)
+		if err != nil {
+			if errors.Is(err, refstore.ErrReferenceNotFound) {
+				continue
+			}
+			return nil, err
+		}
+		refs = append(refs, resolved)
+	}
+	return refs, nil
+}
+
+// collectLooseRefNames returns loose ref names available in this backend.
+func (store *Store) collectLooseRefNames() ([]string, error) {
+	names := make([]string, 0, 16)
+
+	if _, err := store.root.Stat("HEAD"); err == nil {
+		names = append(names, "HEAD")
+	} else if !errors.Is(err, os.ErrNotExist) {
+		return nil, err
+	}
+
+	var walk func(string) error
+	walk = func(dir string) error {
+		file, err := store.root.Open(dir)
+		if err != nil {
+			if errors.Is(err, os.ErrNotExist) {
+				return nil
+			}
+			return err
+		}
+		defer func() { _ = file.Close() }()
+
+		entries, err := file.ReadDir(-1)
+		if err != nil {
+			return err
+		}
+		for _, entry := range entries {
+			name := path.Join(dir, entry.Name())
+			if entry.IsDir() {
+				if err := walk(name); err != nil {
+					return err
+				}
+				continue
+			}
+			names = append(names, name)
+		}
+		return nil
+	}
+
+	if err := walk("refs"); err != nil {
+		return nil, err
+	}
+	return names, nil
+}
--- /dev/null
+++ b/refstore/loose/loose_test.go
@@ -1,0 +1,149 @@
+package loose_test
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"slices"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/ref"
+	"codeberg.org/lindenii/furgit/refstore"
+	"codeberg.org/lindenii/furgit/refstore/loose"
+)
+
+func openLooseStore(t *testing.T, repoPath string, algo objectid.Algorithm) *loose.Store {
+	t.Helper()
+	root, err := os.OpenRoot(repoPath)
+	if err != nil {
+		t.Fatalf("OpenRoot(%q): %v", repoPath, err)
+	}
+	t.Cleanup(func() { _ = root.Close() })
+
+	store, err := loose.New(root, algo)
+	if err != nil {
+		t.Fatalf("loose.New: %v", err)
+	}
+	return store
+}
+
+func TestLooseResolveAndResolveFully(t *testing.T) {
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		testRepo := testgit.NewBareRepo(t, algo)
+		_, _, commitID := testRepo.MakeCommit(t, "loose refs commit")
+		testRepo.UpdateRef(t, "refs/heads/main", commitID)
+		testRepo.SymbolicRef(t, "HEAD", "refs/heads/main")
+
+		store := openLooseStore(t, testRepo.Dir(), algo)
+
+		resolvedHead, err := store.Resolve("HEAD")
+		if err != nil {
+			t.Fatalf("Resolve(HEAD): %v", err)
+		}
+		headSym, ok := resolvedHead.(ref.Symbolic)
+		if !ok {
+			t.Fatalf("Resolve(HEAD) type = %T, want ref.Symbolic", resolvedHead)
+		}
+		if headSym.Target != "refs/heads/main" {
+			t.Fatalf("Resolve(HEAD) target = %q, want %q", headSym.Target, "refs/heads/main")
+		}
+
+		resolvedMain, err := store.Resolve("refs/heads/main")
+		if err != nil {
+			t.Fatalf("Resolve(refs/heads/main): %v", err)
+		}
+		mainDet, ok := resolvedMain.(ref.Detached)
+		if !ok {
+			t.Fatalf("Resolve(main) type = %T, want ref.Detached", resolvedMain)
+		}
+		if mainDet.ID != commitID {
+			t.Fatalf("Resolve(main) id = %s, want %s", mainDet.ID, commitID)
+		}
+
+		fullHead, err := store.ResolveFully("HEAD")
+		if err != nil {
+			t.Fatalf("ResolveFully(HEAD): %v", err)
+		}
+		if fullHead.ID != commitID {
+			t.Fatalf("ResolveFully(HEAD) id = %s, want %s", fullHead.ID, commitID)
+		}
+
+		if _, err := store.Resolve("refs/heads/does-not-exist"); !errors.Is(err, refstore.ErrReferenceNotFound) {
+			t.Fatalf("Resolve(not-found) error = %v", err)
+		}
+	})
+}
+
+func TestLooseResolveFullyCycle(t *testing.T) {
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		testRepo := testgit.NewBareRepo(t, algo)
+		testRepo.SymbolicRef(t, "refs/heads/a", "refs/heads/b")
+		testRepo.SymbolicRef(t, "refs/heads/b", "refs/heads/a")
+
+		store := openLooseStore(t, testRepo.Dir(), algo)
+		if _, err := store.ResolveFully("refs/heads/a"); err == nil {
+			t.Fatalf("ResolveFully(cycle) expected error")
+		}
+	})
+}
+
+func TestLooseListPattern(t *testing.T) {
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		testRepo := testgit.NewBareRepo(t, algo)
+		_, _, commitID := testRepo.MakeCommit(t, "list refs commit")
+		testRepo.UpdateRef(t, "refs/heads/main", commitID)
+		testRepo.UpdateRef(t, "refs/heads/feature", commitID)
+		testRepo.UpdateRef(t, "refs/tags/v1.0.0", commitID)
+		testRepo.SymbolicRef(t, "HEAD", "refs/heads/main")
+
+		store := openLooseStore(t, testRepo.Dir(), algo)
+
+		allRefs, err := store.List("")
+		if err != nil {
+			t.Fatalf("List(\"\"): %v", err)
+		}
+		allNames := make([]string, 0, len(allRefs))
+		for _, entry := range allRefs {
+			allNames = append(allNames, entry.Name())
+		}
+		slices.Sort(allNames)
+		wantAll := []string{"HEAD", "refs/heads/feature", "refs/heads/main", "refs/tags/v1.0.0"}
+		if !slices.Equal(allNames, wantAll) {
+			t.Fatalf("List(\"\") names = %v, want %v", allNames, wantAll)
+		}
+
+		headRefs, err := store.List("refs/heads/*")
+		if err != nil {
+			t.Fatalf("List(refs/heads/*): %v", err)
+		}
+		headNames := make([]string, 0, len(headRefs))
+		for _, entry := range headRefs {
+			headNames = append(headNames, entry.Name())
+		}
+		slices.Sort(headNames)
+		wantHeads := []string{"refs/heads/feature", "refs/heads/main"}
+		if !slices.Equal(headNames, wantHeads) {
+			t.Fatalf("List(refs/heads/*) names = %v, want %v", headNames, wantHeads)
+		}
+	})
+}
+
+func TestLooseMalformedDetachedRef(t *testing.T) {
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		testRepo := testgit.NewBareRepo(t, algo)
+		refPath := filepath.Join(testRepo.Dir(), "refs", "heads", "bad")
+		if err := os.MkdirAll(filepath.Dir(refPath), 0o755); err != nil {
+			t.Fatalf("MkdirAll: %v", err)
+		}
+		if err := os.WriteFile(refPath, []byte("not-a-hash\n"), 0o644); err != nil {
+			t.Fatalf("WriteFile: %v", err)
+		}
+
+		store := openLooseStore(t, testRepo.Dir(), algo)
+		if _, err := store.Resolve("refs/heads/bad"); err == nil {
+			t.Fatalf("Resolve(malformed) expected error")
+		}
+	})
+}
--- /dev/null
+++ b/refstore/loose/resolve.go
@@ -1,0 +1,87 @@
+package loose
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"strings"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/ref"
+	"codeberg.org/lindenii/furgit/refstore"
+)
+
+// Resolve resolves a loose reference name to symbolic or detached form.
+func (store *Store) Resolve(name string) (ref.Ref, error) {
+	if name == "" {
+		return nil, refstore.ErrReferenceNotFound
+	}
+	resolved, err := store.resolveOne(name)
+	if err != nil {
+		return nil, err
+	}
+	return resolved, nil
+}
+
+// ResolveFully resolves symbolic references within the loose backend only.
+func (store *Store) ResolveFully(name string) (ref.Detached, error) {
+	if name == "" {
+		return ref.Detached{}, refstore.ErrReferenceNotFound
+	}
+
+	cur := name
+	seen := make(map[string]struct{})
+	for {
+		if _, ok := seen[cur]; ok {
+			return ref.Detached{}, fmt.Errorf("refstore/loose: symbolic reference cycle at %q", cur)
+		}
+		seen[cur] = struct{}{}
+
+		resolved, err := store.resolveOne(cur)
+		if err != nil {
+			return ref.Detached{}, err
+		}
+		switch resolved := resolved.(type) {
+		case ref.Detached:
+			return resolved, nil
+		case ref.Symbolic:
+			target := strings.TrimSpace(resolved.Target)
+			if target == "" {
+				return ref.Detached{}, fmt.Errorf("refstore/loose: symbolic reference %q has empty target", resolved.Name())
+			}
+			cur = target
+		default:
+			return ref.Detached{}, fmt.Errorf("refstore/loose: unsupported reference type %T", resolved)
+		}
+	}
+}
+
+// resolveOne resolves one loose ref file without symbolic recursion.
+func (store *Store) resolveOne(name string) (ref.Ref, error) {
+	data, err := store.root.ReadFile(name)
+	if err != nil {
+		if errors.Is(err, os.ErrNotExist) {
+			return nil, refstore.ErrReferenceNotFound
+		}
+		return nil, err
+	}
+	line := strings.TrimSpace(string(data))
+	if strings.HasPrefix(line, "ref: ") {
+		target := strings.TrimSpace(line[len("ref: "):])
+		if target == "" {
+			return nil, fmt.Errorf("refstore/loose: symbolic reference %q has empty target", name)
+		}
+		return ref.Symbolic{
+			RefName: name,
+			Target:  target,
+		}, nil
+	}
+	id, err := objectid.ParseHex(store.algo, line)
+	if err != nil {
+		return nil, fmt.Errorf("refstore/loose: invalid detached reference %q: %w", name, err)
+	}
+	return ref.Detached{
+		RefName: name,
+		ID:      id,
+	}, nil
+}
--- /dev/null
+++ b/refstore/loose/store.go
@@ -1,0 +1,37 @@
+// Package loose provides read access to loose Git references.
+package loose
+
+import (
+	"os"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/refstore"
+)
+
+// Store reads loose references from a repository root.
+//
+// Store does not own root. Callers are responsible for closing root.
+type Store struct {
+	// root is the repository root capability.
+	root *os.Root
+	// algo is the object ID algorithm used by this repository.
+	algo objectid.Algorithm
+}
+
+var _ refstore.Store = (*Store)(nil)
+
+// New creates a loose ref store rooted at a repository root.
+func New(root *os.Root, algo objectid.Algorithm) (*Store, error) {
+	if algo.Size() == 0 {
+		return nil, objectid.ErrInvalidAlgorithm
+	}
+	return &Store{
+		root: root,
+		algo: algo,
+	}, nil
+}
+
+// Close releases resources associated with the backend.
+func (store *Store) Close() error {
+	return nil
+}
--