306 lines
8.0 KiB
Go
306 lines
8.0 KiB
Go
// Copyright 2026 Dolthub, Inc.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
// Package commitgraph provides a lightweight topological commit graph iterator.
|
|
//
|
|
// Unlike env/actions/commitwalk, this package does NOT depend on the doltdb
|
|
// package and therefore does not transitively pull in go-mysql-server or
|
|
// go-icu-regex. It depends only on store/datas and store/hash.
|
|
//
|
|
// Consumers inside Dolt that already import doltdb should continue using
|
|
// commitwalk. This package is for lightweight consumers (such as DumboDB) that
|
|
// only need commit graph traversal without the full SQL engine dependency.
|
|
package commitgraph
|
|
|
|
import (
|
|
"container/heap"
|
|
"context"
|
|
"io"
|
|
|
|
"github.com/dolthub/dolt/go/store/datas"
|
|
"github.com/dolthub/dolt/go/store/hash"
|
|
)
|
|
|
|
// HashResolver resolves a commit hash to its CommitInfo. Implementations
|
|
// should return a CommitInfo with IsGhost=true for ghost (placeholder) commits.
|
|
type HashResolver interface {
|
|
ResolveCommitHash(ctx context.Context, h hash.Hash) (*CommitInfo, error)
|
|
}
|
|
|
|
// CommitInfo holds the commit data needed for graph traversal.
|
|
type CommitInfo struct {
|
|
Hash hash.Hash
|
|
Height uint64
|
|
Meta *datas.CommitMeta
|
|
Parents []hash.Hash // parent1, parent2, ...
|
|
IsGhost bool
|
|
}
|
|
|
|
// Iterator walks commits in reverse topological order.
|
|
type Iterator interface {
|
|
// Next returns the next commit. Returns io.EOF when exhausted.
|
|
Next(ctx context.Context) (*CommitInfo, error)
|
|
// Reset restarts the walk from the original start hashes.
|
|
Reset(ctx context.Context) error
|
|
}
|
|
|
|
// GetTopologicalOrderIterator returns an Iterator that walks every commit
|
|
// reachable from startHashes in reverse topological order (highest/newest
|
|
// first). Ties at the same height are broken by timestamp (newer first).
|
|
// Ghost commits sort before resolved commits.
|
|
//
|
|
// The optional matchFn filters commits: return true to include, false to skip.
|
|
func GetTopologicalOrderIterator(
|
|
ctx context.Context,
|
|
resolver HashResolver,
|
|
startHashes []hash.Hash,
|
|
matchFn func(*CommitInfo) (bool, error),
|
|
) (Iterator, error) {
|
|
return newTopoIter(ctx, resolver, startHashes, matchFn)
|
|
}
|
|
|
|
// GetDotDotIterator returns an Iterator that yields commits reachable from
|
|
// includedHashes but NOT reachable from excludedHashes, analogous to
|
|
// `git log excluded..included`.
|
|
func GetDotDotIterator(
|
|
ctx context.Context,
|
|
includedResolver HashResolver,
|
|
includedHashes []hash.Hash,
|
|
excludedResolver HashResolver,
|
|
excludedHashes []hash.Hash,
|
|
matchFn func(*CommitInfo) (bool, error),
|
|
) (Iterator, error) {
|
|
return newDotDotIter(ctx, includedResolver, includedHashes, excludedResolver, excludedHashes, matchFn)
|
|
}
|
|
|
|
// --- priority queue --------------------------------------------------------
|
|
|
|
type entry struct {
|
|
resolver HashResolver
|
|
info *CommitInfo
|
|
invisible bool
|
|
queued bool
|
|
}
|
|
|
|
type pq struct {
|
|
pending []*entry
|
|
numVisiblePending int
|
|
loaded map[hash.Hash]*entry
|
|
}
|
|
|
|
func newPQ() *pq { return &pq{loaded: make(map[hash.Hash]*entry)} }
|
|
|
|
func (q *pq) Len() int { return len(q.pending) }
|
|
func (q *pq) Swap(i, j int) { q.pending[i], q.pending[j] = q.pending[j], q.pending[i] }
|
|
|
|
func (q *pq) Push(x interface{}) { q.pending = append(q.pending, x.(*entry)) }
|
|
func (q *pq) Pop() interface{} {
|
|
old := q.pending
|
|
ret := old[len(old)-1]
|
|
q.pending = old[:len(old)-1]
|
|
return ret
|
|
}
|
|
|
|
func (q *pq) Less(i, j int) bool {
|
|
ei, ej := q.pending[i], q.pending[j]
|
|
if ei.info.IsGhost && !ej.info.IsGhost {
|
|
return true
|
|
}
|
|
if !ei.info.IsGhost && ej.info.IsGhost {
|
|
return false
|
|
}
|
|
if ei.info.IsGhost && ej.info.IsGhost {
|
|
return ei.info.Hash.String() < ej.info.Hash.String()
|
|
}
|
|
if ei.info.Height > ej.info.Height {
|
|
return true
|
|
}
|
|
if ei.info.Height == ej.info.Height && ei.info.Meta != nil && ej.info.Meta != nil {
|
|
return ei.info.Meta.UserTimestampMillis() > ej.info.Meta.UserTimestampMillis()
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (q *pq) get(ctx context.Context, r HashResolver, h hash.Hash) (*entry, error) {
|
|
if e, ok := q.loaded[h]; ok {
|
|
return e, nil
|
|
}
|
|
info, err := r.ResolveCommitHash(ctx, h)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
e := &entry{resolver: r, info: info}
|
|
q.loaded[h] = e
|
|
return e, nil
|
|
}
|
|
|
|
func (q *pq) addIfUnseen(ctx context.Context, r HashResolver, h hash.Hash) error {
|
|
e, err := q.get(ctx, r, h)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !e.queued {
|
|
e.queued = true
|
|
heap.Push(q, e)
|
|
if !e.invisible {
|
|
q.numVisiblePending++
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (q *pq) setInvisible(ctx context.Context, r HashResolver, h hash.Hash) error {
|
|
e, err := q.get(ctx, r, h)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !e.invisible {
|
|
e.invisible = true
|
|
if e.queued {
|
|
q.numVisiblePending--
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (q *pq) popPending() *entry {
|
|
e := heap.Pop(q).(*entry)
|
|
if !e.invisible {
|
|
q.numVisiblePending--
|
|
}
|
|
return e
|
|
}
|
|
|
|
// --- topological iterator --------------------------------------------------
|
|
|
|
type topoIter struct {
|
|
resolver HashResolver
|
|
startHashes []hash.Hash
|
|
matchFn func(*CommitInfo) (bool, error)
|
|
q *pq
|
|
}
|
|
|
|
func newTopoIter(ctx context.Context, resolver HashResolver, startHashes []hash.Hash, matchFn func(*CommitInfo) (bool, error)) (*topoIter, error) {
|
|
it := &topoIter{resolver: resolver, startHashes: startHashes, matchFn: matchFn}
|
|
if err := it.Reset(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
func (it *topoIter) Next(ctx context.Context) (*CommitInfo, error) {
|
|
for it.q.numVisiblePending > 0 {
|
|
e := it.q.popPending()
|
|
for _, ph := range e.info.Parents {
|
|
if err := it.q.addIfUnseen(ctx, e.resolver, ph); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if it.matchFn != nil {
|
|
ok, err := it.matchFn(e.info)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
}
|
|
return e.info, nil
|
|
}
|
|
return nil, io.EOF
|
|
}
|
|
|
|
func (it *topoIter) Reset(ctx context.Context) error {
|
|
it.q = newPQ()
|
|
for _, h := range it.startHashes {
|
|
if err := it.q.addIfUnseen(ctx, it.resolver, h); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- dot-dot iterator ------------------------------------------------------
|
|
|
|
type dotDotIter struct {
|
|
includedResolver HashResolver
|
|
excludedResolver HashResolver
|
|
startHashes []hash.Hash
|
|
excludedHashes []hash.Hash
|
|
matchFn func(*CommitInfo) (bool, error)
|
|
q *pq
|
|
}
|
|
|
|
func newDotDotIter(ctx context.Context, inclR HashResolver, inclH []hash.Hash, exclR HashResolver, exclH []hash.Hash, matchFn func(*CommitInfo) (bool, error)) (*dotDotIter, error) {
|
|
it := &dotDotIter{
|
|
includedResolver: inclR,
|
|
excludedResolver: exclR,
|
|
startHashes: inclH,
|
|
excludedHashes: exclH,
|
|
matchFn: matchFn,
|
|
}
|
|
if err := it.Reset(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
func (it *dotDotIter) Next(ctx context.Context) (*CommitInfo, error) {
|
|
for it.q.numVisiblePending > 0 {
|
|
e := it.q.popPending()
|
|
for _, ph := range e.info.Parents {
|
|
if e.invisible {
|
|
if err := it.q.setInvisible(ctx, e.resolver, ph); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := it.q.addIfUnseen(ctx, e.resolver, ph); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if e.invisible {
|
|
continue
|
|
}
|
|
if it.matchFn != nil {
|
|
ok, err := it.matchFn(e.info)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
}
|
|
return e.info, nil
|
|
}
|
|
return nil, io.EOF
|
|
}
|
|
|
|
func (it *dotDotIter) Reset(ctx context.Context) error {
|
|
it.q = newPQ()
|
|
for _, h := range it.excludedHashes {
|
|
if err := it.q.setInvisible(ctx, it.excludedResolver, h); err != nil {
|
|
return err
|
|
}
|
|
if err := it.q.addIfUnseen(ctx, it.excludedResolver, h); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, h := range it.startHashes {
|
|
if err := it.q.addIfUnseen(ctx, it.includedResolver, h); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|