diff options
| -rw-r--r-- | refs.go | 148 | ||||
| -rw-r--r-- | refs_test.go | 205 |
2 files changed, 353 insertions, 0 deletions
@@ -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) + } + } + }) + } +} |
