diff options
| -rw-r--r-- | refs.go | 213 | ||||
| -rw-r--r-- | refs_test.go | 119 | ||||
| -rw-r--r-- | repo_current_test.go | 42 |
3 files changed, 267 insertions, 107 deletions
@@ -3,117 +3,216 @@ package furgit import ( "bufio" "bytes" - "errors" + "fmt" "os" + "slices" "strings" ) -// ResolveRef resolves a fully qualified ref name to its object ID. -func (repo *Repository) ResolveRef(refname string) (Hash, error) { - id, err := repo.resolveLooseRef(refname) - if err == nil { - return id, nil - } else if !errors.Is(err, ErrNotFound) { - return Hash{}, err - } - - return repo.resolvePackedRef(refname) -} - -func (repo *Repository) resolveLooseRef(refname string) (Hash, error) { +func (repo *Repository) resolveLooseRef(refname string) (Ref, error) { data, err := os.ReadFile(repo.repoPath(refname)) if err != nil { if os.IsNotExist(err) { - return Hash{}, ErrNotFound + return Ref{}, ErrNotFound } - return Hash{}, err + return Ref{}, err } line := strings.TrimSpace(string(data)) + + if strings.HasPrefix(line, "ref: ") { + target := strings.TrimSpace(line[5:]) + if target == "" { + return Ref{Kind: RefKindInvalid}, ErrInvalidRef + } + return Ref{ + Kind: RefKindSymbolic, + Ref: target, + }, nil + } + id, err := repo.ParseHash(line) if err != nil { - return Hash{}, err + return Ref{Kind: RefKindInvalid}, err } - return id, nil + return Ref{ + Kind: RefKindDetached, + Hash: id, + }, nil } -func (repo *Repository) resolvePackedRef(refname string) (Hash, error) { +func (repo *Repository) resolvePackedRef(refname string) (Ref, error) { + // According to git-pack-refs(1), symbolic refs are never + // stored in packed-refs, so we only need to look for detached + // refs here. + path := repo.repoPath("packed-refs") f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { - return Hash{}, ErrNotFound + return Ref{}, ErrNotFound } - return Hash{}, err + return Ref{}, err } defer func() { _ = f.Close() }() want := []byte(refname) scanner := bufio.NewScanner(f) + for scanner.Scan() { line := scanner.Bytes() + if len(line) == 0 || line[0] == '#' || line[0] == '^' { continue } + sp := bytes.IndexByte(line, ' ') if sp != repo.hashSize*2 { continue } + name := line[sp+1:] - if bytes.Equal(name, want) { - hex := string(line[:sp]) - id, err := repo.ParseHash(hex) - if err != nil { - return Hash{}, err + + if !bytes.Equal(name, want) { + continue + } + + hex := string(line[:sp]) + id, err := repo.ParseHash(hex) + if err != nil { + return Ref{Kind: RefKindInvalid}, err + } + + ref := Ref{ + Kind: RefKindDetached, + Hash: id, + } + + if scanner.Scan() { + next := scanner.Bytes() + if len(next) > 0 && next[0] == '^' { + peeledHex := strings.TrimPrefix(string(next), "^") + peeledHex = strings.TrimSpace(peeledHex) + + peeledID, err := repo.ParseHash(peeledHex) + if err != nil { + return Ref{Kind: RefKindInvalid}, err + } + ref.Peeled = peeledID } - return id, nil } + + if scanErr := scanner.Err(); scanErr != nil { + return Ref{Kind: RefKindInvalid}, scanErr + } + + return ref, nil } - scanErr := scanner.Err() - if scanErr != nil { - return Hash{}, scanErr + + if scanErr := scanner.Err(); scanErr != nil { + return Ref{Kind: RefKindInvalid}, scanErr } - return Hash{}, ErrNotFound + return Ref{}, ErrNotFound } -// HeadKind represents the kind of HEAD reference. -type HeadKind int +// RefKind represents the kind of HEAD reference. +type RefKind int const ( // The HEAD reference is invalid. - HeadKindInvalid HeadKind = iota + RefKindInvalid RefKind = iota // The HEAD reference points to a detached commit hash. - HeadKindDetached + RefKindDetached // The HEAD reference points to a symbolic ref. - HeadKindSymbolic + RefKindSymbolic ) -// HeadRef represents a HEAD reference. -type HeadRef struct { - // Kind is the kind of HEAD reference. - Kind HeadKind - // When Kind is HeadSymbolic, Ref is the fully qualified ref name. +// Ref represents a reference. +type Ref struct { + // Kind is the kind of the reference. + Kind RefKind + // When Kind is RefKindSymbolic, Ref is the fully qualified ref name. + // Otherwise the value is undefined. Ref string - // When Kind is HeadDetached, Hash is the commit hash. + // When Kind is RefKindDetached, Hash is the commit hash. + // Otherwise the value is undefined. Hash Hash + // When Kind is RefKindDetached, and the ref supposedly points to an + // annotated tag, Peeled is the peeled hash, i.e., the hash of the + // object that the tag points to. + Peeled Hash } -// ResolveHead reads HEAD into a HEAD reference. -func (repo *Repository) ResolveHead() (HeadRef, error) { - data, err := os.ReadFile(repo.repoPath("HEAD")) - if err != nil { - return HeadRef{Kind: HeadKindInvalid}, err +// 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. +// If path is empty, it defaults to "HEAD". +// (While typically only HEAD may be a symbolic reference, others may be as well.) +func (repo *Repository) ResolveRef(path string) (Ref, error) { + if path == "" { + path = "HEAD" } - line := strings.TrimSuffix(string(data), "\n") - if strings.HasPrefix(line, "ref: ") { - refname := strings.TrimSpace(line[5:]) - if !strings.HasPrefix(refname, "refs/") { - return HeadRef{Kind: HeadKindSymbolic, Ref: refname}, ErrInvalidRef - } - return HeadRef{Kind: HeadKindSymbolic, Ref: refname}, nil + + if !strings.HasPrefix(path, "refs/") && !slices.Contains([]string{ + "HEAD", "ORIG_HEAD", "FETCH_HEAD", "MERGE_HEAD", + "CHERRY_PICK_HEAD", "REVERT_HEAD", "REBASE_HEAD", "BISECT_HEAD", + }, path) { + // For now let's keep this to prevent e.g., random users from + // specifying something crazy like objects/... or ./config. + // There may be other legal pseudo-refs in the future, + // but it's probably the best to stay cautious for now. + return Ref{Kind: RefKindInvalid}, ErrInvalidRef } - id, err := repo.ParseHash(line) + + loose, err := repo.resolveLooseRef(path) + if err == nil { + return loose, nil + } + if err != ErrNotFound { + return Ref{Kind: RefKindInvalid}, err + } + + packed, err := repo.resolvePackedRef(path) + if err == nil { + return packed, nil + } + if err != ErrNotFound { + return Ref{Kind: RefKindInvalid}, err + } + + return Ref{Kind: RefKindInvalid}, ErrNotFound +} + +// ResolveRefFully resolves a ref by recursively following +// symbolic references until it reaches a detached ref. +// Symbolic cycles are detected and reported. +// Tags are not peeled automatically. +func (repo *Repository) ResolveRefFully(path string) (Hash, error) { + seen := make(map[string]struct{}) + return repo.resolveRefFully(path, seen) +} + +func (repo *Repository) resolveRefFully(path string, seen map[string]struct{}) (Hash, error) { + if _, found := seen[path]; found { + return Hash{}, fmt.Errorf("symbolic ref cycle involving %q", path) + } + seen[path] = struct{}{} + + ref, err := repo.ResolveRef(path) if err != nil { - return HeadRef{Kind: HeadKindInvalid}, err + return Hash{}, err + } + + switch ref.Kind { + case RefKindDetached: + return ref.Hash, nil + + case RefKindSymbolic: + if ref.Ref == "" { + return Hash{}, ErrInvalidRef + } + return repo.resolveRefFully(ref.Ref, seen) + + default: + return Hash{}, ErrInvalidRef } - return HeadRef{Kind: HeadKindDetached, Hash: id}, nil } diff --git a/refs_test.go b/refs_test.go index ddef4c14..58953b53 100644 --- a/refs_test.go +++ b/refs_test.go @@ -3,6 +3,7 @@ package furgit import ( "os" "path/filepath" + "strings" "testing" ) @@ -34,13 +35,16 @@ func TestResolveRef(t *testing.T) { t.Fatalf("ResolveRef failed: %v", err) } - if resolved != hashObj { - t.Errorf("resolved hash: got %s, want %s", resolved, hashObj) + if resolved.Kind != RefKindDetached { + t.Fatalf("expected detached ref, got %v", resolved.Kind) + } + if resolved.Hash != hashObj { + t.Errorf("resolved hash: got %s, want %s", resolved.Hash, hashObj) } gitRevParse := gitCmd(t, repoPath, "rev-parse", "refs/heads/main") - if resolved.String() != gitRevParse { - t.Errorf("furgit resolved %s, git resolved %s", resolved, gitRevParse) + if resolved.Hash.String() != gitRevParse { + t.Errorf("furgit resolved %s, git resolved %s", resolved.Hash, gitRevParse) } _, err = repo.ResolveRef("refs/heads/nonexistent") @@ -72,22 +76,22 @@ func TestResolveHEAD(t *testing.T) { } defer func() { _ = repo.Close() }() - ref, err := repo.ResolveHead() + ref, err := repo.ResolveRef("HEAD") if err != nil { - t.Fatalf("ResolveHEAD failed: %v", err) + t.Fatalf("ResolveRef(HEAD) failed: %v", err) + } + + if ref.Kind != RefKindSymbolic { + t.Fatalf("HEAD kind: got %v, want %v", ref.Kind, RefKindSymbolic) } - switch ref.Kind { - case HeadKindSymbolic: - if ref.Ref != "refs/heads/main" { - t.Errorf("HEAD ref: got %q, want %q", ref, "refs/heads/main") - } - gitSymRef := gitCmd(t, repoPath, "symbolic-ref", "HEAD") - if ref.Ref != gitSymRef { - t.Errorf("furgit resolved %v, git resolved %s", ref, gitSymRef) - } - default: - t.Errorf("HEAD kind: got %v, want %v", ref.Kind, HeadKindSymbolic) + if ref.Ref != "refs/heads/main" { + t.Errorf("HEAD symbolic ref: got %q, want %q", ref.Ref, "refs/heads/main") + } + + gitSymRef := gitCmd(t, repoPath, "symbolic-ref", "HEAD") + if ref.Ref != gitSymRef { + t.Errorf("furgit resolved %v, git resolved %s", ref.Ref, gitSymRef) } } @@ -133,28 +137,93 @@ func TestPackedRefs(t *testing.T) { if err != nil { t.Fatalf("ResolveRef branch1 failed: %v", err) } - if resolved1 != hash1 { - t.Errorf("branch1: got %s, want %s", resolved1, hash1) + if resolved1.Kind != RefKindDetached || resolved1.Hash != hash1 { + t.Errorf("branch1: got %s, want %s", resolved1.Hash, hash1) } gitResolved1 := gitCmd(t, repoPath, "rev-parse", "refs/heads/branch1") - if resolved1.String() != gitResolved1 { - t.Errorf("furgit resolved %s, git resolved %s", resolved1, gitResolved1) + if resolved1.Hash.String() != gitResolved1 { + t.Errorf("furgit resolved %s, git resolved %s", resolved1.Hash, gitResolved1) } resolved2, err := repo.ResolveRef("refs/heads/branch2") if err != nil { t.Fatalf("ResolveRef branch2 failed: %v", err) } - if resolved2 != hash2 { - t.Errorf("branch2: got %s, want %s", resolved2, hash2) + if resolved2.Kind != RefKindDetached || resolved2.Hash != hash2 { + t.Errorf("branch2: got %s, want %s", resolved2.Hash, hash2) } resolvedTag, err := repo.ResolveRef("refs/tags/v1.0") if err != nil { t.Fatalf("ResolveRef tag failed: %v", err) } - if resolvedTag != hash1 { - t.Errorf("tag: got %s, want %s", resolvedTag, hash1) + if resolvedTag.Kind != RefKindDetached || resolvedTag.Hash != hash1 { + t.Errorf("tag: got %s, want %s", resolvedTag.Hash, hash1) + } +} + +func TestResolveRefFully(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + // Create an initial commit + err := os.WriteFile(filepath.Join(workDir, "file.txt"), []byte("content"), 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", "init") + commit := gitCmd(t, repoPath, "rev-parse", "HEAD") + + // Create two layers of symbolic refs + gitCmd(t, repoPath, "symbolic-ref", "refs/heads/level1", "refs/heads/level2") + gitCmd(t, repoPath, "symbolic-ref", "refs/heads/level2", "refs/heads/main") + gitCmd(t, repoPath, "update-ref", "refs/heads/main", commit) + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer repo.Close() + + commitHash, err := repo.ParseHash(commit) + if err != nil { + t.Fatalf("ParseHash failed: %v", err) + } + + resolved, err := repo.ResolveRefFully("refs/heads/level1") + if err != nil { + t.Fatalf("ResolveRefFully failed: %v", err) + } + + if resolved != commitHash { + t.Errorf("ResolveRefFully: got hash %s, want %s", resolved, commitHash) + } +} + +func TestResolveRefFullySymbolicCycle(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer repo.Close() + + gitCmd(t, repoPath, "symbolic-ref", "refs/heads/A", "refs/heads/B") + gitCmd(t, repoPath, "symbolic-ref", "refs/heads/B", "refs/heads/A") + + _, err = repo.ResolveRefFully("refs/heads/A") + if err == nil { + t.Fatalf("ResolveRefFully should fail on a symbolic cycle") + } + + if !strings.Contains(err.Error(), "cycle") { + t.Fatalf("unexpected error for symbolic cycle: %v", err) } } diff --git a/repo_current_test.go b/repo_current_test.go index f5699916..ed530385 100644 --- a/repo_current_test.go +++ b/repo_current_test.go @@ -16,36 +16,23 @@ func TestCurrentRepoDepthFirstEnumeration(t *testing.T) { if err != nil { t.Skipf("failed to open current .git directory: %v", err) } - defer func() { _ = repo.Close() }() + defer repo.Close() - headRef, err := repo.ResolveHead() + headHash, err := repo.ResolveRefFully("HEAD") if err != nil { t.Fatalf("failed to resolve HEAD: %v", err) } - var headHash Hash - - switch headRef.Kind { - case HeadKindDetached: - headHash = headRef.Hash - case HeadKindSymbolic: - headHash, err = repo.ResolveRef(headRef.Ref) - if err != nil { - t.Fatalf("failed to resolve symbolic HEAD ref %v: %v", headRef, err) - } - default: - t.Fatalf("unexpected HEAD ref kind: %v", headRef.Kind) - } - visited := make(map[Hash]bool) - var visitQueue []Hash - visitQueue = append(visitQueue, headHash) + var queue []Hash + queue = append(queue, headHash) objectsRead := 0 errors := 0 - for len(visitQueue) > 0 { - hash := visitQueue[0] - visitQueue = visitQueue[1:] + + for len(queue) > 0 { + hash := queue[0] + queue = queue[1:] if visited[hash] { continue @@ -65,15 +52,19 @@ func TestCurrentRepoDepthFirstEnumeration(t *testing.T) { switch o := obj.(type) { case *StoredCommit: - visitQueue = append(visitQueue, o.Tree) - visitQueue = append(visitQueue, o.Parents...) + queue = append(queue, o.Tree) + queue = append(queue, o.Parents...) + case *StoredTree: for _, entry := range o.Entries { - visitQueue = append(visitQueue, entry.ID) + queue = append(queue, entry.ID) } + case *StoredTag: - visitQueue = append(visitQueue, o.Target) + queue = append(queue, o.Target) + case *StoredBlob: + default: t.Errorf("unexpected object type: %T", o) } @@ -84,6 +75,7 @@ func TestCurrentRepoDepthFirstEnumeration(t *testing.T) { } t.Logf("Read %d objects from current repository HEAD (%d errors)", objectsRead, errors) + if errors > 0 { t.Fatalf("encountered %d errors during enumeration", errors) } |
