shithub: furgit

Download patch

ref: df73a4c6f1b58075316ba7449fbfb127b9fbb79d
parent: 21e1e1515477f6e34062a57a17862153bfc27685
author: Runxi Yu <runxiyu@umich.edu>
date: Sun Mar 29 10:42:13 EDT 2026

commitquery: Reorganize

--- a/commitquery/ancestor.go
+++ /dev/null
@@ -1,48 +1,0 @@
-package commitquery
-
-import objectid "codeberg.org/lindenii/furgit/object/id"
-
-// IsAncestor reports whether ancestor is reachable from descendant through
-// commit parent edges.
-//
-// Both inputs are peeled through annotated tags before commit traversal.
-func (query *query) IsAncestor(ancestor, descendant objectid.ObjectID) (bool, error) {
-	ancestorIdx, err := query.resolveCommitish(ancestor)
-	if err != nil {
-		return false, err
-	}
-
-	descendantIdx, err := query.resolveCommitish(descendant)
-	if err != nil {
-		return false, err
-	}
-
-	return query.isAncestor(ancestorIdx, descendantIdx)
-}
-
-func (query *query) isAncestor(ancestor, descendant nodeIndex) (bool, error) {
-	if ancestor == descendant {
-		return true, nil
-	}
-
-	ancestorGeneration := query.effectiveGeneration(ancestor)
-	descendantGeneration := query.effectiveGeneration(descendant)
-
-	if ancestorGeneration != generationInfinity &&
-		descendantGeneration != generationInfinity &&
-		ancestorGeneration > descendantGeneration {
-		return false, nil
-	}
-
-	minGeneration := uint64(0)
-	if ancestorGeneration != generationInfinity {
-		minGeneration = ancestorGeneration
-	}
-
-	err := query.paintDownToCommon(ancestor, []nodeIndex{descendant}, minGeneration)
-	if err != nil {
-		return false, err
-	}
-
-	return query.hasAnyMarks(ancestor, markRight), nil
-}
--- a/commitquery/ancestor_integration_test.go
+++ /dev/null
@@ -1,132 +1,0 @@
-package commitquery_test
-
-import (
-	"errors"
-	"testing"
-
-	"codeberg.org/lindenii/furgit/commitquery"
-	giterrors "codeberg.org/lindenii/furgit/errors"
-	"codeberg.org/lindenii/furgit/internal/testgit"
-	objectid "codeberg.org/lindenii/furgit/object/id"
-)
-
-func TestIsMatchesGitMergeBase(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")
-
-		store := testRepo.OpenObjectStore(t)
-
-		got, err := commitquery.New(store, nil).IsAncestor(c1, tag)
-		if err != nil {
-			t.Fatalf("Is(c1, tag): %v", err)
-		}
-
-		want := gitMergeBaseIsAncestor(t, testRepo, c1, c2)
-		if got != want {
-			t.Fatalf("Is(c1, tag)=%v, want %v", got, want)
-		}
-
-		got, err = commitquery.New(store, nil).IsAncestor(c3, c2)
-		if err != nil {
-			t.Fatalf("Is(c3, c2): %v", err)
-		}
-
-		want = gitMergeBaseIsAncestor(t, testRepo, c3, c2)
-		if got != want {
-			t.Fatalf("Is(c3, c2)=%v, want %v", got, want)
-		}
-	})
-}
-
-func TestIsMatchesGitMergeBaseWithCommitGraph(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)
-
-		testRepo.UpdateRef(t, "refs/heads/main", c2)
-		testRepo.SymbolicRef(t, "HEAD", "refs/heads/main")
-		testRepo.CommitGraphWrite(t, "--reachable")
-
-		store := testRepo.OpenObjectStore(t)
-		graph := testRepo.OpenCommitGraph(t)
-
-		got, err := commitquery.New(store, graph).IsAncestor(c1, c2)
-		if err != nil {
-			t.Fatalf("Is(c1, c2): %v", err)
-		}
-
-		want := gitMergeBaseIsAncestor(t, testRepo, c1, c2)
-		if got != want {
-			t.Fatalf("Is(c1, c2)=%v, want %v", got, want)
-		}
-	})
-}
-
-func TestIsMissingObject(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")
-
-		testRepo.RemoveLooseObject(t, treeID)
-
-		store := testRepo.OpenObjectStore(t)
-
-		_, err := commitquery.New(store, nil).IsAncestor(treeID, commitID)
-		if err == nil {
-			t.Fatal("expected error")
-		}
-
-		missing, ok := errors.AsType[*giterrors.ObjectMissingError](err)
-		if !ok {
-			t.Fatalf("expected ObjectMissingError, got %T (%v)", err, err)
-		}
-
-		if missing.OID != treeID {
-			t.Fatalf("missing oid = %s, want %s", missing.OID, treeID)
-		}
-	})
-}
-
-// gitMergeBaseIsAncestor reports Git's merge-base ancestry answer.
-func gitMergeBaseIsAncestor(t *testing.T, testRepo *testgit.TestRepo, left, right objectid.ObjectID) bool {
-	t.Helper()
-
-	out := testRepo.Run(t, "merge-base", left.String(), right.String())
-
-	return out == left.String()
-}
--- a/commitquery/ancestor_unit_test.go
+++ /dev/null
@@ -1,117 +1,0 @@
-package commitquery_test
-
-import (
-	"errors"
-	"fmt"
-	"testing"
-
-	giterrors "codeberg.org/lindenii/furgit/errors"
-	"codeberg.org/lindenii/furgit/internal/testgit"
-	objectid "codeberg.org/lindenii/furgit/object/id"
-	"codeberg.org/lindenii/furgit/object/store/memory"
-	objecttree "codeberg.org/lindenii/furgit/object/tree"
-	objecttype "codeberg.org/lindenii/furgit/object/type"
-
-	"codeberg.org/lindenii/furgit/commitquery"
-)
-
-// ancestorCommitBody serializes one minimal commit body.
-func ancestorCommitBody(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
-}
-
-// ancestorTagBody serializes one minimal annotated tag body.
-func ancestorTagBody(target objectid.ObjectID, targetType objecttype.Type) []byte {
-	targetName, ok := targetType.Name()
-	if !ok {
-		panic("invalid tag target type")
-	}
-
-	return fmt.Appendf(nil, "object %s\ntype %s\ntag t\n\nmsg\n", target.String(), targetName)
-}
-
-// mustSerializeAncestorTree serializes one tree or fails the test.
-func mustSerializeAncestorTree(tb testing.TB, tree *objecttree.Tree) []byte {
-	tb.Helper()
-
-	body, err := tree.SerializeWithoutHeader()
-	if err != nil {
-		tb.Fatalf("SerializeWithoutHeader: %v", err)
-	}
-
-	return body
-}
-
-func TestIs(t *testing.T) {
-	t.Parallel()
-
-	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
-		store := memory.New(algo)
-		blob := store.AddObject(objecttype.TypeBlob, []byte("blob\n"))
-		tree := store.AddObject(objecttype.TypeTree, mustSerializeAncestorTree(t, &objecttree.Tree{Entries: []objecttree.TreeEntry{{
-			Mode: objecttree.FileModeRegular,
-			Name: []byte("f"),
-			ID:   blob,
-		}}}))
-		c1 := store.AddObject(objecttype.TypeCommit, ancestorCommitBody(tree))
-		c2 := store.AddObject(objecttype.TypeCommit, ancestorCommitBody(tree, c1))
-		otherBlob := store.AddObject(objecttype.TypeBlob, []byte("other-blob\n"))
-		otherTree := store.AddObject(objecttype.TypeTree, mustSerializeAncestorTree(t, &objecttree.Tree{Entries: []objecttree.TreeEntry{{
-			Mode: objecttree.FileModeRegular,
-			Name: []byte("g"),
-			ID:   otherBlob,
-		}}}))
-		c3 := store.AddObject(objecttype.TypeCommit, ancestorCommitBody(otherTree))
-		tag := store.AddObject(objecttype.TypeTag, ancestorTagBody(c2, objecttype.TypeCommit))
-
-		ok, err := commitquery.New(store, nil).IsAncestor(c1, tag)
-		if err != nil {
-			t.Fatalf("Is(c1, tag): %v", err)
-		}
-
-		if !ok {
-			t.Fatal("expected c1 to be ancestor of tag->c2")
-		}
-
-		ok, err = commitquery.New(store, nil).IsAncestor(c3, c2)
-		if err != nil {
-			t.Fatalf("Is(c3, c2): %v", err)
-		}
-
-		if ok {
-			t.Fatal("did not expect c3 to be ancestor of c2")
-		}
-	})
-}
-
-func TestIsRejectsNonCommitAfterPeel(t *testing.T) {
-	t.Parallel()
-
-	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
-		store := memory.New(algo)
-		blob := store.AddObject(objecttype.TypeBlob, []byte("blob\n"))
-		tree := store.AddObject(objecttype.TypeTree, mustSerializeAncestorTree(t, &objecttree.Tree{Entries: []objecttree.TreeEntry{{
-			Mode: objecttree.FileModeRegular,
-			Name: []byte("f"),
-			ID:   blob,
-		}}}))
-		commit := store.AddObject(objecttype.TypeCommit, ancestorCommitBody(tree))
-		tagToTree := store.AddObject(objecttype.TypeTag, ancestorTagBody(tree, objecttype.TypeTree))
-
-		_, err := commitquery.New(store, nil).IsAncestor(commit, tagToTree)
-		if err == nil {
-			t.Fatal("expected error")
-		}
-
-		if _, ok := errors.AsType[*giterrors.ObjectTypeError](err); !ok {
-			t.Fatalf("expected ObjectTypeError, got %T (%v)", err, err)
-		}
-	})
-}
--- a/commitquery/commit.go
+++ /dev/null
@@ -1,17 +1,0 @@
-package commitquery
-
-import (
-	commitgraphread "codeberg.org/lindenii/furgit/format/commitgraph/read"
-	objectid "codeberg.org/lindenii/furgit/object/id"
-)
-
-// commitData stores the metadata needed by commit-domain queries.
-type commitData struct {
-	ID            objectid.ObjectID
-	Parents       []parentRef
-	CommitTime    int64
-	Generation    uint64
-	HasGeneration bool
-	GraphPos      commitgraphread.Position
-	HasGraphPos   bool
-}
--- /dev/null
+++ b/commitquery/commit_data.go
@@ -1,0 +1,17 @@
+package commitquery
+
+import (
+	commitgraphread "codeberg.org/lindenii/furgit/format/commitgraph/read"
+	objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+// commitData stores the metadata needed by commit-domain queries.
+type commitData struct {
+	ID            objectid.ObjectID
+	Parents       []parentRef
+	CommitTime    int64
+	Generation    uint64
+	HasGeneration bool
+	GraphPos      commitgraphread.Position
+	HasGraphPos   bool
+}
--- a/commitquery/errors.go
+++ b/commitquery/errors.go
@@ -2,4 +2,5 @@
 
 import "errors"
 
+// errBadGenerationOrder reports an invalid priority-queue ordering.
 var errBadGenerationOrder = errors.New("commitquery: priority queue violated generation ordering")
--- a/commitquery/graph_pos.go
+++ /dev/null
@@ -1,107 +1,0 @@
-package commitquery
-
-import commitgraphread "codeberg.org/lindenii/furgit/format/commitgraph/read"
-
-// resolveGraphPos resolves one commit-graph position to one internal query node.
-func (query *query) resolveGraphPos(pos commitgraphread.Position) (nodeIndex, error) {
-	idx, ok := query.byGraphPos[pos]
-	if ok {
-		err := query.ensureLoaded(idx)
-		if err != nil {
-			return 0, err
-		}
-
-		return idx, nil
-	}
-
-	commit, err := query.graph.CommitAt(pos)
-	if err != nil {
-		return 0, err
-	}
-
-	idx, ok = query.byOID[commit.OID]
-	if !ok {
-		idx = query.newNode(commit.OID)
-		query.byOID[commit.OID] = idx
-	}
-
-	query.byGraphPos[pos] = idx
-	query.nodes[idx].graphPos = pos
-	query.nodes[idx].hasGraphPos = true
-
-	err = query.loadCommitAtGraphPos(idx, pos)
-	if err != nil {
-		delete(query.byGraphPos, pos)
-
-		return 0, err
-	}
-
-	return idx, nil
-}
-
-// loadByGraphPos populates one node from a commit-graph position.
-func (query *query) loadByGraphPos(idx nodeIndex) error {
-	pos := query.nodes[idx].graphPos
-
-	return query.loadCommitAtGraphPos(idx, pos)
-}
-
-func (query *query) loadCommitAtGraphPos(idx nodeIndex, pos commitgraphread.Position) error {
-	commit, err := query.graph.CommitAt(pos)
-	if err != nil {
-		return err
-	}
-
-	parents := make([]parentRef, 0, 2+len(commit.ExtraParents))
-
-	if commit.Parent1.Valid {
-		parentOID, err := query.graph.OIDAt(commit.Parent1.Pos)
-		if err != nil {
-			return err
-		}
-
-		parents = append(parents, parentRef{
-			ID:          parentOID,
-			GraphPos:    commit.Parent1.Pos,
-			HasGraphPos: true,
-		})
-	}
-
-	if commit.Parent2.Valid {
-		parentOID, err := query.graph.OIDAt(commit.Parent2.Pos)
-		if err != nil {
-			return err
-		}
-
-		parents = append(parents, parentRef{
-			ID:          parentOID,
-			GraphPos:    commit.Parent2.Pos,
-			HasGraphPos: true,
-		})
-	}
-
-	for _, parentPos := range commit.ExtraParents {
-		parentOID, err := query.graph.OIDAt(parentPos)
-		if err != nil {
-			return err
-		}
-
-		parents = append(parents, parentRef{
-			ID:          parentOID,
-			GraphPos:    parentPos,
-			HasGraphPos: true,
-		})
-	}
-
-	data := commitData{
-		ID:            commit.OID,
-		Parents:       parents,
-		CommitTime:    commit.CommitTimeUnix,
-		Generation:    commit.GenerationV2,
-		HasGeneration: commit.GenerationV2 != 0,
-		GraphPos:      pos,
-		HasGraphPos:   true,
-	}
-
-	return query.populateNode(idx, data)
-}
--- a/commitquery/load.go
+++ /dev/null
@@ -1,71 +1,0 @@
-package commitquery
-
-import (
-	stderrors "errors"
-
-	giterrors "codeberg.org/lindenii/furgit/errors"
-	commitgraphread "codeberg.org/lindenii/furgit/format/commitgraph/read"
-	objectcommit "codeberg.org/lindenii/furgit/object/commit"
-	objectstore "codeberg.org/lindenii/furgit/object/store"
-	objecttype "codeberg.org/lindenii/furgit/object/type"
-)
-
-// ensureLoaded completes one node's metadata load if it has not been loaded yet.
-func (query *query) ensureLoaded(idx nodeIndex) error {
-	if query.nodes[idx].loaded {
-		return nil
-	}
-
-	if query.nodes[idx].hasGraphPos {
-		return query.loadByGraphPos(idx)
-	}
-
-	return query.loadByOID(idx)
-}
-
-// loadByOID populates one node from an object ID.
-func (query *query) loadByOID(idx nodeIndex) error {
-	id := query.nodes[idx].id
-
-	if query.graph != nil {
-		pos, err := query.graph.Lookup(id)
-		if err != nil {
-			if _, ok := stderrors.AsType[*commitgraphread.NotFoundError](err); !ok {
-				return err
-			}
-		} else {
-			return query.loadCommitAtGraphPos(idx, pos)
-		}
-	}
-
-	ty, content, err := query.store.ReadBytesContent(id)
-	if err != nil {
-		if stderrors.Is(err, objectstore.ErrObjectNotFound) {
-			return &giterrors.ObjectMissingError{OID: id}
-		}
-
-		return err
-	}
-
-	if ty != objecttype.TypeCommit {
-		return &giterrors.ObjectTypeError{OID: id, Got: ty, Want: objecttype.TypeCommit}
-	}
-
-	commitObj, err := objectcommit.Parse(content, id.Algorithm())
-	if err != nil {
-		return err
-	}
-
-	parents := make([]parentRef, 0, len(commitObj.Parents))
-	for _, parentID := range commitObj.Parents {
-		parents = append(parents, parentRef{ID: parentID})
-	}
-
-	commit := commitData{
-		ID:         id,
-		Parents:    parents,
-		CommitTime: commitObj.Committer.WhenUnix,
-	}
-
-	return query.populateNode(idx, commit)
-}
--- /dev/null
+++ b/commitquery/mark_bits.go
@@ -1,0 +1,17 @@
+package commitquery
+
+// markBits stores one set of traversal marks on one node.
+type markBits uint8
+
+// markLeft, markRight, markStale, and markResult track traversal state.
+const (
+	markLeft markBits = 1 << iota
+	markRight
+	markStale
+	markResult
+)
+
+// allMarks is the union of all defined mark bits.
+const (
+	allMarks = markLeft | markRight | markStale | markResult
+)
--- a/commitquery/marks.go
+++ /dev/null
@@ -1,102 +1,0 @@
-package commitquery
-
-// Marks returns the mark bits of one internal node.
-func (query *query) marks(idx nodeIndex) markBits {
-	return query.nodes[idx].marks
-}
-
-// HasAnyMarks reports whether one internal node has any requested bit.
-func (query *query) hasAnyMarks(idx nodeIndex, bits markBits) bool {
-	return query.nodes[idx].marks&bits != 0
-}
-
-// HasAllMarks reports whether one internal node already has all requested bits.
-func (query *query) hasAllMarks(idx nodeIndex, bits markBits) bool {
-	return query.nodes[idx].marks&bits == bits
-}
-
-// SetMarks ORs one set of mark bits into one internal node.
-func (query *query) setMarks(idx nodeIndex, bits markBits) {
-	newBits := bits &^ query.nodes[idx].marks
-	if newBits == 0 {
-		return
-	}
-
-	query.trackTouched(idx)
-	query.nodes[idx].marks |= bits
-}
-
-// ClearMarks removes one set of mark bits from one internal node.
-func (query *query) clearMarks(idx nodeIndex, bits markBits) {
-	if query.nodes[idx].marks&bits == 0 {
-		return
-	}
-
-	query.trackTouched(idx)
-	query.nodes[idx].marks &^= bits
-}
-
-// BeginMarkPhase starts one tracked mark-mutation phase.
-func (query *query) beginMarkPhase() {
-	for _, idx := range query.touched {
-		query.nodes[idx].marks = 0
-	}
-
-	query.markPhase++
-	if query.markPhase == 0 {
-		query.markPhase++
-		for i := range query.nodes {
-			query.nodes[i].touchedPhase = 0
-		}
-	}
-
-	query.touched = query.touched[:0]
-}
-
-// ClearTouchedMarks clears the provided bits from all nodes touched in the
-// current mark phase.
-func (query *query) clearTouchedMarks(bits markBits) {
-	for _, idx := range query.touched {
-		query.nodes[idx].marks &^= bits
-	}
-}
-
-func (query *query) trackTouched(idx nodeIndex) {
-	if query.nodes[idx].touchedPhase == query.markPhase {
-		return
-	}
-
-	query.nodes[idx].touchedPhase = query.markPhase
-	query.touched = append(query.touched, idx)
-}
-
-func (query *query) collectMarkedResults() []nodeIndex {
-	out := make([]nodeIndex, 0, 4)
-
-	for _, idx := range query.touched {
-		if !query.hasAnyMarks(idx, markResult) {
-			continue
-		}
-
-		if query.hasAnyMarks(idx, markStale) {
-			continue
-		}
-
-		out = append(out, idx)
-	}
-
-	return out
-}
-
-type markBits uint8
-
-const (
-	markLeft markBits = 1 << iota
-	markRight
-	markStale
-	markResult
-)
-
-const (
-	allMarks = markLeft | markRight | markStale | markResult
-)
--- a/commitquery/merge_bases.go
+++ /dev/null
@@ -1,89 +1,0 @@
-package commitquery
-
-import (
-	"slices"
-
-	objectid "codeberg.org/lindenii/furgit/object/id"
-)
-
-// MergeBases reports all merge bases in Git's merge-base --all order.
-//
-// Both inputs are peeled through annotated tags before commit traversal.
-func (query *query) MergeBases(left, right objectid.ObjectID) ([]objectid.ObjectID, error) {
-	leftIdx, err := query.resolveCommitish(left)
-	if err != nil {
-		return nil, err
-	}
-
-	rightIdx, err := query.resolveCommitish(right)
-	if err != nil {
-		return nil, err
-	}
-
-	candidates, err := query.mergeBases(leftIdx, rightIdx)
-	if err != nil {
-		return nil, err
-	}
-
-	slices.SortFunc(candidates, func(left, right nodeIndex) int {
-		switch {
-		case query.commitTime(left) > query.commitTime(right):
-			return -1
-		case query.commitTime(left) < query.commitTime(right):
-			return 1
-		default:
-			return objectid.Compare(query.id(left), query.id(right))
-		}
-	})
-
-	out := make([]objectid.ObjectID, 0, len(candidates))
-	for _, idx := range candidates {
-		out = append(out, query.id(idx))
-	}
-
-	return out, nil
-}
-
-// MergeBase reports one merge base between left and right, if any.
-func (query *query) MergeBase(left, right objectid.ObjectID) (objectid.ObjectID, bool, error) {
-	bases, err := query.MergeBases(left, right)
-	if err != nil {
-		return objectid.ObjectID{}, false, err
-	}
-
-	if len(bases) == 0 {
-		return objectid.ObjectID{}, false, nil
-	}
-
-	return bases[0], true, nil
-}
-
-func (query *query) mergeBases(left, right nodeIndex) ([]nodeIndex, error) {
-	if left == right {
-		return []nodeIndex{left}, nil
-	}
-
-	err := query.paintDownToCommon(left, []nodeIndex{right}, 0)
-	if err != nil {
-		return nil, err
-	}
-
-	candidates := query.collectMarkedResults()
-
-	if len(candidates) <= 1 {
-		slices.SortFunc(candidates, query.compare)
-
-		return candidates, nil
-	}
-
-	query.clearTouchedMarks(allMarks)
-
-	reduced, err := removeRedundant(query, candidates)
-	if err != nil {
-		return nil, err
-	}
-
-	slices.SortFunc(reduced, query.compare)
-
-	return reduced, nil
-}
--- a/commitquery/mergebase_integration_test.go
+++ /dev/null
@@ -1,311 +1,0 @@
-package commitquery_test
-
-import (
-	"maps"
-	"slices"
-	"strings"
-	"testing"
-
-	"codeberg.org/lindenii/furgit/commitquery"
-	"codeberg.org/lindenii/furgit/internal/testgit"
-	objectid "codeberg.org/lindenii/furgit/object/id"
-)
-
-func TestQueryMatchesGitMergeBaseAll(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)
-
-		tag := testRepo.TagAnnotated(t, "right-tag", right, "right-tag")
-
-		store := testRepo.OpenObjectStore(t)
-
-		query := commitquery.New(store, nil)
-
-		all, err := query.MergeBases(left, tag)
-		if err != nil {
-			t.Fatalf("query.All(): %v", err)
-		}
-
-		got := oidSetFromSlice(all)
-
-		want := gitMergeBaseAllSet(t, testRepo, left, tag)
-		if !maps.Equal(got, want) {
-			t.Fatalf("Query(left, tag) mismatch:\n got=%v\nwant=%v", sortedOIDStrings(got), sortedOIDStrings(want))
-		}
-	})
-}
-
-func TestQueryCrissCrossMatchesGitMergeBaseAll(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, "root.txt", []byte("root\n"))
-		root := testRepo.CommitTree(t, tree1, "root")
-
-		_, tree2 := testRepo.MakeSingleFileTree(t, "base1.txt", []byte("base1\n"))
-		base1 := testRepo.CommitTree(t, tree2, "base1", root)
-
-		_, tree3 := testRepo.MakeSingleFileTree(t, "base2.txt", []byte("base2\n"))
-		base2 := testRepo.CommitTree(t, tree3, "base2", root)
-
-		_, tree4 := testRepo.MakeSingleFileTree(t, "left.txt", []byte("left\n"))
-		left := testRepo.CommitTree(t, tree4, "left", base1, base2)
-
-		_, tree5 := testRepo.MakeSingleFileTree(t, "right.txt", []byte("right\n"))
-		right := testRepo.CommitTree(t, tree5, "right", base2, base1)
-
-		store := testRepo.OpenObjectStore(t)
-
-		query := commitquery.New(store, nil)
-
-		all, err := query.MergeBases(left, right)
-		if err != nil {
-			t.Fatalf("query.All(): %v", err)
-		}
-
-		got := oidSetFromSlice(all)
-
-		want := gitMergeBaseAllSet(t, testRepo, left, right)
-		if !maps.Equal(got, want) {
-			t.Fatalf("Query(left, right) mismatch:\n got=%v\nwant=%v", sortedOIDStrings(got), sortedOIDStrings(want))
-		}
-
-		first, ok, err := query.MergeBase(left, right)
-		if err != nil {
-			t.Fatalf("Base(left, right): %v", err)
-		}
-
-		if !ok {
-			t.Fatal("Base(left, right) unexpectedly reported no base")
-		}
-
-		if !containsID(want, first) {
-			t.Fatalf("Base(left, right)=%s, want one of %v", first, slices.Collect(maps.Keys(want)))
-		}
-	})
-}
-
-func TestQueryMatchesGitMergeBaseAllWithCommitGraph(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, "root.txt", []byte("root\n"))
-		root := testRepo.CommitTree(t, tree1, "root")
-
-		_, tree2 := testRepo.MakeSingleFileTree(t, "base1.txt", []byte("base1\n"))
-		base1 := testRepo.CommitTree(t, tree2, "base1", root)
-
-		_, tree3 := testRepo.MakeSingleFileTree(t, "base2.txt", []byte("base2\n"))
-		base2 := testRepo.CommitTree(t, tree3, "base2", root)
-
-		_, tree4 := testRepo.MakeSingleFileTree(t, "left.txt", []byte("left\n"))
-		left := testRepo.CommitTree(t, tree4, "left", base1, base2)
-
-		_, tree5 := testRepo.MakeSingleFileTree(t, "right.txt", []byte("right\n"))
-		right := testRepo.CommitTree(t, tree5, "right", base2, base1)
-
-		testRepo.UpdateRef(t, "refs/heads/main", right)
-		testRepo.SymbolicRef(t, "HEAD", "refs/heads/main")
-		testRepo.CommitGraphWrite(t, "--reachable")
-
-		store := testRepo.OpenObjectStore(t)
-		graph := testRepo.OpenCommitGraph(t)
-
-		query := commitquery.New(store, graph)
-
-		all, err := query.MergeBases(left, right)
-		if err != nil {
-			t.Fatalf("query.All(): %v", err)
-		}
-
-		got := oidSetFromSlice(all)
-
-		want := gitMergeBaseAllSet(t, testRepo, left, right)
-		if !maps.Equal(got, want) {
-			t.Fatalf("Query(left, right) with commit-graph mismatch:\n got=%v\nwant=%v", sortedOIDStrings(got), sortedOIDStrings(want))
-		}
-
-		first, ok, err := query.MergeBase(left, right)
-		if err != nil {
-			t.Fatalf("Base(left, right): %v", err)
-		}
-
-		if !ok {
-			t.Fatal("Base(left, right) unexpectedly reported no base")
-		}
-
-		if !containsID(want, first) {
-			t.Fatalf("Base(left, right)=%s, want one of %v", first, slices.Collect(maps.Keys(want)))
-		}
-	})
-}
-
-func TestBaseMatchesGitMergeBaseWithoutAll(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, "root.txt", []byte("root\n"))
-		root := testRepo.CommitTree(t, tree1, "root")
-
-		_, tree2 := testRepo.MakeSingleFileTree(t, "base1.txt", []byte("base1\n"))
-		base1 := testRepo.CommitTreeWithEnv(t, []string{
-			"GIT_AUTHOR_DATE=1234567890 +0000",
-			"GIT_COMMITTER_DATE=1234567890 +0000",
-		}, tree2, "base1", root)
-
-		_, tree3 := testRepo.MakeSingleFileTree(t, "base2.txt", []byte("base2\n"))
-		base2 := testRepo.CommitTreeWithEnv(t, []string{
-			"GIT_AUTHOR_DATE=1234567990 +0000",
-			"GIT_COMMITTER_DATE=1234567990 +0000",
-		}, tree3, "base2", root)
-
-		_, tree4 := testRepo.MakeSingleFileTree(t, "left.txt", []byte("left\n"))
-		left := testRepo.CommitTree(t, tree4, "left", base1, base2)
-
-		_, tree5 := testRepo.MakeSingleFileTree(t, "right.txt", []byte("right\n"))
-		right := testRepo.CommitTree(t, tree5, "right", base2, base1)
-
-		store := testRepo.OpenObjectStore(t)
-
-		query := commitquery.New(store, nil)
-
-		got, ok, err := query.MergeBase(left, right)
-		if err != nil {
-			t.Fatalf("Base(left, right): %v", err)
-		}
-
-		if !ok {
-			t.Fatal("Base(left, right) unexpectedly reported no base")
-		}
-
-		want := gitMergeBaseOne(t, testRepo, left, right)
-		if got != want {
-			t.Fatalf("Base(left, right)=%s, want %s", got, want)
-		}
-
-		testRepo.UpdateRef(t, "refs/heads/main", right)
-		testRepo.SymbolicRef(t, "HEAD", "refs/heads/main")
-		testRepo.CommitGraphWrite(t, "--reachable")
-
-		graph := testRepo.OpenCommitGraph(t)
-
-		got, ok, err = commitquery.New(store, graph).MergeBase(left, right)
-		if err != nil {
-			t.Fatalf("Base(left, right) with commit-graph: %v", err)
-		}
-
-		if !ok {
-			t.Fatal("Base(left, right) with commit-graph unexpectedly reported no base")
-		}
-
-		if got != want {
-			t.Fatalf("Base(left, right) with commit-graph=%s, want %s", got, want)
-		}
-	})
-}
-
-// oidSetFromSlice collects one object ID slice into a set.
-func oidSetFromSlice(ids []objectid.ObjectID) map[objectid.ObjectID]struct{} {
-	out := make(map[objectid.ObjectID]struct{})
-
-	for _, id := range ids {
-		out[id] = struct{}{}
-	}
-
-	return out
-}
-
-// gitMergeBaseAllSet returns Git's merge-base --all output as a set.
-func gitMergeBaseAllSet(
-	t *testing.T,
-	testRepo *testgit.TestRepo,
-	left objectid.ObjectID,
-	right objectid.ObjectID,
-) map[objectid.ObjectID]struct{} {
-	t.Helper()
-
-	out := testRepo.Run(t, "merge-base", "--all", left.String(), right.String())
-	set := make(map[objectid.ObjectID]struct{})
-
-	for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") {
-		line = strings.TrimSpace(line)
-		if line == "" {
-			continue
-		}
-
-		id, err := objectid.ParseHex(testRepo.Algorithm(), line)
-		if err != nil {
-			t.Fatalf("parse merge-base oid %q: %v", line, err)
-		}
-
-		set[id] = struct{}{}
-	}
-
-	return set
-}
-
-// gitMergeBaseOne returns Git's merge-base output without --all.
-func gitMergeBaseOne(
-	t *testing.T,
-	testRepo *testgit.TestRepo,
-	left objectid.ObjectID,
-	right objectid.ObjectID,
-) objectid.ObjectID {
-	t.Helper()
-
-	out := strings.TrimSpace(testRepo.Run(t, "merge-base", left.String(), right.String()))
-	if out == "" {
-		t.Fatal("git merge-base returned no output")
-	}
-
-	id, err := objectid.ParseHex(testRepo.Algorithm(), out)
-	if err != nil {
-		t.Fatalf("parse merge-base oid %q: %v", out, err)
-	}
-
-	return id
-}
-
-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
-}
--- a/commitquery/mergebase_unit_test.go
+++ /dev/null
@@ -1,332 +1,0 @@
-package commitquery_test
-
-import (
-	"errors"
-	"fmt"
-	"maps"
-	"slices"
-	"testing"
-
-	"codeberg.org/lindenii/furgit/commitquery"
-	giterrors "codeberg.org/lindenii/furgit/errors"
-	"codeberg.org/lindenii/furgit/internal/testgit"
-	objectid "codeberg.org/lindenii/furgit/object/id"
-	"codeberg.org/lindenii/furgit/object/store/memory"
-	"codeberg.org/lindenii/furgit/object/tree"
-	objecttype "codeberg.org/lindenii/furgit/object/type"
-)
-
-// commitBody serializes one minimal commit body.
-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
-}
-
-// tagBody serializes one minimal annotated tag body.
-func tagBody(target objectid.ObjectID, targetType objecttype.Type) []byte {
-	targetName, ok := targetType.Name()
-	if !ok {
-		panic("invalid tag target type")
-	}
-
-	return fmt.Appendf(nil, "object %s\ntype %s\ntag t\n\nmsg\n", target.String(), targetName)
-}
-
-// toSet converts one slice of object IDs into a set.
-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
-}
-
-// containsID reports whether one set contains one object ID.
-func containsID(set map[objectid.ObjectID]struct{}, id objectid.ObjectID) bool {
-	_, ok := set[id]
-
-	return ok
-}
-
-// mustSerializeTree serializes one tree or fails the test.
-func mustSerializeTree(tb testing.TB, tree *tree.Tree) []byte {
-	tb.Helper()
-
-	body, err := tree.SerializeWithoutHeader()
-	if err != nil {
-		tb.Fatalf("SerializeWithoutHeader: %v", err)
-	}
-
-	return body
-}
-
-// TestQueryLinearHistory reports one linear-history merge base.
-func TestQueryLinearHistory(t *testing.T) {
-	t.Parallel()
-
-	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
-		store := memory.New(algo)
-		blob := store.AddObject(objecttype.TypeBlob, []byte("blob\n"))
-		tree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
-			Mode: tree.FileModeRegular,
-			Name: []byte("f"),
-			ID:   blob,
-		}}}))
-		base := store.AddObject(objecttype.TypeCommit, commitBody(tree))
-		left := store.AddObject(objecttype.TypeCommit, commitBody(tree, base))
-		right := store.AddObject(objecttype.TypeCommit, commitBody(tree, left))
-
-		query := commitquery.New(store, nil)
-
-		got, err := query.MergeBases(left, right)
-		if err != nil {
-			t.Fatalf("query.All(): %v", err)
-		}
-
-		if !slices.Equal(got, []objectid.ObjectID{left}) {
-			t.Fatalf("Query(left, right)=%v, want [%s]", got, left)
-		}
-
-		first, ok, err := query.MergeBase(left, right)
-		if err != nil {
-			t.Fatalf("Base(left, right): %v", err)
-		}
-
-		if !ok {
-			t.Fatal("Base(left, right) unexpectedly reported no base")
-		}
-
-		if first != left {
-			t.Fatalf("Base(left, right)=%s, want %s", first, left)
-		}
-	})
-}
-
-func TestQueryPeelsAnnotatedTags(t *testing.T) {
-	t.Parallel()
-
-	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
-		store := memory.New(algo)
-		blob := store.AddObject(objecttype.TypeBlob, []byte("blob\n"))
-		leftTree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
-			Mode: tree.FileModeRegular,
-			Name: []byte("left"),
-			ID:   blob,
-		}}}))
-		rightTree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
-			Mode: tree.FileModeRegular,
-			Name: []byte("right"),
-			ID:   blob,
-		}}}))
-		base := store.AddObject(objecttype.TypeCommit, commitBody(leftTree))
-		left := store.AddObject(objecttype.TypeCommit, commitBody(leftTree, base))
-		right := store.AddObject(objecttype.TypeCommit, commitBody(rightTree, base))
-		tag := store.AddObject(objecttype.TypeTag, tagBody(right, objecttype.TypeCommit))
-
-		query := commitquery.New(store, nil)
-
-		got, err := query.MergeBases(left, tag)
-		if err != nil {
-			t.Fatalf("query.All(): %v", err)
-		}
-
-		if !slices.Equal(got, []objectid.ObjectID{base}) {
-			t.Fatalf("Query(left, tag)=%v, want [%s]", got, base)
-		}
-	})
-}
-
-func TestQueryCrissCrossReturnsAllBestCommonAncestors(t *testing.T) {
-	t.Parallel()
-
-	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
-		store := memory.New(algo)
-		blob := store.AddObject(objecttype.TypeBlob, []byte("blob\n"))
-		rootTree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
-			Mode: tree.FileModeRegular,
-			Name: []byte("root"),
-			ID:   blob,
-		}}}))
-		base1Tree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
-			Mode: tree.FileModeRegular,
-			Name: []byte("base1"),
-			ID:   blob,
-		}}}))
-		base2Tree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
-			Mode: tree.FileModeRegular,
-			Name: []byte("base2"),
-			ID:   blob,
-		}}}))
-		leftTree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
-			Mode: tree.FileModeRegular,
-			Name: []byte("left"),
-			ID:   blob,
-		}}}))
-		rightTree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
-			Mode: tree.FileModeRegular,
-			Name: []byte("right"),
-			ID:   blob,
-		}}}))
-		root := store.AddObject(objecttype.TypeCommit, commitBody(rootTree))
-		base1 := store.AddObject(objecttype.TypeCommit, commitBody(base1Tree, root))
-		base2 := store.AddObject(objecttype.TypeCommit, commitBody(base2Tree, root))
-		left := store.AddObject(objecttype.TypeCommit, commitBody(leftTree, base1, base2))
-		right := store.AddObject(objecttype.TypeCommit, commitBody(rightTree, base2, base1))
-
-		query := commitquery.New(store, nil)
-
-		all, err := query.MergeBases(left, right)
-		if err != nil {
-			t.Fatalf("query.All(): %v", err)
-		}
-
-		got := toSet(all)
-
-		want := map[objectid.ObjectID]struct{}{base1: {}, base2: {}}
-		if !maps.Equal(got, want) {
-			t.Fatalf("Query(left, right)=%v, want %v", slices.Collect(maps.Keys(got)), slices.Collect(maps.Keys(want)))
-		}
-
-		first, ok, err := query.MergeBase(left, right)
-		if err != nil {
-			t.Fatalf("Base(left, right): %v", err)
-		}
-
-		if !ok {
-			t.Fatal("Base(left, right) unexpectedly reported no base")
-		}
-
-		if !containsID(want, first) {
-			t.Fatalf("Base(left, right)=%s, want one of %v", first, slices.Collect(maps.Keys(want)))
-		}
-	})
-}
-
-func TestQueryReturnsNoResultWhenNoCommonAncestorExists(t *testing.T) {
-	t.Parallel()
-
-	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
-		store := memory.New(algo)
-		leftBlob := store.AddObject(objecttype.TypeBlob, []byte("left\n"))
-		leftTree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
-			Mode: tree.FileModeRegular,
-			Name: []byte("left"),
-			ID:   leftBlob,
-		}}}))
-		rightBlob := store.AddObject(objecttype.TypeBlob, []byte("right\n"))
-		rightTree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
-			Mode: tree.FileModeRegular,
-			Name: []byte("right"),
-			ID:   rightBlob,
-		}}}))
-		left := store.AddObject(objecttype.TypeCommit, commitBody(leftTree))
-		right := store.AddObject(objecttype.TypeCommit, commitBody(rightTree))
-
-		query := commitquery.New(store, nil)
-
-		got, err := query.MergeBases(left, right)
-		if err != nil {
-			t.Fatalf("query.All(): %v", err)
-		}
-
-		if len(got) != 0 {
-			t.Fatalf("Query(left, right)=%v, want no results", got)
-		}
-
-		_, ok, err := query.MergeBase(left, right)
-		if err != nil {
-			t.Fatalf("Base(left, right): %v", err)
-		}
-
-		if ok {
-			t.Fatal("Base(left, right) unexpectedly reported a base")
-		}
-	})
-}
-
-func TestQueryRejectsNonCommitAfterPeel(t *testing.T) {
-	t.Parallel()
-
-	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
-		store := memory.New(algo)
-		blob := store.AddObject(objecttype.TypeBlob, []byte("blob\n"))
-		tree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
-			Mode: tree.FileModeRegular,
-			Name: []byte("f"),
-			ID:   blob,
-		}}}))
-		commit := store.AddObject(objecttype.TypeCommit, commitBody(tree))
-		tagToTree := store.AddObject(objecttype.TypeTag, tagBody(tree, objecttype.TypeTree))
-
-		query := commitquery.New(store, nil)
-
-		_, err := query.MergeBases(commit, tagToTree)
-		if err == nil {
-			t.Fatal("expected error")
-		}
-
-		typeErr, ok := errors.AsType[*giterrors.ObjectTypeError](err)
-		if !ok {
-			t.Fatalf("expected ObjectTypeError, got %T (%v)", err, err)
-		}
-
-		if typeErr.Got != objecttype.TypeTree || typeErr.Want != objecttype.TypeCommit {
-			t.Fatalf("unexpected type error: %+v", typeErr)
-		}
-	})
-}
-
-func TestQueryAllIsRepeatable(t *testing.T) {
-	t.Parallel()
-
-	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
-		store := memory.New(algo)
-		blob := store.AddObject(objecttype.TypeBlob, []byte("blob\n"))
-		tree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
-			Mode: tree.FileModeRegular,
-			Name: []byte("f"),
-			ID:   blob,
-		}}}))
-		base := store.AddObject(objecttype.TypeCommit, commitBody(tree))
-		left := store.AddObject(objecttype.TypeCommit, commitBody(tree, base))
-		right := store.AddObject(objecttype.TypeCommit, commitBody(tree, left))
-
-		query := commitquery.New(store, nil)
-
-		first, err := query.MergeBases(left, right)
-		if err != nil {
-			t.Fatalf("query.MergeBases() first call: %v", err)
-		}
-
-		again, err := query.MergeBases(left, right)
-		if err != nil {
-			t.Fatalf("query.MergeBases() second call: %v", err)
-		}
-
-		if !slices.Equal(again, first) {
-			t.Fatalf("second All()=%v, want %v", again, first)
-		}
-
-		if len(first) == 0 {
-			t.Fatal("first MergeBases() unexpectedly returned no results")
-		}
-
-		first[0] = objectid.ObjectID{}
-
-		third, err := query.MergeBases(left, right)
-		if err != nil {
-			t.Fatalf("query.MergeBases() third call: %v", err)
-		}
-
-		if third[0] == (objectid.ObjectID{}) {
-			t.Fatal("query.MergeBases() exposed internal slice state")
-		}
-	})
-}
--- a/commitquery/node_commit_time.go
+++ b/commitquery/node_commit_time.go
@@ -1,5 +1,6 @@
 package commitquery
 
