diff options
| author | 2026-02-21 11:48:27 +0800 | |
|---|---|---|
| committer | 2026-02-21 11:48:27 +0800 | |
| commit | c2b2f7f5f50e729217d9b70674651ca58eae2e9a (patch) | |
| tree | a92a7104c27eef8a61310ad07d8b55d04ba0033e | |
| parent | refstore: ResolveFully doesn't inherently peel annotated tags (diff) | |
| signature | No signature | |
refstore/packed: Add packed refs backend
| -rw-r--r-- | refstore/packed/TODO | 1 | ||||
| -rw-r--r-- | refstore/packed/packed_test.go | 176 | ||||
| -rw-r--r-- | refstore/packed/parse.go | 100 | ||||
| -rw-r--r-- | refstore/packed/store.go | 104 |
4 files changed, 381 insertions, 0 deletions
diff --git a/refstore/packed/TODO b/refstore/packed/TODO new file mode 100644 index 00000000..470648bb --- /dev/null +++ b/refstore/packed/TODO @@ -0,0 +1 @@ +Make ref name and parse-line validations stricter. diff --git a/refstore/packed/packed_test.go b/refstore/packed/packed_test.go new file mode 100644 index 00000000..2c984cb9 --- /dev/null +++ b/refstore/packed/packed_test.go @@ -0,0 +1,176 @@ +package packed_test + +import ( + "bytes" + "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/packed" +) + +func openPackedRefStoreFromRepo(t *testing.T, repoPath string, algo objectid.Algorithm) *packed.Store { + t.Helper() + file, err := os.Open(filepath.Join(repoPath, "packed-refs")) + if err != nil { + t.Fatalf("open packed-refs: %v", err) + } + defer func() { _ = file.Close() }() + + store, err := packed.New(file, algo) + if err != nil { + t.Fatalf("packed.New: %v", err) + } + return store +} + +func TestPackedResolveAndPeeled(t *testing.T) { + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { + testRepo := testgit.NewBareRepo(t, algo) + _, _, commitID := testRepo.MakeCommit(t, "packed refs commit") + testRepo.UpdateRef(t, "refs/heads/main", commitID) + tagID := testRepo.TagAnnotated(t, "v1.0.0", commitID, "annotated tag") + testRepo.PackRefs(t, "--all", "--prune") + + store := openPackedRefStoreFromRepo(t, testRepo.Dir(), algo) + + resolvedMain, err := store.Resolve("refs/heads/main") + if err != nil { + t.Fatalf("Resolve(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) + } + + resolvedTag, err := store.Resolve("refs/tags/v1.0.0") + if err != nil { + t.Fatalf("Resolve(tag): %v", err) + } + tagDet, ok := resolvedTag.(ref.Detached) + if !ok { + t.Fatalf("Resolve(tag) type = %T, want ref.Detached", resolvedTag) + } + if tagDet.ID != tagID { + t.Fatalf("Resolve(tag) id = %s, want %s", tagDet.ID, tagID) + } + if tagDet.Peeled == nil { + t.Fatalf("Resolve(tag) peeled = nil, want commit") + } + if *tagDet.Peeled != commitID { + t.Fatalf("Resolve(tag) peeled = %s, want %s", *tagDet.Peeled, commitID) + } + + fullTag, err := store.ResolveFully("refs/tags/v1.0.0") + if err != nil { + t.Fatalf("ResolveFully(tag): %v", err) + } + if fullTag.ID != tagDet.ID { + t.Fatalf("ResolveFully(tag) id = %s, want %s", fullTag.ID, tagDet.ID) + } + + if _, err := store.Resolve("refs/heads/does-not-exist"); !errors.Is(err, refstore.ErrReferenceNotFound) { + t.Fatalf("Resolve(not-found) error = %v", err) + } + }) +} + +func TestPackedListAndShorten(t *testing.T) { + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { + testRepo := testgit.NewBareRepo(t, algo) + _, _, commitID := testRepo.MakeCommit(t, "packed refs list commit") + testRepo.UpdateRef(t, "refs/heads/main", commitID) + testRepo.UpdateRef(t, "refs/tags/main", commitID) + testRepo.UpdateRef(t, "refs/remotes/origin/main", commitID) + testRepo.PackRefs(t, "--all", "--prune") + + store := openPackedRefStoreFromRepo(t, testRepo.Dir(), algo) + + all, err := store.List("") + if err != nil { + t.Fatalf("List(all): %v", err) + } + allNames := make([]string, 0, len(all)) + for _, entry := range all { + allNames = append(allNames, entry.Name()) + } + slices.Sort(allNames) + wantAll := []string{"refs/heads/main", "refs/remotes/origin/main", "refs/tags/main"} + if !slices.Equal(allNames, wantAll) { + t.Fatalf("List(all) names = %v, want %v", allNames, wantAll) + } + + filtered, err := store.List("refs/heads/*") + if err != nil { + t.Fatalf("List(pattern): %v", err) + } + if len(filtered) != 1 || filtered[0].Name() != "refs/heads/main" { + t.Fatalf("List(refs/heads/*) = %v, want refs/heads/main only", filtered) + } + + short, err := store.Shorten("refs/heads/main") + if err != nil { + t.Fatalf("Shorten(main): %v", err) + } + if short != "heads/main" { + t.Fatalf("Shorten(main) = %q, want %q", short, "heads/main") + } + + if _, err := store.Shorten("refs/heads/does-not-exist"); !errors.Is(err, refstore.ErrReferenceNotFound) { + t.Fatalf("Shorten(not-found) error = %v", err) + } + }) +} + +func TestPackedParseErrors(t *testing.T) { + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { + cases := []struct { + name string + data string + }{ + { + name: "peeled without ref", + data: "^" + stringsOfLen("0", algo.HexLen()) + "\n", + }, + { + name: "invalid entry", + data: "not-a-valid-line\n", + }, + { + name: "duplicate ref", + data: stringsOfLen("0", algo.HexLen()) + " refs/heads/main\n" + + stringsOfLen("1", algo.HexLen()) + " refs/heads/main\n", + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if _, err := packed.New(bytes.NewBufferString(tt.data), algo); err == nil { + t.Fatalf("packed.New expected parse error") + } + }) + } + }) +} + +func TestPackedNewValidation(t *testing.T) { + if _, err := packed.New(bytes.NewReader(nil), objectid.AlgorithmUnknown); !errors.Is(err, objectid.ErrInvalidAlgorithm) { + t.Fatalf("packed.New invalid algorithm error = %v", err) + } + if _, err := packed.New(nil, objectid.AlgorithmSHA1); err == nil { + t.Fatalf("packed.New nil reader expected error") + } +} + +func stringsOfLen(ch string, n int) string { + return string(bytes.Repeat([]byte(ch), n)) +} diff --git a/refstore/packed/parse.go b/refstore/packed/parse.go new file mode 100644 index 00000000..6fe88061 --- /dev/null +++ b/refstore/packed/parse.go @@ -0,0 +1,100 @@ +package packed + +import ( + "bufio" + "fmt" + "io" + "strings" + + "codeberg.org/lindenii/furgit/objectid" + "codeberg.org/lindenii/furgit/ref" +) + +// parsePackedRefs parses packed-refs content into detached refs. +func parsePackedRefs(r io.Reader, algo objectid.Algorithm) (map[string]ref.Detached, []ref.Detached, error) { + byName := make(map[string]ref.Detached) + ordered := make([]ref.Detached, 0, 32) + + br := bufio.NewReader(r) + prev := -1 + lineNum := 0 + + for { + line, err := br.ReadString('\n') + if err != nil && err != io.EOF { + return nil, nil, err + } + if line == "" && err == io.EOF { + break + } + lineNum++ + + line = strings.TrimSuffix(line, "\n") + line = strings.TrimSuffix(line, "\r") + line = strings.TrimSpace(line) + if line == "" { + if err == io.EOF { + break + } + continue + } + if strings.HasPrefix(line, "#") { + if err == io.EOF { + break + } + continue + } + + if strings.HasPrefix(line, "^") { + if prev < 0 { + return nil, nil, fmt.Errorf("refstore/packed: line %d: peeled line without preceding ref", lineNum) + } + peeledHex := strings.TrimSpace(strings.TrimPrefix(line, "^")) + peeled, parseErr := objectid.ParseHex(algo, peeledHex) + if parseErr != nil { + return nil, nil, fmt.Errorf("refstore/packed: line %d: invalid peeled oid: %w", lineNum, parseErr) + } + peeledCopy := peeled + cur := ordered[prev] + cur.Peeled = &peeledCopy + ordered[prev] = cur + byName[cur.Name()] = cur + if err == io.EOF { + break + } + continue + } + + fields := strings.Fields(line) + if len(fields) != 2 { + return nil, nil, fmt.Errorf("refstore/packed: line %d: malformed entry", lineNum) + } + + id, parseErr := objectid.ParseHex(algo, fields[0]) + if parseErr != nil { + return nil, nil, fmt.Errorf("refstore/packed: line %d: invalid oid: %w", lineNum, parseErr) + } + + name := fields[1] + if name == "" { + return nil, nil, fmt.Errorf("refstore/packed: line %d: empty ref name", lineNum) + } + if _, exists := byName[name]; exists { + return nil, nil, fmt.Errorf("refstore/packed: line %d: duplicate ref %q", lineNum, name) + } + + detached := ref.Detached{ + RefName: name, + ID: id, + } + ordered = append(ordered, detached) + prev = len(ordered) - 1 + byName[name] = detached + + if err == io.EOF { + break + } + } + + return byName, ordered, nil +} diff --git a/refstore/packed/store.go b/refstore/packed/store.go new file mode 100644 index 00000000..377eb75c --- /dev/null +++ b/refstore/packed/store.go @@ -0,0 +1,104 @@ +// Package packed provides read access to packed Git references. +package packed + +import ( + "io" + "path" + + "codeberg.org/lindenii/furgit/objectid" + "codeberg.org/lindenii/furgit/ref" + "codeberg.org/lindenii/furgit/refstore" +) + +// Store reads references from a parsed packed-refs snapshot. +type Store struct { + byName map[string]ref.Detached + ordered []ref.Detached +} + +var _ refstore.Store = (*Store)(nil) + +// New parses packed-refs content from r using the given object ID algorithm. +func New(r io.Reader, algo objectid.Algorithm) (*Store, error) { + if algo.Size() == 0 { + return nil, objectid.ErrInvalidAlgorithm + } + if r == nil { + return nil, io.ErrUnexpectedEOF + } + byName, ordered, err := parsePackedRefs(r, algo) + if err != nil { + return nil, err + } + return &Store{ + byName: byName, + ordered: ordered, + }, nil +} + +// Resolve resolves a packed reference name to a detached ref. +func (store *Store) Resolve(name string) (ref.Ref, error) { + detached, ok := store.byName[name] + if !ok { + return nil, refstore.ErrReferenceNotFound + } + return detached, nil +} + +// ResolveFully resolves a packed reference name to a detached ref. +// +// Packed refs are detached-only, so ResolveFully is equivalent to Resolve. +func (store *Store) ResolveFully(name string) (ref.Detached, error) { + detached, ok := store.byName[name] + if !ok { + return ref.Detached{}, refstore.ErrReferenceNotFound + } + return detached, nil +} + +// List lists packed 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, "refs/heads/main"); err != nil { + return nil, err + } + } + + refs := make([]ref.Ref, 0, len(store.ordered)) + for _, entry := range store.ordered { + if !matchAll { + matched, err := path.Match(pattern, entry.Name()) + if err != nil { + return nil, err + } + if !matched { + continue + } + } + refs = append(refs, entry) + } + return refs, nil +} + +// Shorten returns the shortest unambiguous shorthand for a packed ref name. +func (store *Store) Shorten(name string) (string, error) { + _, ok := store.byName[name] + if !ok { + return "", refstore.ErrReferenceNotFound + } + + names := make([]string, 0, len(store.ordered)) + for _, entry := range store.ordered { + names = append(names, entry.Name()) + } + return refstore.ShortenName(name, names), nil +} + +// Close releases resources associated with the backend. +func (store *Store) Close() error { + return nil +} |
