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 }