+// commitTime returns one node's commit time.
 func (query *query) commitTime(idx nodeIndex) int64 {
 	return query.nodes[idx].commitTime
 }
--- a/commitquery/node_compare.go
+++ b/commitquery/node_compare.go
@@ -2,7 +2,7 @@
 
 import objectid "codeberg.org/lindenii/furgit/object/id"
 
-// Compare compares two internal nodes using merge-base queue ordering.
+// compare orders two internal nodes using merge-base queue ordering.
 func (query *query) compare(left, right nodeIndex) int {
 	leftGeneration := query.effectiveGeneration(left)
 	rightGeneration := query.effectiveGeneration(right)
--- a/commitquery/node_generation.go
+++ b/commitquery/node_generation.go
@@ -6,7 +6,7 @@
 	objectid "codeberg.org/lindenii/furgit/object/id"
 )
 
-// EffectiveGeneration returns one node's generation value.
+// effectiveGeneration returns one node's generation value.
 func (query *query) effectiveGeneration(idx nodeIndex) uint64 {
 	if !query.nodes[idx].hasGeneration {
 		return generationInfinity
@@ -15,10 +15,12 @@
 	return query.nodes[idx].generation
 }
 
+// generationInfinity sorts nodes without a known generation last.
 const (
 	generationInfinity = uint64(math.MaxUint64)
 )
 
+// compareByGeneration builds one comparator ordered by generation first.
 func (query *query) compareByGeneration() func(nodeIndex, nodeIndex) int {
 	return func(left, right nodeIndex) int {
 		leftGeneration := query.effectiveGeneration(left)
--- a/commitquery/node_id.go
+++ b/commitquery/node_id.go
@@ -2,6 +2,7 @@
 
 import objectid "codeberg.org/lindenii/furgit/object/id"
 
+// id returns one node's object ID.
 func (query *query) id(idx nodeIndex) objectid.ObjectID {
 	return query.nodes[idx].id
 }
--- a/commitquery/node_paint_down_to_common.go
+++ /dev/null
@@ -1,66 +1,0 @@
-package commitquery
-
-import "codeberg.org/lindenii/furgit/internal/priorityqueue"
-
-func (query *query) paintDownToCommon(left nodeIndex, rights []nodeIndex, minGeneration uint64) error {
-	query.beginMarkPhase()
-
-	query.setMarks(left, markLeft)
-
-	if len(rights) == 0 {
-		query.setMarks(left, markResult)
-
-		return nil
-	}
-
-	queue := priorityqueue.New(func(left, right nodeIndex) bool {
-		return query.compare(left, right) > 0
-	})
-	queue.Push(left)
-
-	for _, right := range rights {
-		query.setMarks(right, markRight)
-		queue.Push(right)
-	}
-
-	lastGeneration := generationInfinity
-
-	for queue.Len() > 0 {
-		idx, ok := queue.Pop()
-		if !ok {
-			break
-		}
-
-		if query.hasAnyMarks(idx, markStale) {
-			continue
-		}
-
-		generation := query.effectiveGeneration(idx)
-		if generation > lastGeneration {
-			return errBadGenerationOrder
-		}
-
-		lastGeneration = generation
-		if generation < minGeneration {
-			break
-		}
-
-		flags := query.marks(idx) & (markLeft | markRight | markStale)
-		if flags == (markLeft | markRight) {
-			query.setMarks(idx, markResult)
-
-			flags |= markStale
-		}
-
-		for _, parent := range query.parents(idx) {
-			if query.hasAllMarks(parent, flags) {
-				continue
-			}
-
-			query.setMarks(parent, flags)
-			queue.Push(parent)
-		}
-	}
-
-	return nil
-}
--- a/commitquery/node_parent.go
+++ /dev/null
@@ -1,27 +1,0 @@
-package commitquery
-
-import (
-	commitgraphread "codeberg.org/lindenii/furgit/format/commitgraph/read"
-	objectid "codeberg.org/lindenii/furgit/object/id"
-)
-
-// Parents returns resolved parent node indices for one internal node.
-func (query *query) parents(idx nodeIndex) []nodeIndex {
-	return query.nodes[idx].parents
-}
-
-// parentRef references one commit parent.
-type parentRef struct {
-	ID          objectid.ObjectID
-	GraphPos    commitgraphread.Position
-	HasGraphPos bool
-}
-
-// resolveParent resolves one parent descriptor to one internal node.
-func (query *query) resolveParent(parent parentRef) (nodeIndex, error) {
-	if parent.HasGraphPos {
-		return query.resolveGraphPos(parent.GraphPos)
-	}
-
-	return query.resolveOID(parent.ID)
-}
--- /dev/null
+++ b/commitquery/node_parents.go
@@ -1,0 +1,6 @@
+package commitquery
+
+// parents returns resolved parent node indices for one internal node.
+func (query *query) parents(idx nodeIndex) []nodeIndex {
+	return query.nodes[idx].parents
+}
--- /dev/null
+++ b/commitquery/parent_ref.go
@@ -1,0 +1,13 @@
+package commitquery
+
+import (
+	commitgraphread "codeberg.org/lindenii/furgit/format/commitgraph/read"
+	objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+// parentRef references one commit parent.
+type parentRef struct {
+	ID          objectid.ObjectID
+	GraphPos    commitgraphread.Position
+	HasGraphPos bool
+}
--- a/commitquery/queries_acquire.go
+++ b/commitquery/queries_acquire.go
@@ -1,5 +1,6 @@
 package commitquery
 
+// acquire removes one worker from the idle pool or allocates one new worker.
 func (queries *Queries) acquire() *query {
 	queries.mu.Lock()
 	defer queries.mu.Unlock()
--- a/commitquery/queries_ancestor.go
+++ /dev/null
@@ -1,14 +1,0 @@
-package commitquery
-
-import objectid "codeberg.org/lindenii/furgit/object/id"
-
-// IsAncestor reports whether ancestor is reachable from descendant through
-// commit parent edges.
-//
-// Both inputs are peeled through annotated tags before commit traversal.
-func (queries *Queries) IsAncestor(ancestor, descendant objectid.ObjectID) (bool, error) {
-	query := queries.acquire()
-	defer queries.release(query)
-
-	return query.IsAncestor(ancestor, descendant)
-}
--- /dev/null
+++ b/commitquery/queries_is_ancestor.go
@@ -1,0 +1,14 @@
+package commitquery
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// IsAncestor reports whether ancestor is reachable from descendant through
+// commit parent edges.
+//
+// Both inputs are peeled through annotated tags before commit traversal.
+func (queries *Queries) IsAncestor(ancestor, descendant objectid.ObjectID) (bool, error) {
+	query := queries.acquire()
+	defer queries.release(query)
+
+	return query.IsAncestor(ancestor, descendant)
+}
--- /dev/null
+++ b/commitquery/queries_is_ancestor_integration_test.go
@@ -1,0 +1,132 @@
+package commitquery_test
+
+import (
+	"errors"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/commitquery"
+	giterrors "codeberg.org/lindenii/furgit/errors"
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+func TestIsMatchesGitMergeBase(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")
+
+		store := testRepo.OpenObjectStore(t)
+
+		got, err := commitquery.New(store, nil).IsAncestor(c1, tag)
+		if err != nil {
+			t.Fatalf("Is(c1, tag): %v", err)
+		}
+
+		want := gitMergeBaseIsAncestor(t, testRepo, c1, c2)
+		if got != want {
+			t.Fatalf("Is(c1, tag)=%v, want %v", got, want)
+		}
+
+		got, err = commitquery.New(store, nil).IsAncestor(c3, c2)
+		if err != nil {
+			t.Fatalf("Is(c3, c2): %v", err)
+		}
+
+		want = gitMergeBaseIsAncestor(t, testRepo, c3, c2)
+		if got != want {
+			t.Fatalf("Is(c3, c2)=%v, want %v", got, want)
+		}
+	})
+}
+
+func TestIsMatchesGitMergeBaseWithCommitGraph(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)
+
+		testRepo.UpdateRef(t, "refs/heads/main", c2)
+		testRepo.SymbolicRef(t, "HEAD", "refs/heads/main")
+		testRepo.CommitGraphWrite(t, "--reachable")
+
+		store := testRepo.OpenObjectStore(t)
+		graph := testRepo.OpenCommitGraph(t)
+
+		got, err := commitquery.New(store, graph).IsAncestor(c1, c2)
+		if err != nil {
+			t.Fatalf("Is(c1, c2): %v", err)
+		}
+
+		want := gitMergeBaseIsAncestor(t, testRepo, c1, c2)
+		if got != want {
+			t.Fatalf("Is(c1, c2)=%v, want %v", got, want)
+		}
+	})
+}
+
+func TestIsMissingObject(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")
+
+		testRepo.RemoveLooseObject(t, treeID)
+
+		store := testRepo.OpenObjectStore(t)
+
+		_, err := commitquery.New(store, nil).IsAncestor(treeID, commitID)
+		if err == nil {
+			t.Fatal("expected error")
+		}
+
+		missing, ok := errors.AsType[*giterrors.ObjectMissingError](err)
+		if !ok {
+			t.Fatalf("expected ObjectMissingError, got %T (%v)", err, err)
+		}
+
+		if missing.OID != treeID {
+			t.Fatalf("missing oid = %s, want %s", missing.OID, treeID)
+		}
+	})
+}
+
+// gitMergeBaseIsAncestor reports Git's merge-base ancestry answer.
+func gitMergeBaseIsAncestor(t *testing.T, testRepo *testgit.TestRepo, left, right objectid.ObjectID) bool {
+	t.Helper()
+
+	out := testRepo.Run(t, "merge-base", left.String(), right.String())
+
+	return out == left.String()
+}
--- /dev/null
+++ b/commitquery/queries_is_ancestor_unit_test.go
@@ -1,0 +1,117 @@
+package commitquery_test
+
+import (
+	"errors"
+	"fmt"
+	"testing"
+
+	giterrors "codeberg.org/lindenii/furgit/errors"
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	objectid "codeberg.org/lindenii/furgit/object/id"
+	"codeberg.org/lindenii/furgit/object/store/memory"
+	objecttree "codeberg.org/lindenii/furgit/object/tree"
+	objecttype "codeberg.org/lindenii/furgit/object/type"
+
+	"codeberg.org/lindenii/furgit/commitquery"
+)
+
+// ancestorCommitBody serializes one minimal commit body.
+func ancestorCommitBody(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
+}
+
+// ancestorTagBody serializes one minimal annotated tag body.
+func ancestorTagBody(target objectid.ObjectID, targetType objecttype.Type) []byte {
+	targetName, ok := targetType.Name()
+	if !ok {
+		panic("invalid tag target type")
+	}
+
+	return fmt.Appendf(nil, "object %s\ntype %s\ntag t\n\nmsg\n", target.String(), targetName)
+}
+
+// mustSerializeAncestorTree serializes one tree or fails the test.
+func mustSerializeAncestorTree(tb testing.TB, tree *objecttree.Tree) []byte {
+	tb.Helper()
+
+	body, err := tree.SerializeWithoutHeader()
+	if err != nil {
+		tb.Fatalf("SerializeWithoutHeader: %v", err)
+	}
+
+	return body
+}
+
+func TestIs(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+		store := memory.New(algo)
+		blob := store.AddObject(objecttype.TypeBlob, []byte("blob\n"))
+		tree := store.AddObject(objecttype.TypeTree, mustSerializeAncestorTree(t, &objecttree.Tree{Entries: []objecttree.TreeEntry{{
+			Mode: objecttree.FileModeRegular,
+			Name: []byte("f"),
+			ID:   blob,
+		}}}))
+		c1 := store.AddObject(objecttype.TypeCommit, ancestorCommitBody(tree))
+		c2 := store.AddObject(objecttype.TypeCommit, ancestorCommitBody(tree, c1))
+		otherBlob := store.AddObject(objecttype.TypeBlob, []byte("other-blob\n"))
+		otherTree := store.AddObject(objecttype.TypeTree, mustSerializeAncestorTree(t, &objecttree.Tree{Entries: []objecttree.TreeEntry{{
+			Mode: objecttree.FileModeRegular,
+			Name: []byte("g"),
+			ID:   otherBlob,
+		}}}))
+		c3 := store.AddObject(objecttype.TypeCommit, ancestorCommitBody(otherTree))
+		tag := store.AddObject(objecttype.TypeTag, ancestorTagBody(c2, objecttype.TypeCommit))
+
+		ok, err := commitquery.New(store, nil).IsAncestor(c1, tag)
+		if err != nil {
+			t.Fatalf("Is(c1, tag): %v", err)
+		}
+
+		if !ok {
+			t.Fatal("expected c1 to be ancestor of tag->c2")
+		}
+
+		ok, err = commitquery.New(store, nil).IsAncestor(c3, c2)
+		if err != nil {
+			t.Fatalf("Is(c3, c2): %v", err)
+		}
+
+		if ok {
+			t.Fatal("did not expect c3 to be ancestor of c2")
+		}
+	})
+}
+
+func TestIsRejectsNonCommitAfterPeel(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+		store := memory.New(algo)
+		blob := store.AddObject(objecttype.TypeBlob, []byte("blob\n"))
+		tree := store.AddObject(objecttype.TypeTree, mustSerializeAncestorTree(t, &objecttree.Tree{Entries: []objecttree.TreeEntry{{
+			Mode: objecttree.FileModeRegular,
+			Name: []byte("f"),
+			ID:   blob,
+		}}}))
+		commit := store.AddObject(objecttype.TypeCommit, ancestorCommitBody(tree))
+		tagToTree := store.AddObject(objecttype.TypeTag, ancestorTagBody(tree, objecttype.TypeTree))
+
+		_, err := commitquery.New(store, nil).IsAncestor(commit, tagToTree)
+		if err == nil {
+			t.Fatal("expected error")
+		}
+
+		if _, ok := errors.AsType[*giterrors.ObjectTypeError](err); !ok {
+			t.Fatalf("expected ObjectTypeError, got %T (%v)", err, err)
+		}
+	})
+}
--- /dev/null
+++ b/commitquery/queries_merge_base.go
@@ -1,0 +1,11 @@
+package commitquery
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// MergeBase reports one merge base between left and right, if any.
+func (queries *Queries) MergeBase(left, right objectid.ObjectID) (objectid.ObjectID, bool, error) {
+	query := queries.acquire()
+	defer queries.release(query)
+
+	return query.MergeBase(left, right)
+}
--- /dev/null
+++ b/commitquery/queries_merge_bases.go
@@ -1,0 +1,13 @@
+package commitquery
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// MergeBases reports all merge bases in Git's merge-base --all order.
+//
+// Both inputs are peeled through annotated tags before commit traversal.
+func (queries *Queries) MergeBases(left, right objectid.ObjectID) ([]objectid.ObjectID, error) {
+	query := queries.acquire()
+	defer queries.release(query)
+
+	return query.MergeBases(left, right)
+}
--- /dev/null
+++ b/commitquery/queries_merge_bases_integration_test.go
@@ -1,0 +1,311 @@
+package commitquery_test
+
+import (
+	"maps"
+	"slices"
+	"strings"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/commitquery"
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+func TestQueryMatchesGitMergeBaseAll(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)
+
+		tag := testRepo.TagAnnotated(t, "right-tag", right, "right-tag")
+
+		store := testRepo.OpenObjectStore(t)
+
+		query := commitquery.New(store, nil)
+
+		all, err := query.MergeBases(left, tag)
+		if err != nil {
+			t.Fatalf("query.All(): %v", err)
+		}
+
+		got := oidSetFromSlice(all)
+
+		want := gitMergeBaseAllSet(t, testRepo, left, tag)
+		if !maps.Equal(got, want) {
+			t.Fatalf("Query(left, tag) mismatch:\n got=%v\nwant=%v", sortedOIDStrings(got), sortedOIDStrings(want))
+		}
+	})
+}
+
+func TestQueryCrissCrossMatchesGitMergeBaseAll(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, "root.txt", []byte("root\n"))
+		root := testRepo.CommitTree(t, tree1, "root")
+
+		_, tree2 := testRepo.MakeSingleFileTree(t, "base1.txt", []byte("base1\n"))
+		base1 := testRepo.CommitTree(t, tree2, "base1", root)
+
+		_, tree3 := testRepo.MakeSingleFileTree(t, "base2.txt", []byte("base2\n"))
+		base2 := testRepo.CommitTree(t, tree3, "base2", root)
+
+		_, tree4 := testRepo.MakeSingleFileTree(t, "left.txt", []byte("left\n"))
+		left := testRepo.CommitTree(t, tree4, "left", base1, base2)
+
+		_, tree5 := testRepo.MakeSingleFileTree(t, "right.txt", []byte("right\n"))
+		right := testRepo.CommitTree(t, tree5, "right", base2, base1)
+
+		store := testRepo.OpenObjectStore(t)
+
+		query := commitquery.New(store, nil)
+
+		all, err := query.MergeBases(left, right)
+		if err != nil {
+			t.Fatalf("query.All(): %v", err)
+		}
+
+		got := oidSetFromSlice(all)
+
+		want := gitMergeBaseAllSet(t, testRepo, left, right)
+		if !maps.Equal(got, want) {
+			t.Fatalf("Query(left, right) mismatch:\n got=%v\nwant=%v", sortedOIDStrings(got), sortedOIDStrings(want))
+		}
+
+		first, ok, err := query.MergeBase(left, right)
+		if err != nil {
+			t.Fatalf("Base(left, right): %v", err)
+		}
+
+		if !ok {
+			t.Fatal("Base(left, right) unexpectedly reported no base")
+		}
+
+		if !containsID(want, first) {
+			t.Fatalf("Base(left, right)=%s, want one of %v", first, slices.Collect(maps.Keys(want)))
+		}
+	})
+}
+
+func TestQueryMatchesGitMergeBaseAllWithCommitGraph(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, "root.txt", []byte("root\n"))
+		root := testRepo.CommitTree(t, tree1, "root")
+
+		_, tree2 := testRepo.MakeSingleFileTree(t, "base1.txt", []byte("base1\n"))
+		base1 := testRepo.CommitTree(t, tree2, "base1", root)
+
+		_, tree3 := testRepo.MakeSingleFileTree(t, "base2.txt", []byte("base2\n"))
+		base2 := testRepo.CommitTree(t, tree3, "base2", root)
+
+		_, tree4 := testRepo.MakeSingleFileTree(t, "left.txt", []byte("left\n"))
+		left := testRepo.CommitTree(t, tree4, "left", base1, base2)
+
+		_, tree5 := testRepo.MakeSingleFileTree(t, "right.txt", []byte("right\n"))
+		right := testRepo.CommitTree(t, tree5, "right", base2, base1)
+
+		testRepo.UpdateRef(t, "refs/heads/main", right)
+		testRepo.SymbolicRef(t, "HEAD", "refs/heads/main")
+		testRepo.CommitGraphWrite(t, "--reachable")
+
+		store := testRepo.OpenObjectStore(t)
+		graph := testRepo.OpenCommitGraph(t)
+
+		query := commitquery.New(store, graph)
+
+		all, err := query.MergeBases(left, right)
+		if err != nil {
+			t.Fatalf("query.All(): %v", err)
+		}
+
+		got := oidSetFromSlice(all)
+
+		want := gitMergeBaseAllSet(t, testRepo, left, right)
+		if !maps.Equal(got, want) {
+			t.Fatalf("Query(left, right) with commit-graph mismatch:\n got=%v\nwant=%v", sortedOIDStrings(got), sortedOIDStrings(want))
+		}
+
+		first, ok, err := query.MergeBase(left, right)
+		if err != nil {
+			t.Fatalf("Base(left, right): %v", err)
+		}
+
+		if !ok {
+			t.Fatal("Base(left, right) unexpectedly reported no base")
+		}
+
+		if !containsID(want, first) {
+			t.Fatalf("Base(left, right)=%s, want one of %v", first, slices.Collect(maps.Keys(want)))
+		}
+	})
+}
+
+func TestBaseMatchesGitMergeBaseWithoutAll(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, "root.txt", []byte("root\n"))
+		root := testRepo.CommitTree(t, tree1, "root")
+
+		_, tree2 := testRepo.MakeSingleFileTree(t, "base1.txt", []byte("base1\n"))
+		base1 := testRepo.CommitTreeWithEnv(t, []string{
+			"GIT_AUTHOR_DATE=1234567890 +0000",
+			"GIT_COMMITTER_DATE=1234567890 +0000",
+		}, tree2, "base1", root)
+
+		_, tree3 := testRepo.MakeSingleFileTree(t, "base2.txt", []byte("base2\n"))
+		base2 := testRepo.CommitTreeWithEnv(t, []string{
+			"GIT_AUTHOR_DATE=1234567990 +0000",
+			"GIT_COMMITTER_DATE=1234567990 +0000",
+		}, tree3, "base2", root)
+
+		_, tree4 := testRepo.MakeSingleFileTree(t, "left.txt", []byte("left\n"))
+		left := testRepo.CommitTree(t, tree4, "left", base1, base2)
+
+		_, tree5 := testRepo.MakeSingleFileTree(t, "right.txt", []byte("right\n"))
+		right := testRepo.CommitTree(t, tree5, "right", base2, base1)
+
+		store := testRepo.OpenObjectStore(t)
+
+		query := commitquery.New(store, nil)
+
+		got, ok, err := query.MergeBase(left, right)
+		if err != nil {
+			t.Fatalf("Base(left, right): %v", err)
+		}
+
+		if !ok {
+			t.Fatal("Base(left, right) unexpectedly reported no base")
+		}
+
+		want := gitMergeBaseOne(t, testRepo, left, right)
+		if got != want {
+			t.Fatalf("Base(left, right)=%s, want %s", got, want)
+		}
+
+		testRepo.UpdateRef(t, "refs/heads/main", right)
+		testRepo.SymbolicRef(t, "HEAD", "refs/heads/main")
+		testRepo.CommitGraphWrite(t, "--reachable")
+
+		graph := testRepo.OpenCommitGraph(t)
+
+		got, ok, err = commitquery.New(store, graph).MergeBase(left, right)
+		if err != nil {
+			t.Fatalf("Base(left, right) with commit-graph: %v", err)
+		}
+
+		if !ok {
+			t.Fatal("Base(left, right) with commit-graph unexpectedly reported no base")
+		}
+
+		if got != want {
+			t.Fatalf("Base(left, right) with commit-graph=%s, want %s", got, want)
+		}
+	})
+}
+
+// oidSetFromSlice collects one object ID slice into a set.
+func oidSetFromSlice(ids []objectid.ObjectID) map[objectid.ObjectID]struct{} {
+	out := make(map[objectid.ObjectID]struct{})
+
+	for _, id := range ids {
+		out[id] = struct{}{}
+	}
+
+	return out
+}
+
+// gitMergeBaseAllSet returns Git's merge-base --all output as a set.
+func gitMergeBaseAllSet(
+	t *testing.T,
+	testRepo *testgit.TestRepo,
+	left objectid.ObjectID,
+	right objectid.ObjectID,
+) map[objectid.ObjectID]struct{} {
+	t.Helper()
+
+	out := testRepo.Run(t, "merge-base", "--all", left.String(), right.String())
+	set := make(map[objectid.ObjectID]struct{})
+
+	for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") {
+		line = strings.TrimSpace(line)
+		if line == "" {
+			continue
+		}
+
+		id, err := objectid.ParseHex(testRepo.Algorithm(), line)
+		if err != nil {
+			t.Fatalf("parse merge-base oid %q: %v", line, err)
+		}
+
+		set[id] = struct{}{}
+	}
+
+	return set
+}
+
+// gitMergeBaseOne returns Git's merge-base output without --all.
+func gitMergeBaseOne(
+	t *testing.T,
+	testRepo *testgit.TestRepo,
+	left objectid.ObjectID,
+	right objectid.ObjectID,
+) objectid.ObjectID {
+	t.Helper()
+
+	out := strings.TrimSpace(testRepo.Run(t, "merge-base", left.String(), right.String()))
+	if out == "" {
+		t.Fatal("git merge-base returned no output")
+	}
+
+	id, err := objectid.ParseHex(testRepo.Algorithm(), out)
+	if err != nil {
+		t.Fatalf("parse merge-base oid %q: %v", out, err)
+	}
+
+	return id
+}
+
+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
+}
--- /dev/null
+++ b/commitquery/queries_merge_bases_unit_test.go
@@ -1,0 +1,332 @@
+package commitquery_test
+
+import (
+	"errors"
+	"fmt"
+	"maps"
+	"slices"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/commitquery"
+	giterrors "codeberg.org/lindenii/furgit/errors"
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	objectid "codeberg.org/lindenii/furgit/object/id"
+	"codeberg.org/lindenii/furgit/object/store/memory"
+	"codeberg.org/lindenii/furgit/object/tree"
+	objecttype "codeberg.org/lindenii/furgit/object/type"
+)
+
+// commitBody serializes one minimal commit body.
+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
+}
+
+// tagBody serializes one minimal annotated tag body.
+func tagBody(target objectid.ObjectID, targetType objecttype.Type) []byte {
+	targetName, ok := targetType.Name()
+	if !ok {
+		panic("invalid tag target type")
+	}
+
+	return fmt.Appendf(nil, "object %s\ntype %s\ntag t\n\nmsg\n", target.String(), targetName)
+}
+
+// toSet converts one slice of object IDs into a set.
+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
+}
+
+// containsID reports whether one set contains one object ID.
+func containsID(set map[objectid.ObjectID]struct{}, id objectid.ObjectID) bool {
+	_, ok := set[id]
+
+	return ok
+}
+
+// mustSerializeTree serializes one tree or fails the test.
+func mustSerializeTree(tb testing.TB, tree *tree.Tree) []byte {
+	tb.Helper()
+
+	body, err := tree.SerializeWithoutHeader()
+	if err != nil {
+		tb.Fatalf("SerializeWithoutHeader: %v", err)
+	}
+
+	return body
+}
+
+// TestQueryLinearHistory reports one linear-history merge base.
+func TestQueryLinearHistory(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+		store := memory.New(algo)
+		blob := store.AddObject(objecttype.TypeBlob, []byte("blob\n"))
+		tree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
+			Mode: tree.FileModeRegular,
+			Name: []byte("f"),
+			ID:   blob,
+		}}}))
+		base := store.AddObject(objecttype.TypeCommit, commitBody(tree))
+		left := store.AddObject(objecttype.TypeCommit, commitBody(tree, base))
+		right := store.AddObject(objecttype.TypeCommit, commitBody(tree, left))
+
+		query := commitquery.New(store, nil)
+
+		got, err := query.MergeBases(left, right)
+		if err != nil {
+			t.Fatalf("query.All(): %v", err)
+		}
+
+		if !slices.Equal(got, []objectid.ObjectID{left}) {
+			t.Fatalf("Query(left, right)=%v, want [%s]", got, left)
+		}
+
+		first, ok, err := query.MergeBase(left, right)
+		if err != nil {
+			t.Fatalf("Base(left, right): %v", err)
+		}
+
+		if !ok {
+			t.Fatal("Base(left, right) unexpectedly reported no base")
+		}
+
+		if first != left {
+			t.Fatalf("Base(left, right)=%s, want %s", first, left)
+		}
+	})
+}
+
+func TestQueryPeelsAnnotatedTags(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+		store := memory.New(algo)
+		blob := store.AddObject(objecttype.TypeBlob, []byte("blob\n"))
+		leftTree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
+			Mode: tree.FileModeRegular,
+			Name: []byte("left"),
+			ID:   blob,
+		}}}))
+		rightTree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
+			Mode: tree.FileModeRegular,
+			Name: []byte("right"),
+			ID:   blob,
+		}}}))
+		base := store.AddObject(objecttype.TypeCommit, commitBody(leftTree))
+		left := store.AddObject(objecttype.TypeCommit, commitBody(leftTree, base))
+		right := store.AddObject(objecttype.TypeCommit, commitBody(rightTree, base))
+		tag := store.AddObject(objecttype.TypeTag, tagBody(right, objecttype.TypeCommit))
+
+		query := commitquery.New(store, nil)
+
+		got, err := query.MergeBases(left, tag)
+		if err != nil {
+			t.Fatalf("query.All(): %v", err)
+		}
+
+		if !slices.Equal(got, []objectid.ObjectID{base}) {
+			t.Fatalf("Query(left, tag)=%v, want [%s]", got, base)
+		}
+	})
+}
+
+func TestQueryCrissCrossReturnsAllBestCommonAncestors(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+		store := memory.New(algo)
+		blob := store.AddObject(objecttype.TypeBlob, []byte("blob\n"))
+		rootTree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
+			Mode: tree.FileModeRegular,
+			Name: []byte("root"),
+			ID:   blob,
+		}}}))
+		base1Tree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
+			Mode: tree.FileModeRegular,
+			Name: []byte("base1"),
+			ID:   blob,
+		}}}))
+		base2Tree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
+			Mode: tree.FileModeRegular,
+			Name: []byte("base2"),
+			ID:   blob,
+		}}}))
+		leftTree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
+			Mode: tree.FileModeRegular,
+			Name: []byte("left"),
+			ID:   blob,
+		}}}))
+		rightTree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
+			Mode: tree.FileModeRegular,
+			Name: []byte("right"),
+			ID:   blob,
+		}}}))
+		root := store.AddObject(objecttype.TypeCommit, commitBody(rootTree))
+		base1 := store.AddObject(objecttype.TypeCommit, commitBody(base1Tree, root))
+		base2 := store.AddObject(objecttype.TypeCommit, commitBody(base2Tree, root))
+		left := store.AddObject(objecttype.TypeCommit, commitBody(leftTree, base1, base2))
+		right := store.AddObject(objecttype.TypeCommit, commitBody(rightTree, base2, base1))
+
+		query := commitquery.New(store, nil)
+
+		all, err := query.MergeBases(left, right)
+		if err != nil {
+			t.Fatalf("query.All(): %v", err)
+		}
+
+		got := toSet(all)
+
+		want := map[objectid.ObjectID]struct{}{base1: {}, base2: {}}
+		if !maps.Equal(got, want) {
+			t.Fatalf("Query(left, right)=%v, want %v", slices.Collect(maps.Keys(got)), slices.Collect(maps.Keys(want)))
+		}
+
+		first, ok, err := query.MergeBase(left, right)
+		if err != nil {
+			t.Fatalf("Base(left, right): %v", err)
+		}
+
+		if !ok {
+			t.Fatal("Base(left, right) unexpectedly reported no base")
+		}
+
+		if !containsID(want, first) {
+			t.Fatalf("Base(left, right)=%s, want one of %v", first, slices.Collect(maps.Keys(want)))
+		}
+	})
+}
+
+func TestQueryReturnsNoResultWhenNoCommonAncestorExists(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+		store := memory.New(algo)
+		leftBlob := store.AddObject(objecttype.TypeBlob, []byte("left\n"))
+		leftTree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
+			Mode: tree.FileModeRegular,
+			Name: []byte("left"),
+			ID:   leftBlob,
+		}}}))
+		rightBlob := store.AddObject(objecttype.TypeBlob, []byte("right\n"))
+		rightTree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
+			Mode: tree.FileModeRegular,
+			Name: []byte("right"),
+			ID:   rightBlob,
+		}}}))
+		left := store.AddObject(objecttype.TypeCommit, commitBody(leftTree))
+		right := store.AddObject(objecttype.TypeCommit, commitBody(rightTree))
+
+		query := commitquery.New(store, nil)
+
+		got, err := query.MergeBases(left, right)
+		if err != nil {
+			t.Fatalf("query.All(): %v", err)
+		}
+
+		if len(got) != 0 {
+			t.Fatalf("Query(left, right)=%v, want no results", got)
+		}
+
+		_, ok, err := query.MergeBase(left, right)
+		if err != nil {
+			t.Fatalf("Base(left, right): %v", err)
+		}
+
+		if ok {
+			t.Fatal("Base(left, right) unexpectedly reported a base")
+		}
+	})
+}
+
+func TestQueryRejectsNonCommitAfterPeel(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+		store := memory.New(algo)
+		blob := store.AddObject(objecttype.TypeBlob, []byte("blob\n"))
+		tree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
+			Mode: tree.FileModeRegular,
+			Name: []byte("f"),
+			ID:   blob,
+		}}}))
+		commit := store.AddObject(objecttype.TypeCommit, commitBody(tree))
+		tagToTree := store.AddObject(objecttype.TypeTag, tagBody(tree, objecttype.TypeTree))
+
+		query := commitquery.New(store, nil)
+
+		_, err := query.MergeBases(commit, tagToTree)
+		if err == nil {
+			t.Fatal("expected error")
+		}
+
+		typeErr, ok := errors.AsType[*giterrors.ObjectTypeError](err)
+		if !ok {
+			t.Fatalf("expected ObjectTypeError, got %T (%v)", err, err)
+		}
+
+		if typeErr.Got != objecttype.TypeTree || typeErr.Want != objecttype.TypeCommit {
+			t.Fatalf("unexpected type error: %+v", typeErr)
+		}
+	})
+}
+
+func TestQueryAllIsRepeatable(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+		store := memory.New(algo)
+		blob := store.AddObject(objecttype.TypeBlob, []byte("blob\n"))
+		tree := store.AddObject(objecttype.TypeTree, mustSerializeTree(t, &tree.Tree{Entries: []tree.TreeEntry{{
+			Mode: tree.FileModeRegular,
+			Name: []byte("f"),
+			ID:   blob,
+		}}}))
+		base := store.AddObject(objecttype.TypeCommit, commitBody(tree))
+		left := store.AddObject(objecttype.TypeCommit, commitBody(tree, base))
+		right := store.AddObject(objecttype.TypeCommit, commitBody(tree, left))
+
+		query := commitquery.New(store, nil)
+
+		first, err := query.MergeBases(left, right)
+		if err != nil {
+			t.Fatalf("query.MergeBases() first call: %v", err)
+		}
+
+		again, err := query.MergeBases(left, right)
+		if err != nil {
+			t.Fatalf("query.MergeBases() second call: %v", err)
+		}
+
+		if !slices.Equal(again, first) {
+			t.Fatalf("second All()=%v, want %v", again, first)
+		}
+
+		if len(first) == 0 {
+			t.Fatal("first MergeBases() unexpectedly returned no results")
+		}
+
+		first[0] = objectid.ObjectID{}
+
+		third, err := query.MergeBases(left, right)
+		if err != nil {
+			t.Fatalf("query.MergeBases() third call: %v", err)
+		}
+
+		if third[0] == (objectid.ObjectID{}) {
+			t.Fatal("query.MergeBases() exposed internal slice state")
+		}
+	})
+}
--- a/commitquery/queries_mergebase.go
+++ /dev/null
@@ -1,21 +1,0 @@
-package commitquery
-
-import objectid "codeberg.org/lindenii/furgit/object/id"
-
-// MergeBases reports all merge bases in Git's merge-base --all order.
-//
-// Both inputs are peeled through annotated tags before commit traversal.
-func (queries *Queries) MergeBases(left, right objectid.ObjectID) ([]objectid.ObjectID, error) {
-	query := queries.acquire()
-	defer queries.release(query)
-
-	return query.MergeBases(left, right)
-}
-
-// MergeBase reports one merge base between left and right, if any.
-func (queries *Queries) MergeBase(left, right objectid.ObjectID) (objectid.ObjectID, bool, error) {
-	query := queries.acquire()
-	defer queries.release(query)
-
-	return query.MergeBase(left, right)
-}
--- a/commitquery/queries_release.go
+++ b/commitquery/queries_release.go
@@ -1,5 +1,6 @@
 package commitquery
 
