package gitx
import (
"context"
"fmt"
"time"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/storer"
"go.bigb.es/sourcehut-compare/core"
)
// CommitInfo is the metadata of a single commit.
type CommitInfo struct {
SHA string
ShortSHA string
AuthorName string
AuthorEmail string
Date time.Time
Subject string
Body string
// ParentSHAs lists the commit's parents in order. Empty for a root commit;
// length > 1 marks a merge commit.
ParentSHAs []string
}
// resolveCommit validates rev, resolves it to a commit, and returns the go-git
// object. A syntactically valid but unresolvable revision yields core.ErrBadRef.
func (r *Repo) resolveCommit(rev string) (*object.Commit, error) {
if !core.ValidRef(rev) {
return nil, fmt.Errorf("%w: %q", core.ErrBadRef, rev)
}
hash, err := r.repo.ResolveRevision(plumbing.Revision(rev))
if err != nil {
return nil, badRef(rev, err)
}
c, err := r.repo.CommitObject(*hash)
if err != nil {
return nil, badRef(rev, err)
}
return c, nil
}
// ResolveCommit resolves an arbitrary revision (branch, tag, short or full SHA)
// to a commit and returns its metadata.
func (r *Repo) ResolveCommit(ctx context.Context, rev string) (*CommitInfo, error) {
_, cancel := r.withTimeout(ctx)
defer cancel()
c, err := r.resolveCommit(rev)
if err != nil {
return nil, err
}
return commitInfo(c), nil
}
// Log returns up to limit commits in the range base..head (reachable from head
// but not from base), newest first. A non-positive limit defaults to 50.
func (r *Repo) Log(ctx context.Context, base, head string, limit int) ([]CommitInfo, error) {
if limit <= 0 {
limit = 50
}
_, cancel := r.withTimeout(ctx)
defer cancel()
baseCommit, err := r.resolveCommit(base)
if err != nil {
return nil, err
}
headCommit, err := r.resolveCommit(head)
if err != nil {
return nil, err
}
// Exclude everything reachable from base so a shared ancestor reached via a
// non-base path is still dropped — matching git's base..head semantics.
excluded, err := reachableSet(baseCommit)
if err != nil {
return nil, err
}
var out []CommitInfo
iter := object.NewCommitPreorderIter(headCommit, excluded, nil)
err = iter.ForEach(func(c *object.Commit) error {
if len(out) >= limit {
return storer.ErrStop
}
out = append(out, *commitInfo(c))
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// Parents returns the parent SHAs of a commit in order. A root commit yields an
// empty slice; length > 1 marks a merge commit.
func (r *Repo) Parents(ctx context.Context, rev string) ([]string, error) {
_, cancel := r.withTimeout(ctx)
defer cancel()
c, err := r.resolveCommit(rev)
if err != nil {
return nil, err
}
if len(c.ParentHashes) == 0 {
return nil, nil
}
parents := make([]string, 0, len(c.ParentHashes))
for _, p := range c.ParentHashes {
parents = append(parents, p.String())
}
return parents, nil
}
// reachableSet returns the set of every commit hash reachable from c
// (inclusive).
func reachableSet(c *object.Commit) (map[plumbing.Hash]bool, error) {
set := make(map[plumbing.Hash]bool)
iter := object.NewCommitPreorderIter(c, nil, nil)
err := iter.ForEach(func(x *object.Commit) error {
set[x.Hash] = true
return nil
})
if err != nil {
return nil, err
}
return set, nil
}