diff options
| author | 2026-02-21 11:25:50 +0800 | |
|---|---|---|
| committer | 2026-02-21 11:25:50 +0800 | |
| commit | 5682de102bdd28741d0b7e371e8ee9bbd003d045 (patch) | |
| tree | 9ce4b8c704c4a5d8b5f0f9537e19a2638e1ff871 /refstore | |
| parent | testgit: Add ref-related functions (diff) | |
| signature | No signature | |
refstore/loose: Add loose refs implementation
Diffstat (limited to 'refstore')
| -rw-r--r-- | refstore/loose/list.go | 96 | ||||
| -rw-r--r-- | refstore/loose/loose_test.go | 149 | ||||
| -rw-r--r-- | refstore/loose/resolve.go | 87 | ||||
| -rw-r--r-- | refstore/loose/store.go | 37 |
4 files changed, 369 insertions, 0 deletions
diff --git a/refstore/loose/list.go b/refstore/loose/list.go new file mode 100644 index 00000000..d28016da --- /dev/null +++ b/refstore/loose/list.go @@ -0,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 +} diff --git a/refstore/loose/loose_test.go b/refstore/loose/loose_test.go new file mode 100644 index 00000000..b56e40ac --- /dev/null +++ b/refstore/loose/loose_test.go @@ -0,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") + } + }) +} diff --git a/refstore/loose/resolve.go b/refstore/loose/resolve.go new file mode 100644 index 00000000..f54ab5a4 --- /dev/null +++ b/refstore/loose/resolve.go @@ -0,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 +} diff --git a/refstore/loose/store.go b/refstore/loose/store.go new file mode 100644 index 00000000..4102ea0d --- /dev/null +++ b/refstore/loose/store.go @@ -0,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 +} |