+// release resets one worker and returns it to the idle pool if there is room.
 func (queries *Queries) release(q *query) {
 	q.resetForReuse()
 
--- /dev/null
+++ b/commitquery/query.go
@@ -1,0 +1,23 @@
+package commitquery
+
+import (
+	commitgraphread "codeberg.org/lindenii/furgit/format/commitgraph/read"
+	objectid "codeberg.org/lindenii/furgit/object/id"
+	objectstore "codeberg.org/lindenii/furgit/object/store"
+)
+
+// query stores one mutable reusable worker and its cached node arena.
+//
+// Labels: MT-Unsafe.
+type query struct {
+	store objectstore.ReadingStore
+	graph *commitgraphread.Reader
+
+	nodes []node
+
+	byOID      map[objectid.ObjectID]nodeIndex
+	byGraphPos map[commitgraphread.Position]nodeIndex
+
+	markPhase uint32
+	touched   []nodeIndex
+}
--- /dev/null
+++ b/commitquery/query_collect_marked_results.go
@@ -1,0 +1,20 @@
+package commitquery
+
+// collectMarkedResults returns touched nodes marked as non-stale results.
+func (query *query) collectMarkedResults() []nodeIndex {
+	out := make([]nodeIndex, 0, 4)
+
+	for _, idx := range query.touched {
+		if !query.hasAnyMarks(idx, markResult) {
+			continue
+		}
+
+		if query.hasAnyMarks(idx, markStale) {
+			continue
+		}
+
+		out = append(out, idx)
+	}
+
+	return out
+}
--- /dev/null
+++ b/commitquery/query_ensure_loaded.go
@@ -1,0 +1,14 @@
+package commitquery
+
+// ensureLoaded completes one node's metadata load if it is not loaded yet.
+func (query *query) ensureLoaded(idx nodeIndex) error {
+	if query.nodes[idx].loaded {
+		return nil
+	}
+
+	if query.nodes[idx].hasGraphPos {
+		return query.loadByGraphPos(idx)
+	}
+
+	return query.loadByOID(idx)
+}
--- /dev/null
+++ b/commitquery/query_has_marks.go
@@ -1,0 +1,11 @@
+package commitquery
+
+// hasAnyMarks reports whether one internal node has any requested bit.
+func (query *query) hasAnyMarks(idx nodeIndex, bits markBits) bool {
+	return query.nodes[idx].marks&bits != 0
+}
+
+// hasAllMarks reports whether one internal node already has all requested bits.
+func (query *query) hasAllMarks(idx nodeIndex, bits markBits) bool {
+	return query.nodes[idx].marks&bits == bits
+}
--- /dev/null
+++ b/commitquery/query_is_ancestor.go
@@ -1,0 +1,49 @@
+package commitquery
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// IsAncestor reports whether ancestor is reachable from descendant through
+// commit parent edges.
+//
+// Both inputs are peeled through annotated tags before commit traversal.
+func (query *query) IsAncestor(ancestor, descendant objectid.ObjectID) (bool, error) {
+	ancestorIdx, err := query.resolveCommitish(ancestor)
+	if err != nil {
+		return false, err
+	}
+
+	descendantIdx, err := query.resolveCommitish(descendant)
+	if err != nil {
+		return false, err
+	}
+
+	return query.isAncestor(ancestorIdx, descendantIdx)
+}
+
+// isAncestor answers one ancestry query between two resolved internal nodes.
+func (query *query) isAncestor(ancestor, descendant nodeIndex) (bool, error) {
+	if ancestor == descendant {
+		return true, nil
+	}
+
+	ancestorGeneration := query.effectiveGeneration(ancestor)
+	descendantGeneration := query.effectiveGeneration(descendant)
+
+	if ancestorGeneration != generationInfinity &&
+		descendantGeneration != generationInfinity &&
+		ancestorGeneration > descendantGeneration {
+		return false, nil
+	}
+
+	minGeneration := uint64(0)
+	if ancestorGeneration != generationInfinity {
+		minGeneration = ancestorGeneration
+	}
+
+	err := query.paintDownToCommon(ancestor, []nodeIndex{descendant}, minGeneration)
+	if err != nil {
+		return false, err
+	}
+
+	return query.hasAnyMarks(ancestor, markRight), nil
+}
--- /dev/null
+++ b/commitquery/query_load_by_graph_pos.go
@@ -1,0 +1,8 @@
+package commitquery
+
+// loadByGraphPos populates one node from a commit-graph position.
+func (query *query) loadByGraphPos(idx nodeIndex) error {
+	pos := query.nodes[idx].graphPos
+
+	return query.loadCommitAtGraphPos(idx, pos)
+}
--- /dev/null
+++ b/commitquery/query_load_by_oid.go
@@ -1,0 +1,58 @@
+package commitquery
+
+import (
+	stderrors "errors"
+
+	giterrors "codeberg.org/lindenii/furgit/errors"
+	commitgraphread "codeberg.org/lindenii/furgit/format/commitgraph/read"
+	objectcommit "codeberg.org/lindenii/furgit/object/commit"
+	objectstore "codeberg.org/lindenii/furgit/object/store"
+	objecttype "codeberg.org/lindenii/furgit/object/type"
+)
+
+// loadByOID populates one node from an object ID.
+func (query *query) loadByOID(idx nodeIndex) error {
+	id := query.nodes[idx].id
+
+	if query.graph != nil {
+		pos, err := query.graph.Lookup(id)
+		if err != nil {
+			if _, ok := stderrors.AsType[*commitgraphread.NotFoundError](err); !ok {
+				return err
+			}
+		} else {
+			return query.loadCommitAtGraphPos(idx, pos)
+		}
+	}
+
+	ty, content, err := query.store.ReadBytesContent(id)
+	if err != nil {
+		if stderrors.Is(err, objectstore.ErrObjectNotFound) {
+			return &giterrors.ObjectMissingError{OID: id}
+		}
+
+		return err
+	}
+
+	if ty != objecttype.TypeCommit {
+		return &giterrors.ObjectTypeError{OID: id, Got: ty, Want: objecttype.TypeCommit}
+	}
+
+	commitObj, err := objectcommit.Parse(content, id.Algorithm())
+	if err != nil {
+		return err
+	}
+
+	parents := make([]parentRef, 0, len(commitObj.Parents))
+	for _, parentID := range commitObj.Parents {
+		parents = append(parents, parentRef{ID: parentID})
+	}
+
+	commit := commitData{
+		ID:         id,
+		Parents:    parents,
+		CommitTime: commitObj.Committer.WhenUnix,
+	}
+
+	return query.populateNode(idx, commit)
+}
--- /dev/null
+++ b/commitquery/query_load_commit_at_graph_pos.go
@@ -1,0 +1,64 @@
+package commitquery
+
+import commitgraphread "codeberg.org/lindenii/furgit/format/commitgraph/read"
+
+// loadCommitAtGraphPos populates one node from one commit-graph record.
+func (query *query) loadCommitAtGraphPos(idx nodeIndex, pos commitgraphread.Position) error {
+	commit, err := query.graph.CommitAt(pos)
+	if err != nil {
+		return err
+	}
+
+	parents := make([]parentRef, 0, 2+len(commit.ExtraParents))
+
+	if commit.Parent1.Valid {
+		parentOID, err := query.graph.OIDAt(commit.Parent1.Pos)
+		if err != nil {
+			return err
+		}
+
+		parents = append(parents, parentRef{
+			ID:          parentOID,
+			GraphPos:    commit.Parent1.Pos,
+			HasGraphPos: true,
+		})
+	}
+
+	if commit.Parent2.Valid {
+		parentOID, err := query.graph.OIDAt(commit.Parent2.Pos)
+		if err != nil {
+			return err
+		}
+
+		parents = append(parents, parentRef{
+			ID:          parentOID,
+			GraphPos:    commit.Parent2.Pos,
+			HasGraphPos: true,
+		})
+	}
+
+	for _, parentPos := range commit.ExtraParents {
+		parentOID, err := query.graph.OIDAt(parentPos)
+		if err != nil {
+			return err
+		}
+
+		parents = append(parents, parentRef{
+			ID:          parentOID,
+			GraphPos:    parentPos,
+			HasGraphPos: true,
+		})
+	}
+
+	data := commitData{
+		ID:            commit.OID,
+		Parents:       parents,
+		CommitTime:    commit.CommitTimeUnix,
+		Generation:    commit.GenerationV2,
+		HasGeneration: commit.GenerationV2 != 0,
+		GraphPos:      pos,
+		HasGraphPos:   true,
+	}
+
+	return query.populateNode(idx, data)
+}
--- /dev/null
+++ b/commitquery/query_mark_phase.go
@@ -1,0 +1,36 @@
+package commitquery
+
+// beginMarkPhase starts one tracked mark-mutation phase.
+func (query *query) beginMarkPhase() {
+	for _, idx := range query.touched {
+		query.nodes[idx].marks = 0
+	}
+
+	query.markPhase++
+	if query.markPhase == 0 {
+		query.markPhase++
+		for i := range query.nodes {
+			query.nodes[i].touchedPhase = 0
+		}
+	}
+
+	query.touched = query.touched[:0]
+}
+
+// clearTouchedMarks clears the provided bits from all nodes touched in the
+// current mark phase.
+func (query *query) clearTouchedMarks(bits markBits) {
+	for _, idx := range query.touched {
+		query.nodes[idx].marks &^= bits
+	}
+}
+
+// trackTouched records one node in the current mark phase.
+func (query *query) trackTouched(idx nodeIndex) {
+	if query.nodes[idx].touchedPhase == query.markPhase {
+		return
+	}
+
+	query.nodes[idx].touchedPhase = query.markPhase
+	query.touched = append(query.touched, idx)
+}
--- /dev/null
+++ b/commitquery/query_marks_get.go
@@ -1,0 +1,6 @@
+package commitquery
+
+// marks returns the mark bits of one internal node.
+func (query *query) marks(idx nodeIndex) markBits {
+	return query.nodes[idx].marks
+}
--- /dev/null
+++ b/commitquery/query_merge_base.go
@@ -1,0 +1,17 @@
+package commitquery
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// MergeBase reports one merge base between left and right, if any.
+func (query *query) MergeBase(left, right objectid.ObjectID) (objectid.ObjectID, bool, error) {
+	bases, err := query.MergeBases(left, right)
+	if err != nil {
+		return objectid.ObjectID{}, false, err
+	}
+
+	if len(bases) == 0 {
+		return objectid.ObjectID{}, false, nil
+	}
+
+	return bases[0], true, nil
+}
--- /dev/null
+++ b/commitquery/query_merge_bases.go
@@ -1,0 +1,45 @@
+package commitquery
+
+import (
+	"slices"
+
+	objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+// MergeBases reports all merge bases in Git's merge-base --all order.
+//
+// Both inputs are peeled through annotated tags before commit traversal.
+func (query *query) MergeBases(left, right objectid.ObjectID) ([]objectid.ObjectID, error) {
+	leftIdx, err := query.resolveCommitish(left)
+	if err != nil {
+		return nil, err
+	}
+
+	rightIdx, err := query.resolveCommitish(right)
+	if err != nil {
+		return nil, err
+	}
+
+	candidates, err := query.mergeBases(leftIdx, rightIdx)
+	if err != nil {
+		return nil, err
+	}
+
+	slices.SortFunc(candidates, func(left, right nodeIndex) int {
+		switch {
+		case query.commitTime(left) > query.commitTime(right):
+			return -1
+		case query.commitTime(left) < query.commitTime(right):
+			return 1
+		default:
+			return objectid.Compare(query.id(left), query.id(right))
+		}
+	})
+
+	out := make([]objectid.ObjectID, 0, len(candidates))
+	for _, idx := range candidates {
+		out = append(out, query.id(idx))
+	}
+
+	return out, nil
+}
--- /dev/null
+++ b/commitquery/query_merge_bases_internal.go
@@ -1,0 +1,34 @@
+package commitquery
+
+import "slices"
+
+// mergeBases returns internal merge-base candidates for two resolved nodes.
+func (query *query) mergeBases(left, right nodeIndex) ([]nodeIndex, error) {
+	if left == right {
+		return []nodeIndex{left}, nil
+	}
+
+	err := query.paintDownToCommon(left, []nodeIndex{right}, 0)
+	if err != nil {
+		return nil, err
+	}
+
+	candidates := query.collectMarkedResults()
+
+	if len(candidates) <= 1 {
+		slices.SortFunc(candidates, query.compare)
+
+		return candidates, nil
+	}
+
+	query.clearTouchedMarks(allMarks)
+
+	reduced, err := removeRedundant(query, candidates)
+	if err != nil {
+		return nil, err
+	}
+
+	slices.SortFunc(reduced, query.compare)
+
+	return reduced, nil
+}
--- /dev/null
+++ b/commitquery/query_new.go
@@ -1,0 +1,19 @@
+package commitquery
+
+import (
+	commitgraphread "codeberg.org/lindenii/furgit/format/commitgraph/read"
+	objectid "codeberg.org/lindenii/furgit/object/id"
+	objectstore "codeberg.org/lindenii/furgit/object/store"
+)
+
+// newQuery builds one empty mutable worker over one object store and graph.
+//
+// Labels: Deps-Borrowed, Life-Parent.
+func newQuery(store objectstore.ReadingStore, graph *commitgraphread.Reader) *query {
+	return &query{
+		store:      store,
+		graph:      graph,
+		byOID:      make(map[objectid.ObjectID]nodeIndex),
+		byGraphPos: make(map[commitgraphread.Position]nodeIndex),
+	}
+}
--- /dev/null
+++ b/commitquery/query_paint_down_to_common.go
@@ -1,0 +1,67 @@
+package commitquery
+
+import "codeberg.org/lindenii/furgit/internal/priorityqueue"
+
+// paintDownToCommon propagates left and right marks downward until common nodes.
+func (query *query) paintDownToCommon(left nodeIndex, rights []nodeIndex, minGeneration uint64) error {
+	query.beginMarkPhase()
+
+	query.setMarks(left, markLeft)
+
+	if len(rights) == 0 {
+		query.setMarks(left, markResult)
+
+		return nil
+	}
+
+	queue := priorityqueue.New(func(left, right nodeIndex) bool {
+		return query.compare(left, right) > 0
+	})
+	queue.Push(left)
+
+	for _, right := range rights {
+		query.setMarks(right, markRight)
+		queue.Push(right)
+	}
+
+	lastGeneration := generationInfinity
+
+	for queue.Len() > 0 {
+		idx, ok := queue.Pop()
+		if !ok {
+			break
+		}
+
+		if query.hasAnyMarks(idx, markStale) {
+			continue
+		}
+
+		generation := query.effectiveGeneration(idx)
+		if generation > lastGeneration {
+			return errBadGenerationOrder
+		}
+
+		lastGeneration = generation
+		if generation < minGeneration {
+			break
+		}
+
+		flags := query.marks(idx) & (markLeft | markRight | markStale)
+		if flags == (markLeft | markRight) {
+			query.setMarks(idx, markResult)
+
+			flags |= markStale
+		}
+
+		for _, parent := range query.parents(idx) {
+			if query.hasAllMarks(parent, flags) {
+				continue
+			}
+
+			query.setMarks(parent, flags)
+			queue.Push(parent)
+		}
+	}
+
+	return nil
+}
--- /dev/null
+++ b/commitquery/query_reduce.go
@@ -1,0 +1,166 @@
+package commitquery
+
+import "slices"
+
+// removeRedundant removes redundant merge-base candidates.
+func removeRedundant(query *query, candidates []nodeIndex) ([]nodeIndex, error) {
+	for _, idx := range candidates {
+		if query.effectiveGeneration(idx) != generationInfinity {
+			return removeRedundantWithGen(query, candidates), nil
+		}
+	}
+
+	return removeRedundantNoGen(query, candidates)
+}
+
+// removeRedundantNoGen removes redundant candidates without generation data.
+func removeRedundantNoGen(query *query, candidates []nodeIndex) ([]nodeIndex, error) {
+	redundant := make([]bool, len(candidates))
+	work := make([]nodeIndex, 0, len(candidates)-1)
+	filledIndex := make([]int, 0, len(candidates)-1)
+
+	for i, candidate := range candidates {
+		if redundant[i] {
+			continue
+		}
+
+		work = work[:0]
+		filledIndex = filledIndex[:0]
+
+		minGeneration := query.effectiveGeneration(candidate)
+
+		for j, other := range candidates {
+			if i == j || redundant[j] {
+				continue
+			}
+
+			work = append(work, other)
+			filledIndex = append(filledIndex, j)
+
+			otherGeneration := query.effectiveGeneration(other)
+			if otherGeneration < minGeneration {
+				minGeneration = otherGeneration
+			}
+		}
+
+		err := query.paintDownToCommon(candidate, work, minGeneration)
+		if err != nil {
+			return nil, err
+		}
+
+		if query.hasAnyMarks(candidate, markRight) {
+			redundant[i] = true
+		}
+
+		for j, other := range work {
+			if query.hasAnyMarks(other, markLeft) {
+				redundant[filledIndex[j]] = true
+			}
+		}
+
+		query.clearTouchedMarks(allMarks)
+	}
+
+	out := make([]nodeIndex, 0, len(candidates))
+	for i, idx := range candidates {
+		if !redundant[i] {
+			out = append(out, idx)
+		}
+	}
+
+	return out, nil
+}
+
+// removeRedundantWithGen removes redundant candidates using generation data.
+func removeRedundantWithGen(query *query, candidates []nodeIndex) []nodeIndex {
+	sorted := append([]nodeIndex(nil), candidates...)
+	slices.SortFunc(sorted, query.compareByGeneration())
+
+	minGeneration := query.effectiveGeneration(sorted[0])
+	minGenPos := 0
+	countStillIndependent := len(candidates)
+
+	query.beginMarkPhase()
+
+	walkStart := make([]nodeIndex, 0, len(candidates)*2)
+
+	for _, idx := range candidates {
+		query.setMarks(idx, markResult)
+
+		for _, parent := range query.parents(idx) {
+			if query.hasAnyMarks(parent, markStale) {
+				continue
+			}
+
+			query.setMarks(parent, markStale)
+			walkStart = append(walkStart, parent)
+		}
+	}
+
+	slices.SortFunc(walkStart, query.compareByGeneration())
+
+	for _, idx := range walkStart {
+		query.clearMarks(idx, markStale)
+	}
+
+	for i := len(walkStart) - 1; i >= 0 && countStillIndependent > 1; i-- {
+		stack := []nodeIndex{walkStart[i]}
+		query.setMarks(walkStart[i], markStale)
+
+		for len(stack) > 0 {
+			top := stack[len(stack)-1]
+
+			if query.hasAnyMarks(top, markResult) {
+				query.clearMarks(top, markResult)
+
+				countStillIndependent--
+				if countStillIndependent <= 1 {
+					break
+				}
+
+				if top == sorted[minGenPos] {
+					for minGenPos < len(sorted)-1 && query.hasAnyMarks(sorted[minGenPos], markStale) {
+						minGenPos++
+					}
+
+					minGeneration = query.effectiveGeneration(sorted[minGenPos])
+				}
+			}
+
+			if query.effectiveGeneration(top) < minGeneration {
+				stack = stack[:len(stack)-1]
+
+				continue
+			}
+
+			pushed := false
+
+			for _, parent := range query.parents(top) {
+				if query.hasAnyMarks(parent, markStale) {
+					continue
+				}
+
+				query.setMarks(parent, markStale)
+				stack = append(stack, parent)
+				pushed = true
+
+				break
+			}
+
+			if !pushed {
+				stack = stack[:len(stack)-1]
+			}
+		}
+	}
+
+	out := make([]nodeIndex, 0, len(candidates))
+	for _, idx := range candidates {
+		if !query.hasAnyMarks(idx, markStale) {
+			out = append(out, idx)
+		}
+	}
+
+	query.clearTouchedMarks(markStale | markResult)
+
+	return out
+}
--- /dev/null
+++ b/commitquery/query_reset.go
@@ -1,0 +1,10 @@
+package commitquery
+
+// resetForReuse clears transient state before one worker returns to the pool.
+func (query *query) resetForReuse() {
+	for _, idx := range query.touched {
+		query.nodes[idx].marks = 0
+	}
+
+	query.touched = query.touched[:0]
+}
--- /dev/null
+++ b/commitquery/query_resolve_commitish.go
@@ -1,0 +1,16 @@
+package commitquery
+
+import (
+	"codeberg.org/lindenii/furgit/internal/peel"
+	objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+// resolveCommitish peels one commit-ish object ID and resolves the commit.
+func (query *query) resolveCommitish(id objectid.ObjectID) (nodeIndex, error) {
+	commitID, err := peel.ToCommit(query.store, id)
+	if err != nil {
+		return 0, err
+	}
+
+	return query.resolveOID(commitID)
+}
--- /dev/null
+++ b/commitquery/query_resolve_graph_pos.go
@@ -1,0 +1,40 @@
+package commitquery
+
+import commitgraphread "codeberg.org/lindenii/furgit/format/commitgraph/read"
+
+// resolveGraphPos resolves one commit-graph position to one internal query node.
+func (query *query) resolveGraphPos(pos commitgraphread.Position) (nodeIndex, error) {
+	idx, ok := query.byGraphPos[pos]
+	if ok {
+		err := query.ensureLoaded(idx)
+		if err != nil {
+			return 0, err
+		}
+
+		return idx, nil
+	}
+
+	commit, err := query.graph.CommitAt(pos)
+	if err != nil {
+		return 0, err
+	}
+
+	idx, ok = query.byOID[commit.OID]
+	if !ok {
+		idx = query.newNode(commit.OID)
+		query.byOID[commit.OID] = idx
+	}
+
+	query.byGraphPos[pos] = idx
+	query.nodes[idx].graphPos = pos
+	query.nodes[idx].hasGraphPos = true
+
+	err = query.loadCommitAtGraphPos(idx, pos)
+	if err != nil {
+		delete(query.byGraphPos, pos)
+
+		return 0, err
+	}
+
+	return idx, nil
+}
--- /dev/null
+++ b/commitquery/query_resolve_oid.go
@@ -1,0 +1,28 @@
+package commitquery
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// resolveOID resolves one commit object ID to one internal query node.
+func (query *query) resolveOID(id objectid.ObjectID) (nodeIndex, error) {
+	idx, ok := query.byOID[id]
+	if ok {
+		err := query.ensureLoaded(idx)
+		if err != nil {
+			return 0, err
+		}
+
+		return idx, nil
+	}
+
+	idx = query.newNode(id)
+	query.byOID[id] = idx
+
+	err := query.loadByOID(idx)
+	if err != nil {
+		delete(query.byOID, id)
+
+		return 0, err
+	}
+
+	return idx, nil
+}
--- /dev/null
+++ b/commitquery/query_resolve_parent.go
@@ -1,0 +1,10 @@
+package commitquery
+
+// resolveParent resolves one parent descriptor to one internal node.
+func (query *query) resolveParent(parent parentRef) (nodeIndex, error) {
+	if parent.HasGraphPos {
+		return query.resolveGraphPos(parent.GraphPos)
+	}
+
+	return query.resolveOID(parent.ID)
+}
--- /dev/null
+++ b/commitquery/query_set_clear_marks.go
@@ -1,0 +1,22 @@
+package commitquery
+
+// setMarks ORs one set of mark bits into one internal node.
+func (query *query) setMarks(idx nodeIndex, bits markBits) {
+	newBits := bits &^ query.nodes[idx].marks
+	if newBits == 0 {
+		return
+	}
+
+	query.trackTouched(idx)
+	query.nodes[idx].marks |= bits
+}
+
+// clearMarks removes one set of mark bits from one internal node.
+func (query *query) clearMarks(idx nodeIndex, bits markBits) {
+	if query.nodes[idx].marks&bits == 0 {
+		return
+	}
+
+	query.trackTouched(idx)
+	query.nodes[idx].marks &^= bits
+}
--- a/commitquery/reduce.go
+++ /dev/null
@@ -1,166 +1,0 @@
-package commitquery
-
-import (
-	"slices"
-)
-
-// removeRedundant removes redundant merge-base candidates.
-func removeRedundant(query *query, candidates []nodeIndex) ([]nodeIndex, error) {
-	for _, idx := range candidates {
-		if query.effectiveGeneration(idx) != generationInfinity {
-			return removeRedundantWithGen(query, candidates), nil
-		}
-	}
-
-	return removeRedundantNoGen(query, candidates)
-}
-
-func removeRedundantNoGen(query *query, candidates []nodeIndex) ([]nodeIndex, error) {
-	redundant := make([]bool, len(candidates))
-	work := make([]nodeIndex, 0, len(candidates)-1)
-	filledIndex := make([]int, 0, len(candidates)-1)
-
-	for i, candidate := range candidates {
-		if redundant[i] {
-			continue
-		}
-
-		work = work[:0]
-		filledIndex = filledIndex[:0]
-
-		minGeneration := query.effectiveGeneration(candidate)
-
-		for j, other := range candidates {
-			if i == j || redundant[j] {
-				continue
-			}
-
-			work = append(work, other)
-			filledIndex = append(filledIndex, j)
-
-			otherGeneration := query.effectiveGeneration(other)
-			if otherGeneration < minGeneration {
-				minGeneration = otherGeneration
-			}
-		}
-
-		err := query.paintDownToCommon(candidate, work, minGeneration)
-		if err != nil {
-			return nil, err
-		}
-
-		if query.hasAnyMarks(candidate, markRight) {
-			redundant[i] = true
-		}
-
-		for j, other := range work {
-			if query.hasAnyMarks(other, markLeft) {
-				redundant[filledIndex[j]] = true
-			}
-		}
-
-		query.clearTouchedMarks(allMarks)
-	}
-
-	out := make([]nodeIndex, 0, len(candidates))
-	for i, idx := range candidates {
-		if !redundant[i] {
-			out = append(out, idx)
-		}
-	}
-
-	return out, nil
-}
-
-func removeRedundantWithGen(query *query, candidates []nodeIndex) []nodeIndex {
-	sorted := append([]nodeIndex(nil), candidates...)
-	slices.SortFunc(sorted, query.compareByGeneration())
-
-	minGeneration := query.effectiveGeneration(sorted[0])
-	minGenPos := 0
-	countStillIndependent := len(candidates)
-
-	query.beginMarkPhase()
-
-	walkStart := make([]nodeIndex, 0, len(candidates)*2)
-
-	for _, idx := range candidates {
-		query.setMarks(idx, markResult)
-
-		for _, parent := range query.parents(idx) {
-			if query.hasAnyMarks(parent, markStale) {
-				continue
-			}
-
-			query.setMarks(parent, markStale)
-			walkStart = append(walkStart, parent)
-		}
-	}
-
-	slices.SortFunc(walkStart, query.compareByGeneration())
-
-	for _, idx := range walkStart {
-		query.clearMarks(idx, markStale)
-	}
-
-	for i := len(walkStart) - 1; i >= 0 && countStillIndependent > 1; i-- {
-		stack := []nodeIndex{walkStart[i]}
-		query.setMarks(walkStart[i], markStale)
-
-		for len(stack) > 0 {
-			top := stack[len(stack)-1]
-
-			if query.hasAnyMarks(top, markResult) {
-				query.clearMarks(top, markResult)
-
-				countStillIndependent--
-				if countStillIndependent <= 1 {
-					break
-				}
-
-				if top == sorted[minGenPos] {
-					for minGenPos < len(sorted)-1 && query.hasAnyMarks(sorted[minGenPos], markStale) {
-						minGenPos++
-					}
-
-					minGeneration = query.effectiveGeneration(sorted[minGenPos])
-				}
-			}
-
-			if query.effectiveGeneration(top) < minGeneration {
-				stack = stack[:len(stack)-1]
-
-				continue
-			}
-
-			pushed := false
-
-			for _, parent := range query.parents(top) {
-				if query.hasAnyMarks(parent, markStale) {
-					continue
-				}
-
-				query.setMarks(parent, markStale)
-				stack = append(stack, parent)
-				pushed = true
-
-				break
-			}
-
-			if !pushed {
-				stack = stack[:len(stack)-1]
-			}
-		}
-	}
-
-	out := make([]nodeIndex, 0, len(candidates))
-	for _, idx := range candidates {
-		if !query.hasAnyMarks(idx, markStale) {
-			out = append(out, idx)
-		}
-	}
-
-	query.clearTouchedMarks(markStale | markResult)
-
-	return out
-}
--- a/commitquery/reset.go
+++ /dev/null
@@ -1,37 +1,0 @@
-package commitquery
-
-import (
-	commitgraphread "codeberg.org/lindenii/furgit/format/commitgraph/read"
-	objectid "codeberg.org/lindenii/furgit/object/id"
-	objectstore "codeberg.org/lindenii/furgit/object/store"
-)
-
-type query struct {
-	store objectstore.ReadingStore
-	graph *commitgraphread.Reader
-
-	nodes []node
-
-	byOID      map[objectid.ObjectID]nodeIndex
-	byGraphPos map[commitgraphread.Position]nodeIndex
-
-	markPhase uint32
-	touched   []nodeIndex
-}
-
-func newQuery(store objectstore.ReadingStore, graph *commitgraphread.Reader) *query {
-	return &query{
-		store:      store,
-		graph:      graph,
-		byOID:      make(map[objectid.ObjectID]nodeIndex),
-		byGraphPos: make(map[commitgraphread.Position]nodeIndex),
-	}
-}
-
-func (query *query) resetForReuse() {
-	for _, idx := range query.touched {
-		query.nodes[idx].marks = 0
-	}
-
-	query.touched = query.touched[:0]
-}
--- a/commitquery/resolve.go
+++ /dev/null
@@ -1,39 +1,0 @@
-package commitquery
-
-import (
-	"codeberg.org/lindenii/furgit/internal/peel"
-	objectid "codeberg.org/lindenii/furgit/object/id"
-)
-
-func (query *query) resolveOID(id objectid.ObjectID) (nodeIndex, error) {
-	idx, ok := query.byOID[id]
-	if ok {
-		err := query.ensureLoaded(idx)
-		if err != nil {
-			return 0, err
-		}
-
-		return idx, nil
-	}
-
-	idx = query.newNode(id)
-	query.byOID[id] = idx
-
-	err := query.loadByOID(idx)
-	if err != nil {
-		delete(query.byOID, id)
-
-		return 0, err
-	}
-
-	return idx, nil
-}
-
-func (query *query) resolveCommitish(id objectid.ObjectID) (nodeIndex, error) {
-	commitID, err := peel.ToCommit(query.store, id)
-	if err != nil {
-		return 0, err
-	}
-
-	return query.resolveOID(commitID)
-}
--