aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--refs.go148
-rw-r--r--refs_test.go205
2 files changed, 353 insertions, 0 deletions
diff --git a/refs.go b/refs.go
index 59621b40..10a3f66b 100644
--- a/refs.go
+++ b/refs.go
@@ -5,6 +5,8 @@ import (
"bytes"
"fmt"
"os"
+ "path"
+ "path/filepath"
"slices"
"strings"
)
@@ -142,6 +144,14 @@ type Ref struct {
Peeled Hash
}
+// ShowRef represents a reference entry as returned by ShowRefs.
+type ShowRef struct {
+ // Name is the fully qualified ref name (e.g., refs/heads/main).
+ Name string
+ // Ref describes the reference target.
+ Ref Ref
+}
+
// ResolveRef reads the given fully qualified ref (such as "HEAD" or "refs/heads/main")
// and interprets its contents as either a symbolic ref ("ref: refs/..."), a detached
// hash, or invalid.
@@ -224,3 +234,141 @@ func (repo *Repository) resolveRefFully(path string, seen map[string]struct{}) (
return Hash{}, ErrInvalidRef
}
}
+
+// ShowRefs lists refs similarly to git-show-ref.
+//
+// The pattern must be empty or begin with "refs/". An empty pattern is
+// treated as "refs/*".
+
+// Loose refs are resolved using filesystem globbing relative to the
+// repository root, then packed refs are read while skipping any names
+// that already appeared as loose refs. Packed refs are filtered
+// similarly.
+func (repo *Repository) ShowRefs(pattern string) ([]ShowRef, error) {
+ if pattern == "" {
+ pattern = "refs/*"
+ }
+ if !strings.HasPrefix(pattern, "refs/") {
+ return nil, ErrInvalidRef
+ }
+ if filepath.IsAbs(pattern) {
+ return nil, ErrInvalidRef
+ }
+
+ var out []ShowRef
+ seen := make(map[string]struct{})
+
+ globPattern := filepath.Join(repo.rootPath, filepath.FromSlash(pattern))
+ matches, err := filepath.Glob(globPattern)
+ if err != nil {
+ return nil, err
+ }
+ for _, match := range matches {
+ info, statErr := os.Stat(match)
+ if statErr != nil {
+ return nil, statErr
+ }
+ if info.IsDir() {
+ continue
+ }
+
+ rel, relErr := filepath.Rel(repo.rootPath, match)
+ if relErr != nil {
+ return nil, relErr
+ }
+ name := filepath.ToSlash(rel)
+ if !strings.HasPrefix(name, "refs/") {
+ continue
+ }
+
+ ref, resolveErr := repo.resolveLooseRef(name)
+ if resolveErr != nil {
+ if resolveErr == ErrNotFound || os.IsNotExist(resolveErr) {
+ continue
+ }
+ return nil, resolveErr
+ }
+
+ seen[name] = struct{}{}
+ out = append(out, ShowRef{
+ Name: name,
+ Ref: ref,
+ })
+ }
+
+ packedPath := repo.repoPath("packed-refs")
+ f, err := os.Open(packedPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return out, nil
+ }
+ return nil, err
+ }
+ defer func() { _ = f.Close() }()
+
+ scanner := bufio.NewScanner(f)
+ lastIdx := -1
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if len(line) == 0 || line[0] == '#' {
+ continue
+ }
+
+ if line[0] == '^' {
+ if lastIdx < 0 {
+ continue
+ }
+ peeledHex := strings.TrimPrefix(string(line), "^")
+ peeledHex = strings.TrimSpace(peeledHex)
+ peeled, parseErr := repo.ParseHash(peeledHex)
+ if parseErr != nil {
+ return nil, parseErr
+ }
+ out[lastIdx].Ref.Peeled = peeled
+ continue
+ }
+
+ sp := bytes.IndexByte(line, ' ')
+ if sp != repo.hashSize*2 {
+ lastIdx = -1
+ continue
+ }
+
+ name := string(line[sp+1:])
+ if !strings.HasPrefix(name, "refs/") {
+ lastIdx = -1
+ continue
+ }
+ if _, ok := seen[name]; ok {
+ lastIdx = -1
+ continue
+ }
+
+ match, matchErr := path.Match(pattern, name)
+ if matchErr != nil {
+ return nil, matchErr
+ }
+ if !match {
+ lastIdx = -1
+ continue
+ }
+
+ hash, parseErr := repo.ParseHash(string(line[:sp]))
+ if parseErr != nil {
+ return nil, parseErr
+ }
+ out = append(out, ShowRef{
+ Name: name,
+ Ref: Ref{
+ Kind: RefKindDetached,
+ Hash: hash,
+ },
+ })
+ lastIdx = len(out) - 1
+ }
+ if scanErr := scanner.Err(); scanErr != nil {
+ return nil, scanErr
+ }
+
+ return out, nil
+}
diff --git a/refs_test.go b/refs_test.go
index 6481dcf8..2e9ddcbc 100644
--- a/refs_test.go
+++ b/refs_test.go
@@ -279,3 +279,208 @@ func TestResolveRefHashInput(t *testing.T) {
t.Fatalf("expected error for invalid hash input")
}
}
+
+func TestShowRefsLooseOverridesPacked(t *testing.T) {
+ repoPath, cleanup := setupTestRepo(t)
+ defer cleanup()
+
+ workDir, cleanupWork := setupWorkDir(t)
+ defer cleanupWork()
+
+ gitCmd(t, repoPath, "symbolic-ref", "HEAD", "refs/heads/main")
+
+ err := os.WriteFile(filepath.Join(workDir, "file.txt"), []byte("one"), 0o644)
+ if err != nil {
+ t.Fatalf("failed to write file.txt: %v", err)
+ }
+ gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".")
+ gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "c1")
+ commit1 := gitCmd(t, repoPath, "rev-parse", "HEAD")
+
+ gitCmd(t, repoPath, "update-ref", "refs/heads/main", commit1)
+ gitCmd(t, repoPath, "update-ref", "refs/heads/feature", commit1)
+ gitCmd(t, repoPath, "pack-refs", "--all", "--prune")
+
+ err = os.WriteFile(filepath.Join(workDir, "file.txt"), []byte("two"), 0o644)
+ if err != nil {
+ t.Fatalf("failed to write file.txt: %v", err)
+ }
+ gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".")
+ gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "c2")
+ commit2 := gitCmd(t, repoPath, "rev-parse", "HEAD")
+ gitCmd(t, repoPath, "update-ref", "refs/heads/main", commit2)
+
+ repo, err := OpenRepository(repoPath)
+ if err != nil {
+ t.Fatalf("OpenRepository failed: %v", err)
+ }
+ defer func() { _ = repo.Close() }()
+
+ hash1, _ := repo.ParseHash(commit1)
+ hash2, _ := repo.ParseHash(commit2)
+
+ refs, err := repo.ShowRefs("refs/heads/*")
+ if err != nil {
+ t.Fatalf("ShowRefs failed: %v", err)
+ }
+
+ if len(refs) != 2 {
+ t.Fatalf("expected 2 refs, got %d", len(refs))
+ }
+
+ got := make(map[string]Ref, len(refs))
+ for _, r := range refs {
+ if _, exists := got[r.Name]; exists {
+ t.Fatalf("duplicate ref %q in results", r.Name)
+ }
+ got[r.Name] = r.Ref
+ }
+
+ mainRef, ok := got["refs/heads/main"]
+ if !ok {
+ t.Fatalf("missing refs/heads/main in results")
+ }
+ if mainRef.Kind != RefKindDetached || mainRef.Hash != hash2 {
+ t.Fatalf("refs/heads/main hash: got %s (kind %v), want %s", mainRef.Hash, mainRef.Kind, hash2)
+ }
+
+ featureRef, ok := got["refs/heads/feature"]
+ if !ok {
+ t.Fatalf("missing refs/heads/feature in results")
+ }
+ if featureRef.Kind != RefKindDetached || featureRef.Hash != hash1 {
+ t.Fatalf("refs/heads/feature hash: got %s (kind %v), want %s", featureRef.Hash, featureRef.Kind, hash1)
+ }
+}
+
+func TestShowRefsPatternFiltering(t *testing.T) {
+ repoPath, cleanup := setupTestRepo(t)
+ defer cleanup()
+
+ workDir, cleanupWork := setupWorkDir(t)
+ defer cleanupWork()
+
+ gitCmd(t, repoPath, "symbolic-ref", "HEAD", "refs/heads/main")
+
+ err := os.WriteFile(filepath.Join(workDir, "file.txt"), []byte("one"), 0o644)
+ if err != nil {
+ t.Fatalf("failed to write file.txt: %v", err)
+ }
+ gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".")
+ gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "c1")
+ commit1 := gitCmd(t, repoPath, "rev-parse", "HEAD")
+
+ gitCmd(t, repoPath, "update-ref", "refs/heads/main", commit1)
+ gitCmd(t, repoPath, "update-ref", "refs/heads/feature", commit1)
+ gitCmd(t, repoPath, "pack-refs", "--all", "--prune")
+
+ repo, err := OpenRepository(repoPath)
+ if err != nil {
+ t.Fatalf("OpenRepository failed: %v", err)
+ }
+ defer func() { _ = repo.Close() }()
+
+ hash1, _ := repo.ParseHash(commit1)
+
+ refs, err := repo.ShowRefs("refs/heads/fea*")
+ if err != nil {
+ t.Fatalf("ShowRefs failed: %v", err)
+ }
+ if len(refs) != 1 {
+ t.Fatalf("expected 1 ref, got %d", len(refs))
+ }
+ if refs[0].Name != "refs/heads/feature" {
+ t.Fatalf("unexpected ref name: got %q, want %q", refs[0].Name, "refs/heads/feature")
+ }
+ if refs[0].Ref.Kind != RefKindDetached || refs[0].Ref.Hash != hash1 {
+ t.Fatalf("refs/heads/feature hash: got %s (kind %v), want %s", refs[0].Ref.Hash, refs[0].Ref.Kind, hash1)
+ }
+}
+
+func TestShowRefsPackedPatterns(t *testing.T) {
+ repoPath, cleanup := setupTestRepo(t)
+ defer cleanup()
+
+ workDir, cleanupWork := setupWorkDir(t)
+ defer cleanupWork()
+
+ gitCmd(t, repoPath, "symbolic-ref", "HEAD", "refs/heads/main")
+
+ err := os.WriteFile(filepath.Join(workDir, "file.txt"), []byte("one"), 0o644)
+ if err != nil {
+ t.Fatalf("failed to write file.txt: %v", err)
+ }
+ gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".")
+ gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "c1")
+ commit := gitCmd(t, repoPath, "rev-parse", "HEAD")
+
+ gitCmd(t, repoPath, "update-ref", "refs/heads/main", commit)
+ gitCmd(t, repoPath, "update-ref", "refs/heads/feature/one", commit)
+ gitCmd(t, repoPath, "update-ref", "refs/notes/review", commit)
+ gitCmd(t, repoPath, "update-ref", "refs/tags/v1", commit)
+ gitCmd(t, repoPath, "pack-refs", "--all", "--prune")
+
+ repo, err := OpenRepository(repoPath)
+ if err != nil {
+ t.Fatalf("OpenRepository failed: %v", err)
+ }
+ defer func() { _ = repo.Close() }()
+
+ tests := []struct {
+ pattern string
+ want []string
+ }{
+ {
+ pattern: "refs/heads/*",
+ want: []string{"refs/heads/main"},
+ },
+ {
+ pattern: "refs/heads/*/*",
+ want: []string{"refs/heads/feature/one"},
+ },
+ {
+ pattern: "refs/*/feature/one",
+ want: []string{"refs/heads/feature/one"},
+ },
+ {
+ pattern: "refs/heads/feat?re/one",
+ want: []string{"refs/heads/feature/one"},
+ },
+ {
+ pattern: "refs/tags/v[0-9]",
+ want: []string{"refs/tags/v1"},
+ },
+ {
+ pattern: "refs/*/*",
+ want: []string{"refs/heads/main", "refs/notes/review", "refs/tags/v1"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.pattern, func(t *testing.T) {
+ refs, err := repo.ShowRefs(tt.pattern)
+ if err != nil {
+ t.Fatalf("ShowRefs(%q) failed: %v", tt.pattern, err)
+ }
+
+ got := make(map[string]struct{}, len(refs))
+ for _, r := range refs {
+ got[r.Name] = struct{}{}
+ }
+
+ want := make(map[string]struct{}, len(tt.want))
+ for _, w := range tt.want {
+ want[w] = struct{}{}
+ }
+
+ if len(got) != len(want) {
+ t.Fatalf("ShowRefs(%q) returned %d refs, want %d", tt.pattern, len(got), len(want))
+ }
+ for name := range got {
+ if _, ok := want[name]; !ok {
+ t.Fatalf("ShowRefs(%q) unexpected ref %q", tt.pattern, name)
+ }
+ }
+ })
+ }
+}