package gitx
import (
"context"
"errors"
"fmt"
"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"
"github.com/go-git/go-git/v5/plumbing/storer"
"github.com/go-git/go-git/v5/storage"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// Trailer is one git trailer line, "Key: Value".
//
// This package renders trailers; it does not decide which ones exist. Which
// keys are required, what an agent identity string looks like and what goes in
// X-Agent-Session are authn/'s to own — putting that policy here would give the
// git layer an opinion about identity and give the two write surfaces two
// places to drift apart. What is enforced here is only that the rendered
// message cannot be forged: a value carrying a newline could otherwise
// manufacture trailers nobody supplied.
type Trailer struct {
Key string
Value string
}
func (t Trailer) validate() error {
if t.Key == "" {
return fmt.Errorf("gitx: trailer key is required")
}
for i := 0; i < len(t.Key); i++ {
c := t.Key[i]
ok := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-'
if !ok {
return fmt.Errorf("gitx: trailer key %q contains a disallowed byte %q", t.Key, c)
}
}
if strings.ContainsAny(t.Value, "\n\r\x00") {
return fmt.Errorf("gitx: trailer %q value must be a single line", t.Key)
}
return nil
}
// CommitMeta is everything a commit records besides its tree and parents. The
// caller supplies all of it: provenance is the product here, so nothing about
// authorship is defaulted or derived.
type CommitMeta struct {
// Message is the commit subject and body, without a trailer block.
Message string
// Trailers are appended after a blank line, in order. Provenance lives here
// rather than in a Postgres-only audit table so it is visible in plain
// git log on any clone and cannot drift from the content it describes.
Trailers []Trailer
// Author is who wrote the change — for an agent commit, the agent. Committer
// is who applied it, which is the service acting for the owner.
Author Signature
Committer Signature
}
func (m CommitMeta) validate() error {
if strings.TrimSpace(m.Message) == "" {
return fmt.Errorf("gitx: commit message is required")
}
if strings.TrimSpace(strings.SplitN(m.Message, "\n", 2)[0]) == "" {
return fmt.Errorf("gitx: commit message must open with a non-empty subject line")
}
if err := m.Author.validate("author"); err != nil {
return err
}
if err := m.Committer.validate("committer"); err != nil {
return err
}
for _, t := range m.Trailers {
if err := t.validate(); err != nil {
return err
}
}
return nil
}
// text renders the full commit message: the body, then a blank line, then the
// trailer block, then a trailing newline.
func (m CommitMeta) text() string {
var b strings.Builder
b.WriteString(strings.TrimRight(strings.ReplaceAll(m.Message, "\r\n", "\n"), "\n"))
if len(m.Trailers) > 0 {
b.WriteString("\n\n")
for i, t := range m.Trailers {
if i > 0 {
b.WriteByte('\n')
}
b.WriteString(t.Key)
b.WriteString(": ")
b.WriteString(t.Value)
}
}
b.WriteByte('\n')
return b.String()
}
// Write is a whole-document replacement at a path. There is no patch form: the
// write plane takes whole documents because that is how agents work, and it is
// what makes the merge model pure plumbing.
type Write struct {
Path string
Content []byte
}
// CommitResult describes a commit this package created.
type CommitResult struct {
Commit plumbing.Hash
Tree plumbing.Hash
Parents []plumbing.Hash
// Blobs maps each written path to its blob sha — the render cache key for
// the content that was just committed.
Blobs map[string]plumbing.Hash
}
// mutableTree is a tree being built: subdirectories by name, plus the non-
// directory entries at this level. Trees are loaded whole and rewritten whole,
// which at this service's volume (tens of documents a day) costs a handful of
// tree-object reads and removes every incremental-rewrite bug class.
type mutableTree struct {
subs map[string]*mutableTree
files map[string]object.TreeEntry
}
func newMutableTree() *mutableTree {
return &mutableTree{subs: map[string]*mutableTree{}, files: map[string]object.TreeEntry{}}
}
// loadTree reads an existing tree into a mutableTree, recursively.
func (r *Repo) loadTree(t *object.Tree, depth int) (*mutableTree, error) {
if depth > maxTreeDepth {
return nil, fmt.Errorf("%w: tree nesting deeper than %d", ErrTooLarge, maxTreeDepth)
}
n := newMutableTree()
for _, e := range t.Entries {
if e.Mode == filemode.Dir {
sub, err := object.GetTree(r.repo.Storer, e.Hash)
if err != nil {
return nil, fmt.Errorf("gitx: read tree %s in %s: %w", e.Hash, r.ref, err)
}
child, err := r.loadTree(sub, depth+1)
if err != nil {
return nil, err
}
n.subs[e.Name] = child
continue
}
n.files[e.Name] = e
}
return n, nil
}
// set places a blob at path, creating intermediate trees. A component that
// collides with an existing file, or a path whose final component is an
// existing directory, is an error: silently shadowing one would replace a
// document with something that is not one.
func (n *mutableTree) set(path string, hash plumbing.Hash) error {
comps := strings.Split(path, "/")
cur := n
for i, comp := range comps[:len(comps)-1] {
if _, clash := cur.files[comp]; clash {
return fmt.Errorf("gitx: cannot write %q: %q is a file", path, strings.Join(comps[:i+1], "/"))
}
next, ok := cur.subs[comp]
if !ok {
next = newMutableTree()
cur.subs[comp] = next
}
cur = next
}
last := comps[len(comps)-1]
if _, clash := cur.subs[last]; clash {
return fmt.Errorf("gitx: cannot write %q: it is a directory", path)
}
cur.files[last] = object.TreeEntry{Name: last, Mode: filemode.Regular, Hash: hash}
return nil
}
// remove deletes the blob at path if present, pruning nothing else. It reports
// whether anything was removed.
func (n *mutableTree) remove(path string) bool {
comps := strings.Split(path, "/")
cur := n
for _, comp := range comps[:len(comps)-1] {
next, ok := cur.subs[comp]
if !ok {
return false
}
cur = next
}
last := comps[len(comps)-1]
if _, ok := cur.files[last]; !ok {
return false
}
delete(cur.files, last)
return true
}
// empty reports whether the tree would encode to nothing. Git has no
// representation for an empty subtree, so those are dropped on write.
func (n *mutableTree) empty() bool {
if len(n.files) > 0 {
return false
}
for _, sub := range n.subs {
if !sub.empty() {
return false
}
}
return true
}
// write encodes the tree and every non-empty subtree, returning the root hash.
func (n *mutableTree) write(store storer.EncodedObjectStorer) (plumbing.Hash, error) {
entries := make([]object.TreeEntry, 0, len(n.files)+len(n.subs))
for name, e := range n.files {
e.Name = name
entries = append(entries, e)
}
for name, sub := range n.subs {
if sub.empty() {
continue
}
h, err := sub.write(store)
if err != nil {
return plumbing.ZeroHash, err
}
entries = append(entries, object.TreeEntry{Name: name, Mode: filemode.Dir, Hash: h})
}
// Encode refuses unsorted entries, and git compares directory names as if
// they carried a trailing slash — TreeEntrySorter is that comparison.
sort.Sort(object.TreeEntrySorter(entries))
t := &object.Tree{Entries: entries}
obj := store.NewEncodedObject()
if err := t.Encode(obj); err != nil {
return plumbing.ZeroHash, fmt.Errorf("gitx: encode tree: %w", err)
}
h, err := store.SetEncodedObject(obj)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("gitx: store tree: %w", err)
}
return h, nil
}
// writeBlob stores content as a blob, refusing anything over the document cap.
func (r *Repo) writeBlob(path string, content []byte) (plumbing.Hash, error) {
if limit := r.blobLimit(); int64(len(content)) > limit {
return plumbing.ZeroHash, fmt.Errorf("%w: %q is %d bytes (limit %d)",
ErrTooLarge, path, len(content), limit)
}
obj := r.repo.Storer.NewEncodedObject()
obj.SetType(plumbing.BlobObject)
obj.SetSize(int64(len(content)))
w, err := obj.Writer()
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("gitx: write blob for %q: %w", path, err)
}
if _, err := w.Write(content); err != nil {
w.Close()
return plumbing.ZeroHash, fmt.Errorf("gitx: write blob for %q: %w", path, err)
}
if err := w.Close(); err != nil {
return plumbing.ZeroHash, fmt.Errorf("gitx: write blob for %q: %w", path, err)
}
h, err := r.repo.Storer.SetEncodedObject(obj)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("gitx: store blob for %q: %w", path, err)
}
return h, nil
}
// writeCommit stores a commit object. Parents are written in the order given,
// which is load-bearing for a merge: the first parent is the approved head.
func (r *Repo) writeCommit(meta CommitMeta, tree plumbing.Hash, parents []plumbing.Hash) (plumbing.Hash, error) {
if err := meta.validate(); err != nil {
return plumbing.ZeroHash, err
}
c := &object.Commit{
Author: meta.Author.toGit(),
Committer: meta.Committer.toGit(),
Message: meta.text(),
TreeHash: tree,
ParentHashes: parents,
}
obj := r.repo.Storer.NewEncodedObject()
if err := c.Encode(obj); err != nil {
return plumbing.ZeroHash, fmt.Errorf("gitx: encode commit: %w", err)
}
h, err := r.repo.Storer.SetEncodedObject(obj)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("gitx: store commit: %w", err)
}
return h, nil
}
// CreateProposalBranch cuts a new proposal branch at base.
//
// base is the agent's If-Match value: the space's approved-head sha at the time
// it read. Whether that value is still an ancestor of the approved head is the
// caller's 409 to raise (Repo.IsAncestor answers it); this function only cuts
// the branch, because the same check has to be spelled identically for REST and
// MCP and so belongs above the git layer.
func (r *Repo) CreateProposalBranch(ctx context.Context, branch, base string) (plumbing.Hash, error) {
ctx, cancel := r.withTimeout(ctx)
defer cancel()
if !IsProposalBranch(branch) {
return plumbing.ZeroHash, fmt.Errorf("%w: %q is not a %s* branch", ErrBadRev, branch, ProposalPrefix)
}
head, err := r.ResolveRev(ctx, base)
if err != nil {
return plumbing.ZeroHash, err
}
unlock, err := r.lock(ctx)
if err != nil {
return plumbing.ZeroHash, err
}
defer unlock()
name := plumbing.NewBranchReferenceName(branch)
if _, err := r.repo.Reference(name, false); err == nil {
return plumbing.ZeroHash, fmt.Errorf("%w: branch %q in %s", ErrExists, branch, r.ref)
} else if !errors.Is(err, plumbing.ErrReferenceNotFound) {
return plumbing.ZeroHash, fmt.Errorf("gitx: read %s in %s: %w", name, r.ref, err)
}
if err := r.repo.Storer.SetReference(plumbing.NewHashReference(name, head)); err != nil {
return plumbing.ZeroHash, fmt.Errorf("gitx: create %s in %s: %w", name, r.ref, err)
}
return head, nil
}
// CommitProposal commits whole-document blobs onto a proposal branch.
//
// It refuses any branch outside proposals/*: the approved branch moves in
// exactly two ways — a human push through receive-pack, or Merge — and a third
// door into it would be a way to land unreviewed agent output without a merge
// commit recording that it happened.
//
// The branch head is read, spliced and compare-and-swapped under the space
// lock, and the whole build is retried if the swap loses to a concurrent
// writer.
func (r *Repo) CommitProposal(ctx context.Context, branch string, writes []Write, meta CommitMeta) (CommitResult, error) {
ctx, cancel := r.withTimeout(ctx)
defer cancel()
if !IsProposalBranch(branch) {
return CommitResult{}, fmt.Errorf("%w: %q is not a %s* branch; only Merge writes the approved branch",
ErrBadRev, branch, ProposalPrefix)
}
if len(writes) == 0 {
return CommitResult{}, fmt.Errorf("gitx: commit to %q has no writes", branch)
}
if err := meta.validate(); err != nil {
return CommitResult{}, err
}
seen := make(map[string]bool, len(writes))
for _, w := range writes {
if err := core.ValidateDocPath(w.Path); err != nil {
return CommitResult{}, err
}
if seen[w.Path] {
return CommitResult{}, fmt.Errorf("gitx: commit to %q writes %q twice", branch, w.Path)
}
seen[w.Path] = true
}
unlock, err := r.lock(ctx)
if err != nil {
return CommitResult{}, err
}
defer unlock()
name := plumbing.NewBranchReferenceName(branch)
var lastErr error
for attempt := 0; attempt < r.casBudget(); attempt++ {
if err := ctx.Err(); err != nil {
return CommitResult{}, err
}
old, err := r.repo.Reference(name, false)
if err != nil {
return CommitResult{}, fmt.Errorf("%w: branch %q in %s: %v", ErrNotFound, branch, r.ref, err)
}
tree, err := r.treeOf(old.Hash())
if err != nil {
return CommitResult{}, err
}
node, err := r.loadTree(tree, 0)
if err != nil {
return CommitResult{}, err
}
blobs := make(map[string]plumbing.Hash, len(writes))
for _, w := range writes {
h, err := r.writeBlob(w.Path, w.Content)
if err != nil {
return CommitResult{}, err
}
if err := node.set(w.Path, h); err != nil {
return CommitResult{}, err
}
blobs[w.Path] = h
}
treeHash, err := node.write(r.repo.Storer)
if err != nil {
return CommitResult{}, err
}
parents := []plumbing.Hash{old.Hash()}
commit, err := r.writeCommit(meta, treeHash, parents)
if err != nil {
return CommitResult{}, err
}
r.raceHook()
err = r.repo.Storer.CheckAndSetReference(plumbing.NewHashReference(name, commit), old)
if err == nil {
return CommitResult{Commit: commit, Tree: treeHash, Parents: parents, Blobs: blobs}, nil
}
if !errors.Is(err, storage.ErrReferenceHasChanged) {
return CommitResult{}, fmt.Errorf("gitx: update %s in %s: %w", name, r.ref, err)
}
lastErr = err
}
return CommitResult{}, fmt.Errorf("%w: %s in %s after %d attempts: %v",
ErrRefRace, name, r.ref, r.casBudget(), lastErr)
}