package gitx
import (
"context"
"errors"
"fmt"
"io"
"sort"
"strings"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// Document is one markdown document as it exists at a revision: its path in the
// tree, the sha of its blob, and the blob's bytes.
//
// Blob is the render cache key. It is content-addressed, so a cache entry keyed
// by it can never go stale and the cache can be dropped at any moment.
type Document struct {
Path string
Blob plumbing.Hash
Data []byte
}
// docEntry is a document located in a tree, before its blob is read.
type docEntry struct {
path string
hash plumbing.Hash
}
// ValidateRev checks a revision string before it reaches go-git's parser.
//
// What is accepted is a ref name or a full-or-abbreviated hex object id. What
// is rejected is revision arithmetic — "main^2", "HEAD~3", "main@{yesterday}" —
// because the read contract is a pinned immutable ?rev= or a branch, and every
// extra accepted spelling is one more thing the review UI, the index stamp and
// the agent's If-Match have to agree about.
//
// It deliberately does not try to tell an object id from a ref name. The two
// grammars overlap ("cafe" is both a plausible abbreviation and a perfectly
// legal branch name), so imposing a minimum length on "things that look hex"
// would reject real branches. Resolution decides which one it is; this function
// only decides whether it could be either.
func ValidateRev(rev string) error {
if rev == "" {
return fmt.Errorf("%w: empty revision", ErrBadRev)
}
if len(rev) > maxRevLen {
return fmt.Errorf("%w: revision is too long (%d > %d)", ErrBadRev, len(rev), maxRevLen)
}
if rev == "HEAD" {
return nil
}
return validateRefComponent("revision", rev)
}
// ResolveRev resolves a revision to the commit it names. Anything that is not a
// commit in this repository — a tree sha, an unknown branch, a truncated id —
// is ErrNotFound; anything that is not a usable revision string at all is
// ErrBadRev.
func (r *Repo) ResolveRev(ctx context.Context, rev string) (plumbing.Hash, error) {
_, cancel := r.withTimeout(ctx)
defer cancel()
if err := ValidateRev(rev); err != nil {
return plumbing.ZeroHash, err
}
h, err := r.repo.ResolveRevision(plumbing.Revision(rev))
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("%w: revision %q in %s: %v", ErrNotFound, rev, r.ref, err)
}
if _, err := r.repo.CommitObject(*h); err != nil {
return plumbing.ZeroHash, fmt.Errorf("%w: revision %q in %s does not name a commit: %v",
ErrNotFound, rev, r.ref, err)
}
return *h, nil
}
// ApprovedHead returns the current tip of the approved branch. This is the
// value an agent's If-Match carries and the value a stale merge reports back.
func (r *Repo) ApprovedHead(ctx context.Context) (plumbing.Hash, error) {
return r.BranchHead(ctx, r.approved)
}
// BranchHead returns the tip of a branch. A branch that does not exist is
// ErrNotFound.
func (r *Repo) BranchHead(ctx context.Context, branch string) (plumbing.Hash, error) {
_, cancel := r.withTimeout(ctx)
defer cancel()
if err := ValidateBranch(branch); err != nil {
return plumbing.ZeroHash, err
}
ref, err := r.repo.Reference(plumbing.NewBranchReferenceName(branch), true)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("%w: branch %q in %s: %v", ErrNotFound, branch, r.ref, err)
}
return ref.Hash(), nil
}
// IsAncestor reports whether a is reachable from b. It is what the service uses
// to decide an If-Match is still valid, and what the update hook uses to tell a
// fast-forward from a force-update before calling CheckRefUpdate.
func (r *Repo) IsAncestor(ctx context.Context, a, b plumbing.Hash) (bool, error) {
_, cancel := r.withTimeout(ctx)
defer cancel()
if a == b {
return true, nil
}
ca, err := r.repo.CommitObject(a)
if err != nil {
return false, fmt.Errorf("%w: commit %s in %s: %v", ErrNotFound, a, r.ref, err)
}
cb, err := r.repo.CommitObject(b)
if err != nil {
return false, fmt.Errorf("%w: commit %s in %s: %v", ErrNotFound, b, r.ref, err)
}
return ca.IsAncestor(cb)
}
// ListProposalBranches returns every proposals/* branch with its tip, sorted by
// name. Refs are the source of truth for whether a proposal exists, so this is
// what the reconciler scans to rebuild rows it lost.
func (r *Repo) ListProposalBranches(ctx context.Context) ([]Branch, error) {
_, cancel := r.withTimeout(ctx)
defer cancel()
iter, err := r.repo.Branches()
if err != nil {
return nil, fmt.Errorf("gitx: list branches of %s: %w", r.ref, err)
}
var out []Branch
err = iter.ForEach(func(ref *plumbing.Reference) error {
name := ref.Name().Short()
if !IsProposalBranch(name) {
return nil
}
out = append(out, Branch{Name: name, Head: ref.Hash()})
return nil
})
if err != nil {
return nil, fmt.Errorf("gitx: list branches of %s: %w", r.ref, err)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
// Branch is a branch name paired with its tip.
type Branch struct {
Name string
Head plumbing.Hash
}
// treeAt resolves a revision to its commit's tree.
func (r *Repo) treeAt(ctx context.Context, rev string) (*object.Tree, error) {
h, err := r.ResolveRev(ctx, rev)
if err != nil {
return nil, err
}
return r.treeOf(h)
}
// treeOf returns the tree of a commit already resolved to a hash.
func (r *Repo) treeOf(commit plumbing.Hash) (*object.Tree, error) {
c, err := r.repo.CommitObject(commit)
if err != nil {
return nil, fmt.Errorf("%w: commit %s in %s: %v", ErrNotFound, commit, r.ref, err)
}
t, err := c.Tree()
if err != nil {
return nil, fmt.Errorf("gitx: tree of %s in %s: %w", commit, r.ref, err)
}
return t, nil
}
// walkBudget tracks the per-walk entry and byte caps.
type walkBudget struct {
entries int
maxEntries int
bytes int64
maxBytes int64
}
func (b *walkBudget) entry(path string) error {
b.entries++
if b.entries > b.maxEntries {
return fmt.Errorf("%w: tree has more than %d entries (at %q)", ErrTooLarge, b.maxEntries, path)
}
return nil
}
func (b *walkBudget) read(path string, n int64) error {
b.bytes += n
if b.bytes > b.maxBytes {
return fmt.Errorf("%w: walk exceeded %d bytes (at %q)", ErrTooLarge, b.maxBytes, path)
}
return nil
}
func (r *Repo) newBudget() *walkBudget {
return &walkBudget{maxEntries: r.entryCap(), maxBytes: r.totalLimit()}
}
// collectDocs lists every markdown document in a tree, depth-first and in tree
// order, without reading any blob.
//
// Entries that are not documents — attachments, .spec.yml, anything without the
// .md extension — are skipped, because they are legitimately not documents. An
// entry that occupies a document path but cannot be one is an error, not a
// skip: a symlinked or submoduled *.md would otherwise vanish from the index
// and the merge with nothing recording that it was ever there.
func (r *Repo) collectDocs(ctx context.Context, t *object.Tree, prefix string, depth int, b *walkBudget, out *[]docEntry) error {
if err := ctx.Err(); err != nil {
return err
}
if depth > maxTreeDepth {
return fmt.Errorf("%w: tree nesting deeper than %d at %q", ErrTooLarge, maxTreeDepth, prefix)
}
for _, e := range t.Entries {
path := e.Name
if prefix != "" {
path = prefix + "/" + e.Name
}
if err := b.entry(path); err != nil {
return err
}
switch e.Mode {
case filemode.Dir:
sub, err := object.GetTree(r.repo.Storer, e.Hash)
if err != nil {
return fmt.Errorf("gitx: read tree %s at %q in %s: %w", e.Hash, path, r.ref, err)
}
if err := r.collectDocs(ctx, sub, path, depth+1, b, out); err != nil {
return err
}
case filemode.Regular, filemode.Executable:
if !strings.HasSuffix(e.Name, core.DocExt) {
continue // an attachment, or .spec.yml
}
if err := core.ValidateDocPath(path); err != nil {
return fmt.Errorf("gitx: %s carries an unusable document path: %w", r.ref, err)
}
*out = append(*out, docEntry{path: path, hash: e.Hash})
default:
if strings.HasSuffix(e.Name, core.DocExt) {
return fmt.Errorf("%w: %q in %s is a %s, not a document blob",
ErrUnsupportedEntry, path, r.ref, e.Mode)
}
}
}
return nil
}
// readBlob reads a blob, refusing anything over the per-blob cap. The cap is
// checked against the object header first so an oversized blob is never
// materialized, and again against what was actually read so a lying header
// cannot get past it.
func (r *Repo) readBlob(h plumbing.Hash, path string) ([]byte, error) {
obj, err := r.repo.Storer.EncodedObject(plumbing.BlobObject, h)
if err != nil {
return nil, fmt.Errorf("%w: blob %s at %q in %s: %v", ErrNotFound, h, path, r.ref, err)
}
limit := r.blobLimit()
if obj.Size() > limit {
return nil, fmt.Errorf("%w: %q in %s is %d bytes (limit %d)",
ErrTooLarge, path, r.ref, obj.Size(), limit)
}
rd, err := obj.Reader()
if err != nil {
return nil, fmt.Errorf("gitx: read blob %s at %q in %s: %w", h, path, r.ref, err)
}
defer rd.Close()
data, err := io.ReadAll(io.LimitReader(rd, limit+1))
if err != nil {
return nil, fmt.Errorf("gitx: read blob %s at %q in %s: %w", h, path, r.ref, err)
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("%w: %q in %s exceeds %d bytes", ErrTooLarge, path, r.ref, limit)
}
return data, nil
}
// WalkDocuments calls fn for every markdown document at rev, in tree order.
// This is the seam that replaces warren's filesystem scan: the caller feeds the
// yielded documents to vault.FromPages and nothing downstream of Archive
// changes.
//
// fn's error stops the walk and is returned unwrapped, so a caller can use a
// sentinel of its own to stop early.
func (r *Repo) WalkDocuments(ctx context.Context, rev string, fn func(Document) error) error {
ctx, cancel := r.withTimeout(ctx)
defer cancel()
t, err := r.treeAt(ctx, rev)
if err != nil {
return err
}
return r.walkTreeDocs(ctx, t, fn)
}
func (r *Repo) walkTreeDocs(ctx context.Context, t *object.Tree, fn func(Document) error) error {
budget := r.newBudget()
var entries []docEntry
if err := r.collectDocs(ctx, t, "", 0, budget, &entries); err != nil {
return err
}
for _, e := range entries {
if err := ctx.Err(); err != nil {
return err
}
data, err := r.readBlob(e.hash, e.path)
if err != nil {
return err
}
if err := budget.read(e.path, int64(len(data))); err != nil {
return err
}
if err := fn(Document{Path: e.path, Blob: e.hash, Data: data}); err != nil {
return err
}
}
return nil
}
// ListDocuments returns every markdown document at rev. It is WalkDocuments
// with the collection done for you; prefer WalkDocuments when the caller can
// stream.
func (r *Repo) ListDocuments(ctx context.Context, rev string) ([]Document, error) {
var docs []Document
if err := r.WalkDocuments(ctx, rev, func(d Document) error {
docs = append(docs, d)
return nil
}); err != nil {
return nil, err
}
return docs, nil
}
// ReadDocument reads one markdown document by path at a revision. The path must
// be a valid document path; a path that exists but is not a document blob is
// ErrUnsupportedEntry rather than a silent miss.
func (r *Repo) ReadDocument(ctx context.Context, rev, path string) (Document, error) {
if err := core.ValidateDocPath(path); err != nil {
return Document{}, err
}
data, hash, err := r.ReadBlob(ctx, rev, path)
if err != nil {
return Document{}, err
}
return Document{Path: path, Blob: hash, Data: data}, nil
}
// ReadBlob reads any blob by path at a revision — a document, .spec.yml, or an
// attachment — and returns its bytes and sha. Directories and non-blob entries
// are refused rather than reported as missing, because "you asked for a file
// and that is a directory" is a different bug from "it is not there".
func (r *Repo) ReadBlob(ctx context.Context, rev, path string) ([]byte, plumbing.Hash, error) {
ctx, cancel := r.withTimeout(ctx)
defer cancel()
if err := core.ValidatePath(path); err != nil {
return nil, plumbing.ZeroHash, err
}
t, err := r.treeAt(ctx, rev)
if err != nil {
return nil, plumbing.ZeroHash, err
}
entry, err := t.FindEntry(path)
if err != nil {
if errors.Is(err, object.ErrEntryNotFound) || errors.Is(err, object.ErrDirectoryNotFound) {
return nil, plumbing.ZeroHash, fmt.Errorf("%w: %q at %q in %s", ErrNotFound, path, rev, r.ref)
}
return nil, plumbing.ZeroHash, fmt.Errorf("gitx: find %q at %q in %s: %w", path, rev, r.ref, err)
}
switch entry.Mode {
case filemode.Regular, filemode.Executable:
default:
return nil, plumbing.ZeroHash, fmt.Errorf("%w: %q at %q in %s is a %s",
ErrUnsupportedEntry, path, rev, r.ref, entry.Mode)
}
data, err := r.readBlob(entry.Hash, path)
if err != nil {
return nil, plumbing.ZeroHash, err
}
return data, entry.Hash, nil
}