shithub: furgit

Download patch

ref: 20d82eb47c63e6cb8a8bf360a79e8ba53948a6d6
parent: fd7b64c36c265253ee1dd93ac6588e8982ad0e01
author: Runxi Yu <me@runxiyu.org>
date: Tue Nov 25 03:00:00 EST 2025

refs: Add ShowRefs

--- a/refs.go
+++ b/refs.go
@@ -5,6 +5,8 @@
 	"bytes"
 	"fmt"
 	"os"
+	"path"
+	"path/filepath"
 	"slices"
 	"strings"
 )
@@ -142,6 +144,14 @@
 	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.
@@ -223,4 +233,142 @@
 	default:
 		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
 }
--- a/refs_test.go
+++ b/refs_test.go
@@ -279,3 +279,208 @@
 		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)
+				}
+			}
+		})
+	}
+}
--