aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--refs.go213
-rw-r--r--refs_test.go119
-rw-r--r--repo_current_test.go42
3 files changed, 267 insertions, 107 deletions
diff --git a/refs.go b/refs.go
index 3ebdfb86..258f06df 100644
--- a/refs.go
+++ b/refs.go
@@ -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)
}