ref: 6378da9dcf8d991a00ee410bb5408231861d90c0
parent: 37e4dcd9ba583936a7da9bbcb865c6be448e2c19
author: Runxi Yu <me@runxiyu.org>
date: Tue Mar 3 17:49:24 EST 2026
reachability: Refactor
--- /dev/null
+++ b/reachability/domain.go
@@ -1,0 +1,11 @@
+package reachability
+
+// Domain specifies which graph edges are traversed.
+type Domain uint8
+
+const (
+ // DomainCommits traverses commit-parent edges and annotated-tag target edges.
+ DomainCommits Domain = iota
+ // DomainObjects traverses full commit/tree/blob objects.
+ DomainObjects
+)
--- /dev/null
+++ b/reachability/errors.go
@@ -1,0 +1,37 @@
+package reachability
+
+import (
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/objecttype"
+)
+
+// ErrObjectMissing indicates that a referenced object is absent from the store.
+type ErrObjectMissing struct {+ OID objectid.ObjectID
+}
+
+func (e *ErrObjectMissing) Error() string {+ return fmt.Sprintf("reachability: missing object %s", e.OID)+}
+
+// ErrObjectType indicates that a referenced object has a different type than
+// what traversal expected on that edge.
+type ErrObjectType struct {+ OID objectid.ObjectID
+ Got objecttype.Type
+ Want objecttype.Type
+}
+
+func (e *ErrObjectType) Error() string {+ gotName, gotOK := objecttype.Name(e.Got)
+ if !gotOK {+ gotName = fmt.Sprintf("type(%d)", e.Got)+ }
+ wantName, wantOK := objecttype.Name(e.Want)
+ if !wantOK {+ wantName = fmt.Sprintf("type(%d)", e.Want)+ }
+ return fmt.Sprintf("reachability: object %s has type %s, want %s", e.OID, gotName, wantName)+}
--- /dev/null
+++ b/reachability/helpers.go
@@ -1,0 +1,64 @@
+package reachability
+
+import (
+ "errors"
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/objectstore"
+ "codeberg.org/lindenii/furgit/objecttype"
+)
+
+func validateDomain(domain Domain) error {+ switch domain {+ case DomainCommits, DomainObjects:
+ return nil
+ default:
+ return fmt.Errorf("reachability: invalid domain %d", domain)+ }
+}
+
+func containsOID(set map[objectid.ObjectID]struct{}, id objectid.ObjectID) bool {+ if len(set) == 0 {+ return false
+ }
+ _, ok := set[id]
+ return ok
+}
+
+// The following helpers exist because we don't have unified error handling across the entire project.
+// This will be fixed later.
+
+func (walk *Walk) readHeaderType(id objectid.ObjectID) (objecttype.Type, error) {+ return walk.reachability.readHeaderType(id)
+}
+
+func (r *Reachability) readHeaderType(id objectid.ObjectID) (objecttype.Type, error) {+ ty, _, err := r.store.ReadHeader(id)
+ if err != nil {+ if errors.Is(err, objectstore.ErrObjectNotFound) {+ return objecttype.TypeInvalid, &ErrObjectMissing{OID: id}+ }
+ return objecttype.TypeInvalid, err
+ }
+ return ty, nil
+}
+
+func (walk *Walk) readBytesContent(id objectid.ObjectID) ([]byte, error) {+ content, err := walk.reachability.readBytesContent(id)
+ if err != nil {+ return nil, err
+ }
+ return content, nil
+}
+
+func (r *Reachability) readBytesContent(id objectid.ObjectID) ([]byte, error) {+ _, content, err := r.store.ReadBytesContent(id)
+ if err != nil {+ if errors.Is(err, objectstore.ErrObjectNotFound) {+ return nil, &ErrObjectMissing{OID: id}+ }
+ return nil, err
+ }
+ return content, nil
+}
--- /dev/null
+++ b/reachability/integration_test.go
@@ -1,0 +1,388 @@
+package reachability_test
+
+import (
+ "errors"
+ "fmt"
+ "maps"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/reachability"
+ "codeberg.org/lindenii/furgit/repository"
+)
+
+func TestWalkCommitsMatchesGitRevList(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{+ ObjectFormat: algo,
+ Bare: true,
+ RefFormat: "files",
+ })
+
+ _, tree1 := testRepo.MakeSingleFileTree(t, "base.txt", []byte("base\n"))+ base := testRepo.CommitTree(t, tree1, "base")
+
+ _, tree2 := testRepo.MakeSingleFileTree(t, "left.txt", []byte("left\n"))+ left := testRepo.CommitTree(t, tree2, "left", base)
+
+ _, tree3 := testRepo.MakeSingleFileTree(t, "right.txt", []byte("right\n"))+ right := testRepo.CommitTree(t, tree3, "right", base)
+
+ _, tree4 := testRepo.MakeSingleFileTree(t, "merge.txt", []byte("merge\n"))+ merge := testRepo.CommitTree(t, tree4, "merge", left, right)
+
+ tag1 := testRepo.TagAnnotated(t, "v1", merge, "v1")
+ tag2 := testRepo.TagAnnotated(t, "v2", tag1, "v2")
+
+ r := openReachabilityFromTestRepo(t, testRepo)
+ walk := r.Walk(
+ reachability.DomainCommits,
+ nil,
+ map[objectid.ObjectID]struct{}{merge: {}},+ )
+ got := oidSetFromSeq(walk.Seq())
+ if err := walk.Err(); err != nil {+ t.Fatalf("walk.Err(): %v", err)+ }
+
+ want := gitRevListSet(t, testRepo, false, []objectid.ObjectID{merge}, nil)+ if !maps.Equal(got, want) {+ t.Fatalf("commit walk mismatch:\n got=%v\nwant=%v", sortedOIDStrings(got), sortedOIDStrings(want))+ }
+
+ peelWalk := r.Walk(
+ reachability.DomainCommits,
+ nil,
+ map[objectid.ObjectID]struct{}{tag2: {}},+ )
+ peelGot := oidSetFromSeq(peelWalk.Seq())
+ if err := peelWalk.Err(); err != nil {+ t.Fatalf("peelWalk.Err(): %v", err)+ }
+ wantWithTags := maps.Clone(want)
+ wantWithTags[tag1] = struct{}{}+ wantWithTags[tag2] = struct{}{}+ if !maps.Equal(peelGot, wantWithTags) {+ t.Fatalf("tag-root commit walk mismatch:\n got=%v\nwant=%v", sortedOIDStrings(peelGot), sortedOIDStrings(wantWithTags))+ }
+ })
+}
+
+func TestWalkObjectsMatchesGitRevListObjects(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{+ ObjectFormat: algo,
+ Bare: true,
+ RefFormat: "files",
+ })
+
+ aBlob := testRepo.HashObject(t, "blob", []byte("a\n"))+ bBlob := testRepo.HashObject(t, "blob", []byte("b\n"))+ nestedTree := testRepo.Mktree(t, fmt.Sprintf("100644 blob %s\tb.txt\n", bBlob))+ rootTree := testRepo.Mktree(t,
+ fmt.Sprintf("100644 blob %s\ta.txt\n040000 tree %s\tdir\n", aBlob, nestedTree),+ )
+ base := testRepo.CommitTree(t, rootTree, "base")
+
+ cBlob := testRepo.HashObject(t, "blob", []byte("c\n"))+ tree2 := testRepo.Mktree(t, fmt.Sprintf("100644 blob %s\tc.txt\n", cBlob))+ head := testRepo.CommitTree(t, tree2, "head", base)
+ tag := testRepo.TagAnnotated(t, "objtag", head, "objtag")
+
+ r := openReachabilityFromTestRepo(t, testRepo)
+ walk := r.Walk(
+ reachability.DomainObjects,
+ nil,
+ map[objectid.ObjectID]struct{}{head: {}},+ )
+ got := oidSetFromSeq(walk.Seq())
+ if err := walk.Err(); err != nil {+ t.Fatalf("walk.Err(): %v", err)+ }
+
+ want := gitRevListSet(t, testRepo, true, []objectid.ObjectID{head}, nil)+ if !maps.Equal(got, want) {+ t.Fatalf("object walk mismatch:\n got=%v\nwant=%v", sortedOIDStrings(got), sortedOIDStrings(want))+ }
+
+ peelWalk := r.Walk(
+ reachability.DomainObjects,
+ nil,
+ map[objectid.ObjectID]struct{}{tag: {}},+ )
+ peelGot := oidSetFromSeq(peelWalk.Seq())
+ if err := peelWalk.Err(); err != nil {+ t.Fatalf("peelWalk.Err(): %v", err)+ }
+ wantFromTag := gitRevListSet(t, testRepo, true, []objectid.ObjectID{tag}, nil)+ if !maps.Equal(peelGot, wantFromTag) {+ t.Fatalf("tag-root object walk mismatch:\n got=%v\nwant=%v", sortedOIDStrings(peelGot), sortedOIDStrings(wantFromTag))+ }
+
+ walkWithHave := r.Walk(
+ reachability.DomainObjects,
+ map[objectid.ObjectID]struct{}{base: {}},+ map[objectid.ObjectID]struct{}{head: {}},+ )
+ withHave := oidSetFromSeq(walkWithHave.Seq())
+ if err := walkWithHave.Err(); err != nil {+ t.Fatalf("walkWithHave.Err(): %v", err)+ }
+ if _, ok := withHave[base]; ok {+ t.Fatalf("walk output unexpectedly contains have commit %s", base)+ }
+ })
+}
+
+func TestIsAncestorMatchesGitMergeBase(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{+ ObjectFormat: algo,
+ Bare: true,
+ RefFormat: "files",
+ })
+
+ _, tree1 := testRepo.MakeSingleFileTree(t, "one.txt", []byte("one\n"))+ c1 := testRepo.CommitTree(t, tree1, "c1")
+
+ _, tree2 := testRepo.MakeSingleFileTree(t, "two.txt", []byte("two\n"))+ c2 := testRepo.CommitTree(t, tree2, "c2", c1)
+
+ _, tree3 := testRepo.MakeSingleFileTree(t, "three.txt", []byte("three\n"))+ c3 := testRepo.CommitTree(t, tree3, "c3", c2)
+
+ tag := testRepo.TagAnnotated(t, "tip", c2, "tip")
+
+ r := openReachabilityFromTestRepo(t, testRepo)
+
+ got, err := r.IsAncestor(c1, tag)
+ if err != nil {+ t.Fatalf("IsAncestor(c1, tag): %v", err)+ }
+ if want := gitMergeBaseIsAncestor(t, testRepo, c1, c2); got != want {+ t.Fatalf("IsAncestor(c1, tag)=%v, want %v", got, want)+ }
+
+ got, err = r.IsAncestor(c3, c2)
+ if err != nil {+ t.Fatalf("IsAncestor(c3, c2): %v", err)+ }
+ if want := gitMergeBaseIsAncestor(t, testRepo, c3, c2); got != want {+ t.Fatalf("IsAncestor(c3, c2)=%v, want %v", got, want)+ }
+ })
+}
+
+func TestCheckConnectedMissingObject(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{+ ObjectFormat: algo,
+ Bare: true,
+ RefFormat: "files",
+ })
+
+ _, treeID, commitID := testRepo.MakeCommit(t, "missing")
+ if err := os.Remove(looseObjectPath(testRepo.Dir(), treeID)); err != nil {+ t.Fatalf("remove tree object: %v", err)+ }
+
+ r := openReachabilityFromTestRepo(t, testRepo)
+ err := r.CheckConnected(
+ reachability.DomainObjects,
+ nil,
+ map[objectid.ObjectID]struct{}{commitID: {}},+ )
+ if err == nil {+ t.Fatal("expected error")+ }
+ var missing *reachability.ErrObjectMissing
+ if !errors.As(err, &missing) {+ t.Fatalf("expected ErrObjectMissing, got %T (%v)", err, err)+ }
+ if missing.OID != treeID {+ t.Fatalf("missing oid = %s, want %s", missing.OID, treeID)+ }
+ })
+}
+
+func TestWalkOnPackedOnlyRepo(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{+ ObjectFormat: algo,
+ Bare: true,
+ RefFormat: "files",
+ })
+
+ _, tree1 := testRepo.MakeSingleFileTree(t, "one.txt", []byte("one\n"))+ c1 := testRepo.CommitTree(t, tree1, "one")
+ _, tree2 := testRepo.MakeSingleFileTree(t, "two.txt", []byte("two\n"))+ c2 := testRepo.CommitTree(t, tree2, "two", c1)
+ testRepo.UpdateRef(t, "refs/heads/main", c2)
+ testRepo.SymbolicRef(t, "HEAD", "refs/heads/main")
+
+ testRepo.Repack(t, "-ad")
+ testRepo.Run(t, "prune-packed")
+
+ assertPackedOnly(t, testRepo.Dir())
+
+ r := openReachabilityFromTestRepo(t, testRepo)
+ walk := r.Walk(
+ reachability.DomainCommits,
+ nil,
+ map[objectid.ObjectID]struct{}{c2: {}},+ )
+ got := oidSetFromSeq(walk.Seq())
+ if err := walk.Err(); err != nil {+ t.Fatalf("walk.Err(): %v", err)+ }
+ if _, ok := got[c2]; !ok {+ t.Fatalf("walk output missing HEAD commit %s", c2)+ }
+ if _, ok := got[c1]; !ok {+ t.Fatalf("walk output missing parent commit %s", c1)+ }
+ })
+}
+
+func openReachabilityFromTestRepo(t *testing.T, testRepo *testgit.TestRepo) *reachability.Reachability {+ t.Helper()
+ root, err := os.OpenRoot(testRepo.Dir())
+ if err != nil {+ t.Fatalf("os.OpenRoot: %v", err)+ }
+ t.Cleanup(func() { _ = root.Close() })+
+ repo, err := repository.Open(root)
+ if err != nil {+ t.Fatalf("repository.Open: %v", err)+ }
+ t.Cleanup(func() { _ = repo.Close() })+
+ return reachability.New(repo.Objects())
+}
+
+func oidSetFromSeq(seq func(func(objectid.ObjectID) bool)) map[objectid.ObjectID]struct{} {+ out := make(map[objectid.ObjectID]struct{})+ seq(func(id objectid.ObjectID) bool {+ out[id] = struct{}{}+ return true
+ })
+ return out
+}
+
+func gitRevListSet(
+ t *testing.T,
+ testRepo *testgit.TestRepo,
+ includeObjects bool,
+ wants []objectid.ObjectID,
+ haves []objectid.ObjectID,
+) map[objectid.ObjectID]struct{} {+ t.Helper()
+
+ args := []string{"rev-list"}+ if includeObjects {+ args = append(args, "--objects")
+ }
+ for _, want := range wants {+ args = append(args, want.String())
+ }
+ if len(haves) > 0 {+ args = append(args, "--not")
+ for _, have := range haves {+ args = append(args, have.String())
+ }
+ }
+
+ out := testRepo.Run(t, args...)
+ set := make(map[objectid.ObjectID]struct{})+ for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") {+ line = strings.TrimSpace(line)
+ if line == "" {+ continue
+ }
+ tok := line
+ if i := strings.IndexByte(tok, ' '); i >= 0 {+ tok = tok[:i]
+ }
+ id, err := objectid.ParseHex(testRepo.Algorithm(), tok)
+ if err != nil {+ t.Fatalf("parse rev-list oid %q: %v", tok, err)+ }
+ set[id] = struct{}{}+ }
+ return set
+}
+
+func gitMergeBaseIsAncestor(t *testing.T, testRepo *testgit.TestRepo, a, b objectid.ObjectID) bool {+ t.Helper()
+ // testgit.Run fatals on non-zero status, so we compare merge-base output.
+ mb := testRepo.Run(t, "merge-base", a.String(), b.String())
+ return mb == a.String()
+}
+
+func sortedOIDStrings(set map[objectid.ObjectID]struct{}) []string {+ out := make([]string, 0, len(set))
+ for id := range set {+ out = append(out, id.String())
+ }
+ slices.Sort(out)
+ return out
+}
+
+func looseObjectPath(repoDir string, id objectid.ObjectID) string {+ hex := id.String()
+ return filepath.Join(repoDir, "objects", hex[:2], hex[2:])
+}
+
+func assertPackedOnly(t *testing.T, repoDir string) {+ t.Helper()
+ objectsDir := filepath.Join(repoDir, "objects")
+
+ entries, err := os.ReadDir(objectsDir)
+ if err != nil {+ t.Fatalf("ReadDir(objects): %v", err)+ }
+ for _, entry := range entries {+ name := entry.Name()
+ if name == "pack" || name == "info" {+ continue
+ }
+ if len(name) == 2 && isHexDirName(name) {+ subEntries, err := os.ReadDir(filepath.Join(objectsDir, name))
+ if err != nil {+ t.Fatalf("ReadDir(objects/%s): %v", name, err)+ }
+ if len(subEntries) != 0 {+ t.Fatalf("found loose objects in %s", filepath.Join(objectsDir, name))+ }
+ }
+ }
+}
+
+func isHexDirName(name string) bool {+ if len(name) != 2 {+ return false
+ }
+ for i := range 2 {+ c := name[i]
+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') {+ return false
+ }
+ }
+ return true
+}
--- /dev/null
+++ b/reachability/peel.go
@@ -1,0 +1,35 @@
+package reachability
+
+import (
+ "codeberg.org/lindenii/furgit/object"
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/objecttype"
+)
+
+func (r *Reachability) peelRootToDomain(id objectid.ObjectID, domain Domain) (objectid.ObjectID, error) {+ if err := validateDomain(domain); err != nil {+ return objectid.ObjectID{}, err+ }
+ for {+ ty, err := r.readHeaderType(id)
+ if err != nil {+ return objectid.ObjectID{}, err+ }
+ if ty != objecttype.TypeTag {+ if domain == DomainCommits && ty != objecttype.TypeCommit {+ return objectid.ObjectID{}, &ErrObjectType{OID: id, Got: ty, Want: objecttype.TypeCommit}+ }
+ return id, nil
+ }
+
+ content, err := r.readBytesContent(id)
+ if err != nil {+ return objectid.ObjectID{}, err+ }
+ tag, err := object.ParseTag(content, id.Algorithm())
+ if err != nil {+ return objectid.ObjectID{}, err+ }
+ id = tag.Target
+ }
+}
--- a/reachability/reachability.go
+++ b/reachability/reachability.go
@@ -1,36 +1,20 @@
package reachability
import (
- "errors"
- "fmt"
- "iter"
-
- "codeberg.org/lindenii/furgit/object"
"codeberg.org/lindenii/furgit/objectid"
"codeberg.org/lindenii/furgit/objectstore"
- "codeberg.org/lindenii/furgit/objecttype"
)
-// Domain specifies which graph edges are traversed.
-type Domain uint8
-
-const (
- // DomainCommits traverses commit-parent edges and annotated-tag target edges.
- DomainCommits Domain = iota
- // DomainObjects traverses full commit/tree/blob objects.
- DomainObjects
-)
-
// Reachability provides graph traversal over objects in one object store.
//
// It is not safe for concurrent use.
type Reachability struct {- Store objectstore.Store
+ store objectstore.Store
}
// New builds a Reachability over one object store.
func New(store objectstore.Store) *Reachability {- return &Reachability{Store: store}+ return &Reachability{store: store}}
// IsAncestor reports whether ancestor is reachable from descendant via commit
@@ -84,299 +68,4 @@
walk.err = err
}
return walk
-}
-
-// ErrObjectMissing indicates that a referenced object is absent from the store.
-type ErrObjectMissing struct {- OID objectid.ObjectID
-}
-
-func (e *ErrObjectMissing) Error() string {- return fmt.Sprintf("reachability: missing object %s", e.OID)-}
-
-// ErrObjectType indicates that a referenced object has a different type than
-// what traversal expected on that edge.
-type ErrObjectType struct {- OID objectid.ObjectID
- Got objecttype.Type
- Want objecttype.Type
-}
-
-func (e *ErrObjectType) Error() string {- gotName, gotOK := objecttype.Name(e.Got)
- if !gotOK {- gotName = fmt.Sprintf("type(%d)", e.Got)- }
- wantName, wantOK := objecttype.Name(e.Want)
- if !wantOK {- wantName = fmt.Sprintf("type(%d)", e.Want)- }
- return fmt.Sprintf("reachability: object %s has type %s, want %s", e.OID, gotName, wantName)-}
-
-// Walk is one single-use iterator-style traversal.
-type Walk struct {- reachability *Reachability
- domain Domain
- haves map[objectid.ObjectID]struct{}- wants map[objectid.ObjectID]struct{}-
- seqUsed bool
- err error
-}
-
-// Seq returns the traversal sequence. It is single-use.
-func (walk *Walk) Seq() iter.Seq[objectid.ObjectID] {- if walk.seqUsed {- return func(yield func(objectid.ObjectID) bool) {- _ = yield
- if walk.err == nil {- walk.err = errors.New("reachability: walk sequence already consumed")- }
- }
- }
- walk.seqUsed = true
- return func(yield func(objectid.ObjectID) bool) {- if walk.err != nil {- return
- }
- stack := walk.initialStack()
- var err error
- visited := make(map[objectid.ObjectID]struct{}, len(stack))- for len(stack) > 0 {- item := stack[len(stack)-1]
- stack = stack[:len(stack)-1]
-
- if containsOID(walk.haves, item.id) {- continue
- }
- if _, ok := visited[item.id]; ok {- continue
- }
- visited[item.id] = struct{}{}-
- var next []walkItem
- next, err = walk.expand(item)
- if err != nil {- walk.err = err
- return
- }
- if !yield(item.id) {- return
- }
- stack = append(stack, next...)
- }
- }
-}
-
-// Err returns the terminal error, if any, once Seq has been consumed.
-func (walk *Walk) Err() error {- return walk.err
-}
-
-type walkItem struct {- id objectid.ObjectID
- want objecttype.Type
-}
-
-func (walk *Walk) initialStack() []walkItem {- if len(walk.wants) == 0 {- return nil
- }
- stack := make([]walkItem, 0, len(walk.wants))
- for want := range walk.wants {- stack = append(stack, walkItem{id: want, want: objecttype.TypeInvalid})- }
- return stack
-}
-
-func (walk *Walk) expand(item walkItem) ([]walkItem, error) {- if walk.domain == DomainCommits {- return walk.expandCommits(item)
- }
- return walk.expandObjects(item)
-}
-
-func (walk *Walk) expandCommits(item walkItem) ([]walkItem, error) {- ty, err := walk.readHeaderType(item.id)
- if err != nil {- return nil, err
- }
- switch ty {- case objecttype.TypeCommit:
- content, err := walk.readBytesContent(item.id)
- if err != nil {- return nil, err
- }
- commit, err := object.ParseCommit(content, item.id.Algorithm())
- if err != nil {- return nil, err
- }
- next := make([]walkItem, 0, len(commit.Parents))
- for _, parent := range commit.Parents {- next = append(next, walkItem{id: parent, want: objecttype.TypeInvalid})- }
- return next, nil
- case objecttype.TypeTag:
- content, err := walk.readBytesContent(item.id)
- if err != nil {- return nil, err
- }
- tag, err := object.ParseTag(content, item.id.Algorithm())
- if err != nil {- return nil, err
- }
- return []walkItem{{id: tag.Target, want: objecttype.TypeInvalid}}, nil- case objecttype.TypeTree, objecttype.TypeBlob, objecttype.TypeInvalid,
- objecttype.TypeFuture, objecttype.TypeOfsDelta, objecttype.TypeRefDelta:
- return nil, &ErrObjectType{OID: item.id, Got: ty, Want: objecttype.TypeCommit}- }
- return nil, fmt.Errorf("reachability: unreachable object type %d", ty)-}
-
-func (walk *Walk) expandObjects(item walkItem) ([]walkItem, error) {- ty, err := walk.readHeaderType(item.id)
- if err != nil {- return nil, err
- }
- if item.want != objecttype.TypeInvalid && ty != item.want {- return nil, &ErrObjectType{OID: item.id, Got: ty, Want: item.want}- }
-
- switch ty {- case objecttype.TypeBlob:
- return nil, nil
- case objecttype.TypeCommit:
- content, err := walk.readBytesContent(item.id)
- if err != nil {- return nil, err
- }
- commit, err := object.ParseCommit(content, item.id.Algorithm())
- if err != nil {- return nil, err
- }
- next := make([]walkItem, 0, len(commit.Parents)+1)
- next = append(next, walkItem{id: commit.Tree, want: objecttype.TypeTree})- for _, parent := range commit.Parents {- next = append(next, walkItem{id: parent, want: objecttype.TypeCommit})- }
- return next, nil
- case objecttype.TypeTree:
- content, err := walk.readBytesContent(item.id)
- if err != nil {- return nil, err
- }
- tree, err := object.ParseTree(content, item.id.Algorithm())
- if err != nil {- return nil, err
- }
- next := make([]walkItem, 0, len(tree.Entries))
- for _, entry := range tree.Entries {- switch entry.Mode {- case object.FileModeGitlink:
- continue
- case object.FileModeDir:
- next = append(next, walkItem{id: entry.ID, want: objecttype.TypeTree})- case object.FileModeRegular, object.FileModeExecutable, object.FileModeSymlink:
- next = append(next, walkItem{id: entry.ID, want: objecttype.TypeBlob})- }
- }
- return next, nil
- case objecttype.TypeTag:
- content, err := walk.readBytesContent(item.id)
- if err != nil {- return nil, err
- }
- tag, err := object.ParseTag(content, item.id.Algorithm())
- if err != nil {- return nil, err
- }
- return []walkItem{{id: tag.Target, want: tag.TargetType}}, nil- case objecttype.TypeInvalid, objecttype.TypeFuture, objecttype.TypeOfsDelta, objecttype.TypeRefDelta:
- return nil, &ErrObjectType{OID: item.id, Got: ty, Want: item.want}- }
- return nil, fmt.Errorf("reachability: unreachable object type %d", ty)-}
-
-func (r *Reachability) peelRootToDomain(id objectid.ObjectID, domain Domain) (objectid.ObjectID, error) {- if err := validateDomain(domain); err != nil {- return objectid.ObjectID{}, err- }
- for {- ty, err := r.readHeaderType(id)
- if err != nil {- return objectid.ObjectID{}, err- }
- if ty != objecttype.TypeTag {- if domain == DomainCommits && ty != objecttype.TypeCommit {- return objectid.ObjectID{}, &ErrObjectType{OID: id, Got: ty, Want: objecttype.TypeCommit}- }
- return id, nil
- }
-
- content, err := r.readBytesContent(id)
- if err != nil {- return objectid.ObjectID{}, err- }
- tag, err := object.ParseTag(content, id.Algorithm())
- if err != nil {- return objectid.ObjectID{}, err- }
- id = tag.Target
- }
-}
-
-func validateDomain(domain Domain) error {- switch domain {- case DomainCommits, DomainObjects:
- return nil
- default:
- return fmt.Errorf("reachability: invalid domain %d", domain)- }
-}
-
-func containsOID(set map[objectid.ObjectID]struct{}, id objectid.ObjectID) bool {- if len(set) == 0 {- return false
- }
- _, ok := set[id]
- return ok
-}
-
-// The following helpers exist because we don't have unified error handling across the entire project.
-// This will be fixed later.
-
-func (walk *Walk) readHeaderType(id objectid.ObjectID) (objecttype.Type, error) {- return walk.reachability.readHeaderType(id)
-}
-
-func (r *Reachability) readHeaderType(id objectid.ObjectID) (objecttype.Type, error) {- ty, _, err := r.Store.ReadHeader(id)
- if err != nil {- if errors.Is(err, objectstore.ErrObjectNotFound) {- return objecttype.TypeInvalid, &ErrObjectMissing{OID: id}- }
- return objecttype.TypeInvalid, err
- }
- return ty, nil
-}
-
-func (walk *Walk) readBytesContent(id objectid.ObjectID) ([]byte, error) {- content, err := walk.reachability.readBytesContent(id)
- if err != nil {- return nil, err
- }
- return content, nil
-}
-
-func (r *Reachability) readBytesContent(id objectid.ObjectID) ([]byte, error) {- _, content, err := r.Store.ReadBytesContent(id)
- if err != nil {- if errors.Is(err, objectstore.ErrObjectNotFound) {- return nil, &ErrObjectMissing{OID: id}- }
- return nil, err
- }
- return content, nil
}
--- a/reachability/reachability_integration_test.go
+++ /dev/null
@@ -1,388 +1,0 @@
-package reachability_test
-
-import (
- "errors"
- "fmt"
- "maps"
- "os"
- "path/filepath"
- "slices"
- "strings"
- "testing"
-
- "codeberg.org/lindenii/furgit/internal/testgit"
- "codeberg.org/lindenii/furgit/objectid"
- "codeberg.org/lindenii/furgit/reachability"
- "codeberg.org/lindenii/furgit/repository"
-)
-
-func TestWalkCommitsMatchesGitRevList(t *testing.T) {- t.Parallel()
-
- testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper- testRepo := testgit.NewRepo(t, testgit.RepoOptions{- ObjectFormat: algo,
- Bare: true,
- RefFormat: "files",
- })
-
- _, tree1 := testRepo.MakeSingleFileTree(t, "base.txt", []byte("base\n"))- base := testRepo.CommitTree(t, tree1, "base")
-
- _, tree2 := testRepo.MakeSingleFileTree(t, "left.txt", []byte("left\n"))- left := testRepo.CommitTree(t, tree2, "left", base)
-
- _, tree3 := testRepo.MakeSingleFileTree(t, "right.txt", []byte("right\n"))- right := testRepo.CommitTree(t, tree3, "right", base)
-
- _, tree4 := testRepo.MakeSingleFileTree(t, "merge.txt", []byte("merge\n"))- merge := testRepo.CommitTree(t, tree4, "merge", left, right)
-
- tag1 := testRepo.TagAnnotated(t, "v1", merge, "v1")
- tag2 := testRepo.TagAnnotated(t, "v2", tag1, "v2")
-
- r := openReachabilityFromTestRepo(t, testRepo)
- walk := r.Walk(
- reachability.DomainCommits,
- nil,
- map[objectid.ObjectID]struct{}{merge: {}},- )
- got := oidSetFromSeq(walk.Seq())
- if err := walk.Err(); err != nil {- t.Fatalf("walk.Err(): %v", err)- }
-
- want := gitRevListSet(t, testRepo, false, []objectid.ObjectID{merge}, nil)- if !maps.Equal(got, want) {- t.Fatalf("commit walk mismatch:\n got=%v\nwant=%v", sortedOIDStrings(got), sortedOIDStrings(want))- }
-
- peelWalk := r.Walk(
- reachability.DomainCommits,
- nil,
- map[objectid.ObjectID]struct{}{tag2: {}},- )
- peelGot := oidSetFromSeq(peelWalk.Seq())
- if err := peelWalk.Err(); err != nil {- t.Fatalf("peelWalk.Err(): %v", err)- }
- wantWithTags := maps.Clone(want)
- wantWithTags[tag1] = struct{}{}- wantWithTags[tag2] = struct{}{}- if !maps.Equal(peelGot, wantWithTags) {- t.Fatalf("tag-root commit walk mismatch:\n got=%v\nwant=%v", sortedOIDStrings(peelGot), sortedOIDStrings(wantWithTags))- }
- })
-}
-
-func TestWalkObjectsMatchesGitRevListObjects(t *testing.T) {- t.Parallel()
-
- testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper- testRepo := testgit.NewRepo(t, testgit.RepoOptions{- ObjectFormat: algo,
- Bare: true,
- RefFormat: "files",
- })
-
- aBlob := testRepo.HashObject(t, "blob", []byte("a\n"))- bBlob := testRepo.HashObject(t, "blob", []byte("b\n"))- nestedTree := testRepo.Mktree(t, fmt.Sprintf("100644 blob %s\tb.txt\n", bBlob))- rootTree := testRepo.Mktree(t,
- fmt.Sprintf("100644 blob %s\ta.txt\n040000 tree %s\tdir\n", aBlob, nestedTree),- )
- base := testRepo.CommitTree(t, rootTree, "base")
-
- cBlob := testRepo.HashObject(t, "blob", []byte("c\n"))- tree2 := testRepo.Mktree(t, fmt.Sprintf("100644 blob %s\tc.txt\n", cBlob))- head := testRepo.CommitTree(t, tree2, "head", base)
- tag := testRepo.TagAnnotated(t, "objtag", head, "objtag")
-
- r := openReachabilityFromTestRepo(t, testRepo)
- walk := r.Walk(
- reachability.DomainObjects,
- nil,
- map[objectid.ObjectID]struct{}{head: {}},- )
- got := oidSetFromSeq(walk.Seq())
- if err := walk.Err(); err != nil {- t.Fatalf("walk.Err(): %v", err)- }
-
- want := gitRevListSet(t, testRepo, true, []objectid.ObjectID{head}, nil)- if !maps.Equal(got, want) {- t.Fatalf("object walk mismatch:\n got=%v\nwant=%v", sortedOIDStrings(got), sortedOIDStrings(want))- }
-
- peelWalk := r.Walk(
- reachability.DomainObjects,
- nil,
- map[objectid.ObjectID]struct{}{tag: {}},- )
- peelGot := oidSetFromSeq(peelWalk.Seq())
- if err := peelWalk.Err(); err != nil {- t.Fatalf("peelWalk.Err(): %v", err)- }
- wantFromTag := gitRevListSet(t, testRepo, true, []objectid.ObjectID{tag}, nil)- if !maps.Equal(peelGot, wantFromTag) {- t.Fatalf("tag-root object walk mismatch:\n got=%v\nwant=%v", sortedOIDStrings(peelGot), sortedOIDStrings(wantFromTag))- }
-
- walkWithHave := r.Walk(
- reachability.DomainObjects,
- map[objectid.ObjectID]struct{}{base: {}},- map[objectid.ObjectID]struct{}{head: {}},- )
- withHave := oidSetFromSeq(walkWithHave.Seq())
- if err := walkWithHave.Err(); err != nil {- t.Fatalf("walkWithHave.Err(): %v", err)- }
- if _, ok := withHave[base]; ok {- t.Fatalf("walk output unexpectedly contains have commit %s", base)- }
- })
-}
-
-func TestIsAncestorMatchesGitMergeBase(t *testing.T) {- t.Parallel()
-
- testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper- testRepo := testgit.NewRepo(t, testgit.RepoOptions{- ObjectFormat: algo,
- Bare: true,
- RefFormat: "files",
- })
-
- _, tree1 := testRepo.MakeSingleFileTree(t, "one.txt", []byte("one\n"))- c1 := testRepo.CommitTree(t, tree1, "c1")
-
- _, tree2 := testRepo.MakeSingleFileTree(t, "two.txt", []byte("two\n"))- c2 := testRepo.CommitTree(t, tree2, "c2", c1)
-
- _, tree3 := testRepo.MakeSingleFileTree(t, "three.txt", []byte("three\n"))- c3 := testRepo.CommitTree(t, tree3, "c3", c2)
-
- tag := testRepo.TagAnnotated(t, "tip", c2, "tip")
-
- r := openReachabilityFromTestRepo(t, testRepo)
-
- got, err := r.IsAncestor(c1, tag)
- if err != nil {- t.Fatalf("IsAncestor(c1, tag): %v", err)- }
- if want := gitMergeBaseIsAncestor(t, testRepo, c1, c2); got != want {- t.Fatalf("IsAncestor(c1, tag)=%v, want %v", got, want)- }
-
- got, err = r.IsAncestor(c3, c2)
- if err != nil {- t.Fatalf("IsAncestor(c3, c2): %v", err)- }
- if want := gitMergeBaseIsAncestor(t, testRepo, c3, c2); got != want {- t.Fatalf("IsAncestor(c3, c2)=%v, want %v", got, want)- }
- })
-}
-
-func TestCheckConnectedMissingObject(t *testing.T) {- t.Parallel()
-
- testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper- testRepo := testgit.NewRepo(t, testgit.RepoOptions{- ObjectFormat: algo,
- Bare: true,
- RefFormat: "files",
- })
-
- _, treeID, commitID := testRepo.MakeCommit(t, "missing")
- if err := os.Remove(looseObjectPath(testRepo.Dir(), treeID)); err != nil {- t.Fatalf("remove tree object: %v", err)- }
-
- r := openReachabilityFromTestRepo(t, testRepo)
- err := r.CheckConnected(
- reachability.DomainObjects,
- nil,
- map[objectid.ObjectID]struct{}{commitID: {}},- )
- if err == nil {- t.Fatal("expected error")- }
- var missing *reachability.ErrObjectMissing
- if !errors.As(err, &missing) {- t.Fatalf("expected ErrObjectMissing, got %T (%v)", err, err)- }
- if missing.OID != treeID {- t.Fatalf("missing oid = %s, want %s", missing.OID, treeID)- }
- })
-}
-
-func TestWalkOnPackedOnlyRepo(t *testing.T) {- t.Parallel()
-
- testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper- testRepo := testgit.NewRepo(t, testgit.RepoOptions{- ObjectFormat: algo,
- Bare: true,
- RefFormat: "files",
- })
-
- _, tree1 := testRepo.MakeSingleFileTree(t, "one.txt", []byte("one\n"))- c1 := testRepo.CommitTree(t, tree1, "one")
- _, tree2 := testRepo.MakeSingleFileTree(t, "two.txt", []byte("two\n"))- c2 := testRepo.CommitTree(t, tree2, "two", c1)
- testRepo.UpdateRef(t, "refs/heads/main", c2)
- testRepo.SymbolicRef(t, "HEAD", "refs/heads/main")
-
- testRepo.Repack(t, "-ad")
- testRepo.Run(t, "prune-packed")
-
- assertPackedOnly(t, testRepo.Dir())
-
- r := openReachabilityFromTestRepo(t, testRepo)
- walk := r.Walk(
- reachability.DomainCommits,
- nil,
- map[objectid.ObjectID]struct{}{c2: {}},- )
- got := oidSetFromSeq(walk.Seq())
- if err := walk.Err(); err != nil {- t.Fatalf("walk.Err(): %v", err)- }
- if _, ok := got[c2]; !ok {- t.Fatalf("walk output missing HEAD commit %s", c2)- }
- if _, ok := got[c1]; !ok {- t.Fatalf("walk output missing parent commit %s", c1)- }
- })
-}
-
-func openReachabilityFromTestRepo(t *testing.T, testRepo *testgit.TestRepo) *reachability.Reachability {- t.Helper()
- root, err := os.OpenRoot(testRepo.Dir())
- if err != nil {- t.Fatalf("os.OpenRoot: %v", err)- }
- t.Cleanup(func() { _ = root.Close() })-
- repo, err := repository.Open(root)
- if err != nil {- t.Fatalf("repository.Open: %v", err)- }
- t.Cleanup(func() { _ = repo.Close() })-
- return reachability.New(repo.Objects())
-}
-
-func oidSetFromSeq(seq func(func(objectid.ObjectID) bool)) map[objectid.ObjectID]struct{} {- out := make(map[objectid.ObjectID]struct{})- seq(func(id objectid.ObjectID) bool {- out[id] = struct{}{}- return true
- })
- return out
-}
-
-func gitRevListSet(
- t *testing.T,
- testRepo *testgit.TestRepo,
- includeObjects bool,
- wants []objectid.ObjectID,
- haves []objectid.ObjectID,
-) map[objectid.ObjectID]struct{} {- t.Helper()
-
- args := []string{"rev-list"}- if includeObjects {- args = append(args, "--objects")
- }
- for _, want := range wants {- args = append(args, want.String())
- }
- if len(haves) > 0 {- args = append(args, "--not")
- for _, have := range haves {- args = append(args, have.String())
- }
- }
-
- out := testRepo.Run(t, args...)
- set := make(map[objectid.ObjectID]struct{})- for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") {- line = strings.TrimSpace(line)
- if line == "" {- continue
- }
- tok := line
- if i := strings.IndexByte(tok, ' '); i >= 0 {- tok = tok[:i]
- }
- id, err := objectid.ParseHex(testRepo.Algorithm(), tok)
- if err != nil {- t.Fatalf("parse rev-list oid %q: %v", tok, err)- }
- set[id] = struct{}{}- }
- return set
-}
-
-func gitMergeBaseIsAncestor(t *testing.T, testRepo *testgit.TestRepo, a, b objectid.ObjectID) bool {- t.Helper()
- // testgit.Run fatals on non-zero status, so we compare merge-base output.
- mb := testRepo.Run(t, "merge-base", a.String(), b.String())
- return mb == a.String()
-}
-
-func sortedOIDStrings(set map[objectid.ObjectID]struct{}) []string {- out := make([]string, 0, len(set))
- for id := range set {- out = append(out, id.String())
- }
- slices.Sort(out)
- return out
-}
-
-func looseObjectPath(repoDir string, id objectid.ObjectID) string {- hex := id.String()
- return filepath.Join(repoDir, "objects", hex[:2], hex[2:])
-}
-
-func assertPackedOnly(t *testing.T, repoDir string) {- t.Helper()
- objectsDir := filepath.Join(repoDir, "objects")
-
- entries, err := os.ReadDir(objectsDir)
- if err != nil {- t.Fatalf("ReadDir(objects): %v", err)- }
- for _, entry := range entries {- name := entry.Name()
- if name == "pack" || name == "info" {- continue
- }
- if len(name) == 2 && isHexDirName(name) {- subEntries, err := os.ReadDir(filepath.Join(objectsDir, name))
- if err != nil {- t.Fatalf("ReadDir(objects/%s): %v", name, err)- }
- if len(subEntries) != 0 {- t.Fatalf("found loose objects in %s", filepath.Join(objectsDir, name))- }
- }
- }
-}
-
-func isHexDirName(name string) bool {- if len(name) != 2 {- return false
- }
- for i := range 2 {- c := name[i]
- if (c < '0' || c > '9') && (c < 'a' || c > 'f') {- return false
- }
- }
- return true
-}
--- a/reachability/reachability_unit_test.go
+++ /dev/null
@@ -1,422 +1,0 @@
-package reachability_test
-
-import (
- "bytes"
- "errors"
- "fmt"
- "io"
- "maps"
- "slices"
- "testing"
-
- "codeberg.org/lindenii/furgit/internal/testgit"
- "codeberg.org/lindenii/furgit/object"
- "codeberg.org/lindenii/furgit/objectheader"
- "codeberg.org/lindenii/furgit/objectid"
- "codeberg.org/lindenii/furgit/objectstore"
- "codeberg.org/lindenii/furgit/objecttype"
- "codeberg.org/lindenii/furgit/reachability"
-)
-
-type storeObject struct {- ty objecttype.Type
- content []byte
-}
-
-type memStore struct {- algo objectid.Algorithm
- objects map[objectid.ObjectID]storeObject
- readBytesByObjectID map[objectid.ObjectID]int
-}
-
-func newMemStore(algo objectid.Algorithm) *memStore {- return &memStore{- algo: algo,
- objects: make(map[objectid.ObjectID]storeObject),
- readBytesByObjectID: make(map[objectid.ObjectID]int),
- }
-}
-
-func (store *memStore) ReadBytesFull(id objectid.ObjectID) ([]byte, error) {- obj, ok := store.objects[id]
- if !ok {- return nil, objectstore.ErrObjectNotFound
- }
- header, ok := objectheader.Encode(obj.ty, int64(len(obj.content)))
- if !ok {- panic("failed to encode object header")- }
- raw := make([]byte, len(header)+len(obj.content))
- copy(raw, header)
- copy(raw[len(header):], obj.content)
- return raw, nil
-}
-
-func (store *memStore) ReadBytesContent(id objectid.ObjectID) (objecttype.Type, []byte, error) {- obj, ok := store.objects[id]
- if !ok {- return objecttype.TypeInvalid, nil, objectstore.ErrObjectNotFound
- }
- store.readBytesByObjectID[id]++
- return obj.ty, append([]byte(nil), obj.content...), nil
-}
-
-func (store *memStore) ReadReaderFull(id objectid.ObjectID) (io.ReadCloser, error) {- raw, err := store.ReadBytesFull(id)
- if err != nil {- return nil, err
- }
- return io.NopCloser(bytes.NewReader(raw)), nil
-}
-
-func (store *memStore) ReadReaderContent(id objectid.ObjectID) (objecttype.Type, int64, io.ReadCloser, error) {- ty, content, err := store.ReadBytesContent(id)
- if err != nil {- return objecttype.TypeInvalid, 0, nil, err
- }
- return ty, int64(len(content)), io.NopCloser(bytes.NewReader(content)), nil
-}
-
-func (store *memStore) ReadSize(id objectid.ObjectID) (int64, error) {- _, size, err := store.ReadHeader(id)
- if err != nil {- return 0, err
- }
- return size, nil
-}
-
-func (store *memStore) ReadHeader(id objectid.ObjectID) (objecttype.Type, int64, error) {- obj, ok := store.objects[id]
- if !ok {- return objecttype.TypeInvalid, 0, objectstore.ErrObjectNotFound
- }
- return obj.ty, int64(len(obj.content)), nil
-}
-
-func (store *memStore) Close() error {- return nil
-}
-
-func commitBody(tree objectid.ObjectID, parents ...objectid.ObjectID) []byte {- buf := fmt.Appendf(nil, "tree %s\n", tree.String())
- for _, parent := range parents {- buf = append(buf, fmt.Appendf(nil, "parent %s\n", parent.String())...)
- }
- buf = append(buf, []byte("\nmsg\n")...)- return buf
-}
-
-func tagBody(target objectid.ObjectID, targetType objecttype.Type) []byte {- targetName, ok := objecttype.Name(targetType)
- if !ok {- panic("invalid tag target type")- }
- return fmt.Appendf(nil, "object %s\ntype %s\ntag t\n\nmsg\n", target.String(), targetName)
-}
-
-func collectSeq(seq func(func(objectid.ObjectID) bool)) []objectid.ObjectID {- var out []objectid.ObjectID
- seq(func(id objectid.ObjectID) bool {- out = append(out, id)
- return true
- })
- return out
-}
-
-func toSet(ids []objectid.ObjectID) map[objectid.ObjectID]struct{} {- set := make(map[objectid.ObjectID]struct{}, len(ids))- for _, id := range ids {- set[id] = struct{}{}- }
- return set
-}
-
-func TestWalkDomainCommitsIncludesTagNodes(t *testing.T) {- t.Parallel()
-
- testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper- store := newMemStore(algo)
- blob := store.addObject(objecttype.TypeBlob, []byte("blob\n"))- tree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{- Mode: object.FileModeRegular,
- Name: []byte("f"),- ID: blob,
- }}}))
- commit1 := store.addObject(objecttype.TypeCommit, commitBody(tree))
- commit2 := store.addObject(objecttype.TypeCommit, commitBody(tree, commit1))
- tag1 := store.addObject(objecttype.TypeTag, tagBody(commit2, objecttype.TypeCommit))
- tag2 := store.addObject(objecttype.TypeTag, tagBody(tag1, objecttype.TypeTag))
-
- r := reachability.New(store)
- walk := r.Walk(reachability.DomainCommits, nil, map[objectid.ObjectID]struct{}{tag2: {}})- got := collectSeq(walk.Seq())
- if err := walk.Err(); err != nil {- t.Fatalf("walk.Err(): %v", err)- }
-
- gotSet := toSet(got)
- wantSet := map[objectid.ObjectID]struct{}{tag2: {}, tag1: {}, commit2: {}, commit1: {}}- if !maps.Equal(gotSet, wantSet) {- t.Fatalf("walk output mismatch: got %v, want %v", slices.Collect(maps.Keys(gotSet)), slices.Collect(maps.Keys(wantSet)))- }
- })
-}
-
-func TestWalkExcludesHavesCompletely(t *testing.T) {- t.Parallel()
-
- testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper- store := newMemStore(algo)
- blob := store.addObject(objecttype.TypeBlob, []byte("blob\n"))- tree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{- Mode: object.FileModeRegular,
- Name: []byte("f"),- ID: blob,
- }}}))
- commit := store.addObject(objecttype.TypeCommit, commitBody(tree))
-
- r := reachability.New(store)
- walk := r.Walk(reachability.DomainCommits, map[objectid.ObjectID]struct{}{commit: {}}, map[objectid.ObjectID]struct{}{commit: {}})- got := collectSeq(walk.Seq())
- if err := walk.Err(); err != nil {- t.Fatalf("walk.Err(): %v", err)- }
- if len(got) != 0 {- t.Fatalf("expected empty output, got %v", got)- }
- })
-}
-
-func TestWalkDomainCommitsRejectsNonCommitRootAfterPeel(t *testing.T) {- t.Parallel()
-
- testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper- store := newMemStore(algo)
- blob := store.addObject(objecttype.TypeBlob, []byte("blob\n"))- tree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{- Mode: object.FileModeRegular,
- Name: []byte("f"),- ID: blob,
- }}}))
- tag := store.addObject(objecttype.TypeTag, tagBody(tree, objecttype.TypeTree))
-
- r := reachability.New(store)
- walk := r.Walk(reachability.DomainCommits, nil, map[objectid.ObjectID]struct{}{tag: {}})- _ = collectSeq(walk.Seq())
- err := walk.Err()
- if err == nil {- t.Fatal("expected error")- }
- var typeErr *reachability.ErrObjectType
- if !errors.As(err, &typeErr) {- t.Fatalf("expected ErrObjectType, got %T (%v)", err, err)- }
- if typeErr.Got != objecttype.TypeTree || typeErr.Want != objecttype.TypeCommit {- t.Fatalf("unexpected type error: %+v", typeErr)- }
- })
-}
-
-func TestWalkDomainCommitsHaveTagStopsTraversal(t *testing.T) {- t.Parallel()
-
- testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper- store := newMemStore(algo)
- blob := store.addObject(objecttype.TypeBlob, []byte("blob\n"))- tree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{- Mode: object.FileModeRegular,
- Name: []byte("f"),- ID: blob,
- }}}))
- commit1 := store.addObject(objecttype.TypeCommit, commitBody(tree))
- commit2 := store.addObject(objecttype.TypeCommit, commitBody(tree, commit1))
- tag1 := store.addObject(objecttype.TypeTag, tagBody(commit2, objecttype.TypeCommit))
- tag2 := store.addObject(objecttype.TypeTag, tagBody(tag1, objecttype.TypeTag))
-
- r := reachability.New(store)
- walk := r.Walk(
- reachability.DomainCommits,
- map[objectid.ObjectID]struct{}{tag1: {}},- map[objectid.ObjectID]struct{}{tag2: {}},- )
- got := collectSeq(walk.Seq())
- if err := walk.Err(); err != nil {- t.Fatalf("walk.Err(): %v", err)- }
-
- gotSet := toSet(got)
- wantSet := map[objectid.ObjectID]struct{}{tag2: {}}- if !maps.Equal(gotSet, wantSet) {- t.Fatalf("walk output mismatch: got %v, want %v", slices.Collect(maps.Keys(gotSet)), slices.Collect(maps.Keys(wantSet)))- }
- })
-}
-
-func TestWalkDomainObjectsRecursesTreesAndSkipsBlobContentReads(t *testing.T) {- t.Parallel()
-
- testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper- store := newMemStore(algo)
-
- blob1 := store.addObject(objecttype.TypeBlob, []byte("b1\n"))- blob2 := store.addObject(objecttype.TypeBlob, []byte("b2\n"))- gitlinkTarget := store.algo.Sum([]byte("external-submodule"))-
- subtree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{- Mode: object.FileModeRegular,
- Name: []byte("nested"),- ID: blob2,
- }}}))
- rootTree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{- {Mode: object.FileModeRegular, Name: []byte("a"), ID: blob1},- {Mode: object.FileModeDir, Name: []byte("dir"), ID: subtree},- {Mode: object.FileModeGitlink, Name: []byte("submodule"), ID: gitlinkTarget},- }}))
- commit := store.addObject(objecttype.TypeCommit, commitBody(rootTree))
-
- r := reachability.New(store)
- walk := r.Walk(reachability.DomainObjects, nil, map[objectid.ObjectID]struct{}{commit: {}})- got := collectSeq(walk.Seq())
- if err := walk.Err(); err != nil {- t.Fatalf("walk.Err(): %v", err)- }
-
- gotSet := toSet(got)
- wantSet := map[objectid.ObjectID]struct{}{commit: {}, rootTree: {}, subtree: {}, blob1: {}, blob2: {}}- if !maps.Equal(gotSet, wantSet) {- t.Fatalf("walk output mismatch: got %v, want %v", slices.Collect(maps.Keys(gotSet)), slices.Collect(maps.Keys(wantSet)))- }
- if store.readBytesByObjectID[blob1] != 0 || store.readBytesByObjectID[blob2] != 0 {- t.Fatalf("blob contents should not be read; counts: blob1=%d blob2=%d", store.readBytesByObjectID[blob1], store.readBytesByObjectID[blob2])- }
- })
-}
-
-func TestCheckConnectedReturnsConcreteMissingObject(t *testing.T) {- t.Parallel()
-
- testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper- store := newMemStore(algo)
- blob := store.addObject(objecttype.TypeBlob, []byte("blob\n"))- tree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{- Mode: object.FileModeRegular,
- Name: []byte("f"),- ID: blob,
- }}}))
- missingParent := store.algo.Sum([]byte("missing-parent"))- commit := store.addObject(objecttype.TypeCommit, commitBody(tree, missingParent))
-
- r := reachability.New(store)
- err := r.CheckConnected(reachability.DomainCommits, nil, map[objectid.ObjectID]struct{}{commit: {}})- if err == nil {- t.Fatal("expected error")- }
- var missing *reachability.ErrObjectMissing
- if !errors.As(err, &missing) {- t.Fatalf("expected ErrObjectMissing, got %T (%v)", err, err)- }
- if missing.OID != missingParent {- t.Fatalf("unexpected missing oid: got %s want %s", missing.OID, missingParent)- }
- })
-}
-
-func TestWalkInvalidDomainReturnsPlainError(t *testing.T) {- t.Parallel()
-
- testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper- r := reachability.New(newMemStore(algo))
- walk := r.Walk(reachability.Domain(99), nil, nil)
- _ = collectSeq(walk.Seq())
- if err := walk.Err(); err == nil {- t.Fatal("expected error")- }
- })
-}
-
-func TestIsAncestor(t *testing.T) {- t.Parallel()
-
- testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper- store := newMemStore(algo)
- blob := store.addObject(objecttype.TypeBlob, []byte("blob\n"))- tree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{- Mode: object.FileModeRegular,
- Name: []byte("f"),- ID: blob,
- }}}))
- c1 := store.addObject(objecttype.TypeCommit, commitBody(tree))
- c2 := store.addObject(objecttype.TypeCommit, commitBody(tree, c1))
- otherBlob := store.addObject(objecttype.TypeBlob, []byte("other-blob\n"))- otherTree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{- Mode: object.FileModeRegular,
- Name: []byte("g"),- ID: otherBlob,
- }}}))
- c3 := store.addObject(objecttype.TypeCommit, commitBody(otherTree))
- tag := store.addObject(objecttype.TypeTag, tagBody(c2, objecttype.TypeCommit))
-
- r := reachability.New(store)
- ok, err := r.IsAncestor(c1, tag)
- if err != nil {- t.Fatalf("IsAncestor(c1, tag): %v", err)- }
- if !ok {- t.Fatal("expected c1 to be ancestor of tag->c2")- }
-
- ok, err = r.IsAncestor(c3, c2)
- if err != nil {- t.Fatalf("IsAncestor(c3, c2): %v", err)- }
- if ok {- t.Fatal("did not expect c3 to be ancestor of c2")- }
- })
-}
-
-func TestIsAncestorRejectsNonCommitAfterPeel(t *testing.T) {- t.Parallel()
-
- testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper- store := newMemStore(algo)
- blob := store.addObject(objecttype.TypeBlob, []byte("blob\n"))- tree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{- Mode: object.FileModeRegular,
- Name: []byte("f"),- ID: blob,
- }}}))
- commit := store.addObject(objecttype.TypeCommit, commitBody(tree))
- tagToTree := store.addObject(objecttype.TypeTag, tagBody(tree, objecttype.TypeTree))
-
- r := reachability.New(store)
- _, err := r.IsAncestor(commit, tagToTree)
- if err == nil {- t.Fatal("expected error")- }
- var typeErr *reachability.ErrObjectType
- if !errors.As(err, &typeErr) {- t.Fatalf("expected ErrObjectType, got %T (%v)", err, err)- }
- })
-}
-
-func mustSerializeTree(tb testing.TB, tree *object.Tree) []byte {- tb.Helper()
- body, err := tree.SerializeWithoutHeader()
- if err != nil {- tb.Fatalf("SerializeWithoutHeader: %v", err)- }
- return body
-}
-
-func (store *memStore) addObject(ty objecttype.Type, body []byte) objectid.ObjectID {- header, ok := objectheader.Encode(ty, int64(len(body)))
- if !ok {- panic("failed to encode object header")- }
- raw := append(append([]byte(nil), header...), body...)
- id := store.algo.Sum(raw)
- store.objects[id] = storeObject{ty: ty, content: append([]byte(nil), body...)}- return id
-}
--- /dev/null
+++ b/reachability/unit_test.go
@@ -1,0 +1,422 @@
+package reachability_test
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "maps"
+ "slices"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ "codeberg.org/lindenii/furgit/object"
+ "codeberg.org/lindenii/furgit/objectheader"
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/objectstore"
+ "codeberg.org/lindenii/furgit/objecttype"
+ "codeberg.org/lindenii/furgit/reachability"
+)
+
+type storeObject struct {+ ty objecttype.Type
+ content []byte
+}
+
+type memStore struct {+ algo objectid.Algorithm
+ objects map[objectid.ObjectID]storeObject
+ readBytesByObjectID map[objectid.ObjectID]int
+}
+
+func newMemStore(algo objectid.Algorithm) *memStore {+ return &memStore{+ algo: algo,
+ objects: make(map[objectid.ObjectID]storeObject),
+ readBytesByObjectID: make(map[objectid.ObjectID]int),
+ }
+}
+
+func (store *memStore) ReadBytesFull(id objectid.ObjectID) ([]byte, error) {+ obj, ok := store.objects[id]
+ if !ok {+ return nil, objectstore.ErrObjectNotFound
+ }
+ header, ok := objectheader.Encode(obj.ty, int64(len(obj.content)))
+ if !ok {+ panic("failed to encode object header")+ }
+ raw := make([]byte, len(header)+len(obj.content))
+ copy(raw, header)
+ copy(raw[len(header):], obj.content)
+ return raw, nil
+}
+
+func (store *memStore) ReadBytesContent(id objectid.ObjectID) (objecttype.Type, []byte, error) {+ obj, ok := store.objects[id]
+ if !ok {+ return objecttype.TypeInvalid, nil, objectstore.ErrObjectNotFound
+ }
+ store.readBytesByObjectID[id]++
+ return obj.ty, append([]byte(nil), obj.content...), nil
+}
+
+func (store *memStore) ReadReaderFull(id objectid.ObjectID) (io.ReadCloser, error) {+ raw, err := store.ReadBytesFull(id)
+ if err != nil {+ return nil, err
+ }
+ return io.NopCloser(bytes.NewReader(raw)), nil
+}
+
+func (store *memStore) ReadReaderContent(id objectid.ObjectID) (objecttype.Type, int64, io.ReadCloser, error) {+ ty, content, err := store.ReadBytesContent(id)
+ if err != nil {+ return objecttype.TypeInvalid, 0, nil, err
+ }
+ return ty, int64(len(content)), io.NopCloser(bytes.NewReader(content)), nil
+}
+
+func (store *memStore) ReadSize(id objectid.ObjectID) (int64, error) {+ _, size, err := store.ReadHeader(id)
+ if err != nil {+ return 0, err
+ }
+ return size, nil
+}
+
+func (store *memStore) ReadHeader(id objectid.ObjectID) (objecttype.Type, int64, error) {+ obj, ok := store.objects[id]
+ if !ok {+ return objecttype.TypeInvalid, 0, objectstore.ErrObjectNotFound
+ }
+ return obj.ty, int64(len(obj.content)), nil
+}
+
+func (store *memStore) Close() error {+ return nil
+}
+
+func commitBody(tree objectid.ObjectID, parents ...objectid.ObjectID) []byte {+ buf := fmt.Appendf(nil, "tree %s\n", tree.String())
+ for _, parent := range parents {+ buf = append(buf, fmt.Appendf(nil, "parent %s\n", parent.String())...)
+ }
+ buf = append(buf, []byte("\nmsg\n")...)+ return buf
+}
+
+func tagBody(target objectid.ObjectID, targetType objecttype.Type) []byte {+ targetName, ok := objecttype.Name(targetType)
+ if !ok {+ panic("invalid tag target type")+ }
+ return fmt.Appendf(nil, "object %s\ntype %s\ntag t\n\nmsg\n", target.String(), targetName)
+}
+
+func collectSeq(seq func(func(objectid.ObjectID) bool)) []objectid.ObjectID {+ var out []objectid.ObjectID
+ seq(func(id objectid.ObjectID) bool {+ out = append(out, id)
+ return true
+ })
+ return out
+}
+
+func toSet(ids []objectid.ObjectID) map[objectid.ObjectID]struct{} {+ set := make(map[objectid.ObjectID]struct{}, len(ids))+ for _, id := range ids {+ set[id] = struct{}{}+ }
+ return set
+}
+
+func TestWalkDomainCommitsIncludesTagNodes(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ store := newMemStore(algo)
+ blob := store.addObject(objecttype.TypeBlob, []byte("blob\n"))+ tree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{+ Mode: object.FileModeRegular,
+ Name: []byte("f"),+ ID: blob,
+ }}}))
+ commit1 := store.addObject(objecttype.TypeCommit, commitBody(tree))
+ commit2 := store.addObject(objecttype.TypeCommit, commitBody(tree, commit1))
+ tag1 := store.addObject(objecttype.TypeTag, tagBody(commit2, objecttype.TypeCommit))
+ tag2 := store.addObject(objecttype.TypeTag, tagBody(tag1, objecttype.TypeTag))
+
+ r := reachability.New(store)
+ walk := r.Walk(reachability.DomainCommits, nil, map[objectid.ObjectID]struct{}{tag2: {}})+ got := collectSeq(walk.Seq())
+ if err := walk.Err(); err != nil {+ t.Fatalf("walk.Err(): %v", err)+ }
+
+ gotSet := toSet(got)
+ wantSet := map[objectid.ObjectID]struct{}{tag2: {}, tag1: {}, commit2: {}, commit1: {}}+ if !maps.Equal(gotSet, wantSet) {+ t.Fatalf("walk output mismatch: got %v, want %v", slices.Collect(maps.Keys(gotSet)), slices.Collect(maps.Keys(wantSet)))+ }
+ })
+}
+
+func TestWalkExcludesHavesCompletely(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ store := newMemStore(algo)
+ blob := store.addObject(objecttype.TypeBlob, []byte("blob\n"))+ tree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{+ Mode: object.FileModeRegular,
+ Name: []byte("f"),+ ID: blob,
+ }}}))
+ commit := store.addObject(objecttype.TypeCommit, commitBody(tree))
+
+ r := reachability.New(store)
+ walk := r.Walk(reachability.DomainCommits, map[objectid.ObjectID]struct{}{commit: {}}, map[objectid.ObjectID]struct{}{commit: {}})+ got := collectSeq(walk.Seq())
+ if err := walk.Err(); err != nil {+ t.Fatalf("walk.Err(): %v", err)+ }
+ if len(got) != 0 {+ t.Fatalf("expected empty output, got %v", got)+ }
+ })
+}
+
+func TestWalkDomainCommitsRejectsNonCommitRootAfterPeel(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ store := newMemStore(algo)
+ blob := store.addObject(objecttype.TypeBlob, []byte("blob\n"))+ tree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{+ Mode: object.FileModeRegular,
+ Name: []byte("f"),+ ID: blob,
+ }}}))
+ tag := store.addObject(objecttype.TypeTag, tagBody(tree, objecttype.TypeTree))
+
+ r := reachability.New(store)
+ walk := r.Walk(reachability.DomainCommits, nil, map[objectid.ObjectID]struct{}{tag: {}})+ _ = collectSeq(walk.Seq())
+ err := walk.Err()
+ if err == nil {+ t.Fatal("expected error")+ }
+ var typeErr *reachability.ErrObjectType
+ if !errors.As(err, &typeErr) {+ t.Fatalf("expected ErrObjectType, got %T (%v)", err, err)+ }
+ if typeErr.Got != objecttype.TypeTree || typeErr.Want != objecttype.TypeCommit {+ t.Fatalf("unexpected type error: %+v", typeErr)+ }
+ })
+}
+
+func TestWalkDomainCommitsHaveTagStopsTraversal(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ store := newMemStore(algo)
+ blob := store.addObject(objecttype.TypeBlob, []byte("blob\n"))+ tree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{+ Mode: object.FileModeRegular,
+ Name: []byte("f"),+ ID: blob,
+ }}}))
+ commit1 := store.addObject(objecttype.TypeCommit, commitBody(tree))
+ commit2 := store.addObject(objecttype.TypeCommit, commitBody(tree, commit1))
+ tag1 := store.addObject(objecttype.TypeTag, tagBody(commit2, objecttype.TypeCommit))
+ tag2 := store.addObject(objecttype.TypeTag, tagBody(tag1, objecttype.TypeTag))
+
+ r := reachability.New(store)
+ walk := r.Walk(
+ reachability.DomainCommits,
+ map[objectid.ObjectID]struct{}{tag1: {}},+ map[objectid.ObjectID]struct{}{tag2: {}},+ )
+ got := collectSeq(walk.Seq())
+ if err := walk.Err(); err != nil {+ t.Fatalf("walk.Err(): %v", err)+ }
+
+ gotSet := toSet(got)
+ wantSet := map[objectid.ObjectID]struct{}{tag2: {}}+ if !maps.Equal(gotSet, wantSet) {+ t.Fatalf("walk output mismatch: got %v, want %v", slices.Collect(maps.Keys(gotSet)), slices.Collect(maps.Keys(wantSet)))+ }
+ })
+}
+
+func TestWalkDomainObjectsRecursesTreesAndSkipsBlobContentReads(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ store := newMemStore(algo)
+
+ blob1 := store.addObject(objecttype.TypeBlob, []byte("b1\n"))+ blob2 := store.addObject(objecttype.TypeBlob, []byte("b2\n"))+ gitlinkTarget := store.algo.Sum([]byte("external-submodule"))+
+ subtree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{+ Mode: object.FileModeRegular,
+ Name: []byte("nested"),+ ID: blob2,
+ }}}))
+ rootTree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{+ {Mode: object.FileModeRegular, Name: []byte("a"), ID: blob1},+ {Mode: object.FileModeDir, Name: []byte("dir"), ID: subtree},+ {Mode: object.FileModeGitlink, Name: []byte("submodule"), ID: gitlinkTarget},+ }}))
+ commit := store.addObject(objecttype.TypeCommit, commitBody(rootTree))
+
+ r := reachability.New(store)
+ walk := r.Walk(reachability.DomainObjects, nil, map[objectid.ObjectID]struct{}{commit: {}})+ got := collectSeq(walk.Seq())
+ if err := walk.Err(); err != nil {+ t.Fatalf("walk.Err(): %v", err)+ }
+
+ gotSet := toSet(got)
+ wantSet := map[objectid.ObjectID]struct{}{commit: {}, rootTree: {}, subtree: {}, blob1: {}, blob2: {}}+ if !maps.Equal(gotSet, wantSet) {+ t.Fatalf("walk output mismatch: got %v, want %v", slices.Collect(maps.Keys(gotSet)), slices.Collect(maps.Keys(wantSet)))+ }
+ if store.readBytesByObjectID[blob1] != 0 || store.readBytesByObjectID[blob2] != 0 {+ t.Fatalf("blob contents should not be read; counts: blob1=%d blob2=%d", store.readBytesByObjectID[blob1], store.readBytesByObjectID[blob2])+ }
+ })
+}
+
+func TestCheckConnectedReturnsConcreteMissingObject(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ store := newMemStore(algo)
+ blob := store.addObject(objecttype.TypeBlob, []byte("blob\n"))+ tree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{+ Mode: object.FileModeRegular,
+ Name: []byte("f"),+ ID: blob,
+ }}}))
+ missingParent := store.algo.Sum([]byte("missing-parent"))+ commit := store.addObject(objecttype.TypeCommit, commitBody(tree, missingParent))
+
+ r := reachability.New(store)
+ err := r.CheckConnected(reachability.DomainCommits, nil, map[objectid.ObjectID]struct{}{commit: {}})+ if err == nil {+ t.Fatal("expected error")+ }
+ var missing *reachability.ErrObjectMissing
+ if !errors.As(err, &missing) {+ t.Fatalf("expected ErrObjectMissing, got %T (%v)", err, err)+ }
+ if missing.OID != missingParent {+ t.Fatalf("unexpected missing oid: got %s want %s", missing.OID, missingParent)+ }
+ })
+}
+
+func TestWalkInvalidDomainReturnsPlainError(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ r := reachability.New(newMemStore(algo))
+ walk := r.Walk(reachability.Domain(99), nil, nil)
+ _ = collectSeq(walk.Seq())
+ if err := walk.Err(); err == nil {+ t.Fatal("expected error")+ }
+ })
+}
+
+func TestIsAncestor(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ store := newMemStore(algo)
+ blob := store.addObject(objecttype.TypeBlob, []byte("blob\n"))+ tree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{+ Mode: object.FileModeRegular,
+ Name: []byte("f"),+ ID: blob,
+ }}}))
+ c1 := store.addObject(objecttype.TypeCommit, commitBody(tree))
+ c2 := store.addObject(objecttype.TypeCommit, commitBody(tree, c1))
+ otherBlob := store.addObject(objecttype.TypeBlob, []byte("other-blob\n"))+ otherTree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{+ Mode: object.FileModeRegular,
+ Name: []byte("g"),+ ID: otherBlob,
+ }}}))
+ c3 := store.addObject(objecttype.TypeCommit, commitBody(otherTree))
+ tag := store.addObject(objecttype.TypeTag, tagBody(c2, objecttype.TypeCommit))
+
+ r := reachability.New(store)
+ ok, err := r.IsAncestor(c1, tag)
+ if err != nil {+ t.Fatalf("IsAncestor(c1, tag): %v", err)+ }
+ if !ok {+ t.Fatal("expected c1 to be ancestor of tag->c2")+ }
+
+ ok, err = r.IsAncestor(c3, c2)
+ if err != nil {+ t.Fatalf("IsAncestor(c3, c2): %v", err)+ }
+ if ok {+ t.Fatal("did not expect c3 to be ancestor of c2")+ }
+ })
+}
+
+func TestIsAncestorRejectsNonCommitAfterPeel(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ store := newMemStore(algo)
+ blob := store.addObject(objecttype.TypeBlob, []byte("blob\n"))+ tree := store.addObject(objecttype.TypeTree, mustSerializeTree(t, &object.Tree{Entries: []object.TreeEntry{{+ Mode: object.FileModeRegular,
+ Name: []byte("f"),+ ID: blob,
+ }}}))
+ commit := store.addObject(objecttype.TypeCommit, commitBody(tree))
+ tagToTree := store.addObject(objecttype.TypeTag, tagBody(tree, objecttype.TypeTree))
+
+ r := reachability.New(store)
+ _, err := r.IsAncestor(commit, tagToTree)
+ if err == nil {+ t.Fatal("expected error")+ }
+ var typeErr *reachability.ErrObjectType
+ if !errors.As(err, &typeErr) {+ t.Fatalf("expected ErrObjectType, got %T (%v)", err, err)+ }
+ })
+}
+
+func mustSerializeTree(tb testing.TB, tree *object.Tree) []byte {+ tb.Helper()
+ body, err := tree.SerializeWithoutHeader()
+ if err != nil {+ tb.Fatalf("SerializeWithoutHeader: %v", err)+ }
+ return body
+}
+
+func (store *memStore) addObject(ty objecttype.Type, body []byte) objectid.ObjectID {+ header, ok := objectheader.Encode(ty, int64(len(body)))
+ if !ok {+ panic("failed to encode object header")+ }
+ raw := append(append([]byte(nil), header...), body...)
+ id := store.algo.Sum(raw)
+ store.objects[id] = storeObject{ty: ty, content: append([]byte(nil), body...)}+ return id
+}
--- /dev/null
+++ b/reachability/walk.go
@@ -1,0 +1,195 @@
+package reachability
+
+import (
+ "errors"
+ "fmt"
+ "iter"
+
+ "codeberg.org/lindenii/furgit/object"
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/objecttype"
+)
+
+// Walk is one single-use iterator-style traversal.
+type Walk struct {+ reachability *Reachability
+ domain Domain
+ haves map[objectid.ObjectID]struct{}+ wants map[objectid.ObjectID]struct{}+
+ seqUsed bool
+ err error
+}
+
+// Seq returns the traversal sequence. It is single-use.
+func (walk *Walk) Seq() iter.Seq[objectid.ObjectID] {+ if walk.seqUsed {+ return func(yield func(objectid.ObjectID) bool) {+ _ = yield
+ if walk.err == nil {+ walk.err = errors.New("reachability: walk sequence already consumed")+ }
+ }
+ }
+ walk.seqUsed = true
+ return func(yield func(objectid.ObjectID) bool) {+ if walk.err != nil {+ return
+ }
+ stack := walk.initialStack()
+ var err error
+ visited := make(map[objectid.ObjectID]struct{}, len(stack))+ for len(stack) > 0 {+ item := stack[len(stack)-1]
+ stack = stack[:len(stack)-1]
+
+ if containsOID(walk.haves, item.id) {+ continue
+ }
+ if _, ok := visited[item.id]; ok {+ continue
+ }
+ visited[item.id] = struct{}{}+
+ var next []walkItem
+ next, err = walk.expand(item)
+ if err != nil {+ walk.err = err
+ return
+ }
+ if !yield(item.id) {+ return
+ }
+ stack = append(stack, next...)
+ }
+ }
+}
+
+// Err returns the terminal error, if any, once Seq has been consumed.
+func (walk *Walk) Err() error {+ return walk.err
+}
+
+type walkItem struct {+ id objectid.ObjectID
+ want objecttype.Type
+}
+
+func (walk *Walk) initialStack() []walkItem {+ if len(walk.wants) == 0 {+ return nil
+ }
+ stack := make([]walkItem, 0, len(walk.wants))
+ for want := range walk.wants {+ stack = append(stack, walkItem{id: want, want: objecttype.TypeInvalid})+ }
+ return stack
+}
+
+func (walk *Walk) expand(item walkItem) ([]walkItem, error) {+ if walk.domain == DomainCommits {+ return walk.expandCommits(item)
+ }
+ return walk.expandObjects(item)
+}
+
+func (walk *Walk) expandCommits(item walkItem) ([]walkItem, error) {+ ty, err := walk.readHeaderType(item.id)
+ if err != nil {+ return nil, err
+ }
+ switch ty {+ case objecttype.TypeCommit:
+ content, err := walk.readBytesContent(item.id)
+ if err != nil {+ return nil, err
+ }
+ commit, err := object.ParseCommit(content, item.id.Algorithm())
+ if err != nil {+ return nil, err
+ }
+ next := make([]walkItem, 0, len(commit.Parents))
+ for _, parent := range commit.Parents {+ next = append(next, walkItem{id: parent, want: objecttype.TypeInvalid})+ }
+ return next, nil
+ case objecttype.TypeTag:
+ content, err := walk.readBytesContent(item.id)
+ if err != nil {+ return nil, err
+ }
+ tag, err := object.ParseTag(content, item.id.Algorithm())
+ if err != nil {+ return nil, err
+ }
+ return []walkItem{{id: tag.Target, want: objecttype.TypeInvalid}}, nil+ case objecttype.TypeTree, objecttype.TypeBlob, objecttype.TypeInvalid,
+ objecttype.TypeFuture, objecttype.TypeOfsDelta, objecttype.TypeRefDelta:
+ return nil, &ErrObjectType{OID: item.id, Got: ty, Want: objecttype.TypeCommit}+ }
+ return nil, fmt.Errorf("reachability: unreachable object type %d", ty)+}
+
+func (walk *Walk) expandObjects(item walkItem) ([]walkItem, error) {+ ty, err := walk.readHeaderType(item.id)
+ if err != nil {+ return nil, err
+ }
+ if item.want != objecttype.TypeInvalid && ty != item.want {+ return nil, &ErrObjectType{OID: item.id, Got: ty, Want: item.want}+ }
+
+ switch ty {+ case objecttype.TypeBlob:
+ return nil, nil
+ case objecttype.TypeCommit:
+ content, err := walk.readBytesContent(item.id)
+ if err != nil {+ return nil, err
+ }
+ commit, err := object.ParseCommit(content, item.id.Algorithm())
+ if err != nil {+ return nil, err
+ }
+ next := make([]walkItem, 0, len(commit.Parents)+1)
+ next = append(next, walkItem{id: commit.Tree, want: objecttype.TypeTree})+ for _, parent := range commit.Parents {+ next = append(next, walkItem{id: parent, want: objecttype.TypeCommit})+ }
+ return next, nil
+ case objecttype.TypeTree:
+ content, err := walk.readBytesContent(item.id)
+ if err != nil {+ return nil, err
+ }
+ tree, err := object.ParseTree(content, item.id.Algorithm())
+ if err != nil {+ return nil, err
+ }
+ next := make([]walkItem, 0, len(tree.Entries))
+ for _, entry := range tree.Entries {+ switch entry.Mode {+ case object.FileModeGitlink:
+ continue
+ case object.FileModeDir:
+ next = append(next, walkItem{id: entry.ID, want: objecttype.TypeTree})+ case object.FileModeRegular, object.FileModeExecutable, object.FileModeSymlink:
+ next = append(next, walkItem{id: entry.ID, want: objecttype.TypeBlob})+ }
+ }
+ return next, nil
+ case objecttype.TypeTag:
+ content, err := walk.readBytesContent(item.id)
+ if err != nil {+ return nil, err
+ }
+ tag, err := object.ParseTag(content, item.id.Algorithm())
+ if err != nil {+ return nil, err
+ }
+ return []walkItem{{id: tag.Target, want: tag.TargetType}}, nil+ case objecttype.TypeInvalid, objecttype.TypeFuture, objecttype.TypeOfsDelta, objecttype.TypeRefDelta:
+ return nil, &ErrObjectType{OID: item.id, Got: ty, Want: item.want}+ }
+ return nil, fmt.Errorf("reachability: unreachable object type %d", ty)+}
--
⑨