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 } // DeleteProposalBranch removes a proposal branch, under the space write lock so // it excludes the merge path and any other in-process write. // // It refuses anything outside proposals/*. That check is not defence in depth, // it is the only thing standing between a caller bug and a deleted approved // branch — the approved branch is deleted by nobody, ever, which is also what // CheckRefUpdate tells receive-pack. // // An already-absent branch is success rather than ErrNotFound. The caller is // the reconciler, whose repair is a postcondition ("this unreferenced ref does // not exist") and not an action, and the ref can legitimately vanish between // the listing that found it and this call — a native receive-pack push deleting // it, or an earlier pass that raced this one. Reporting that as a failure would // fill the reconcile report with failures for repairs that in fact hold. // // There is no compare-and-swap here and no lost-race retry: unlike a ref move, // a delete does not depend on the value it is replacing, so a concurrent writer // cannot make it do the wrong thing. It can only make it redundant, which is // the case above. func (r *Repo) DeleteProposalBranch(ctx context.Context, branch string) error { ctx, cancel := r.withTimeout(ctx) defer cancel() if !IsProposalBranch(branch) { return fmt.Errorf("%w: %q is not a %s* branch; only a proposal branch may be deleted", ErrBadRev, branch, ProposalPrefix) } unlock, err := r.lock(ctx) if err != nil { return err } defer unlock() name := plumbing.NewBranchReferenceName(branch) if err := r.repo.Storer.RemoveReference(name); err != nil { return fmt.Errorf("gitx: delete %s in %s: %w", name, r.ref, err) } return 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) }