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/storage"
"github.com/go-git/go-git/v5/utils/merkletrie"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// MergeRequest asks for a proposal branch to be spliced onto the approved head.
type MergeRequest struct {
// Branch is the proposal branch, "proposals/42".
Branch string
// Base is the proposal's base revision B: the approved-head sha the agent
// held when it opened the proposal. It does not move as the proposal
// accumulates edits, and it is what staleness is measured against.
//
// It is required rather than derived from a merge base. The caller has it
// (it is the recorded base_rev, the same value the agent keeps sending as
// If-Match), and inferring it would let a rewritten approved branch quietly
// change which revision the merge believes it was proposed against.
Base string
// Meta is the merge commit's message, trailers and identities.
Meta CommitMeta
}
// MergedDoc records what the merge did with one document.
type MergedDoc struct {
// DocID is the document's frontmatter id — the key the whole merge turns
// on, since paths move and ids do not.
DocID string
// Path is where the blob landed in the new approved tree.
Path string
// ProposalPath is where the proposal held it. It differs from Path exactly
// when the approved branch moved the document between the base and the
// head, which is the case that must not become a conflict.
ProposalPath string
// Blob is the document's blob sha, taken unchanged from the proposal.
Blob plumbing.Hash
// New is set when the document did not exist on the approved head.
New bool
}
// Renamed reports whether the approved branch had moved this document under the
// proposal, so the proposal's blob followed the document to its new path.
func (d MergedDoc) Renamed() bool { return !d.New && d.Path != d.ProposalPath }
// MergeResult describes a completed merge.
type MergeResult struct {
// Commit is the merge commit and the new approved head.
Commit plumbing.Hash
Tree plumbing.Hash
// ApprovedHead is the head this merged onto — the first parent.
ApprovedHead plumbing.Hash
// ProposalHead is the proposal branch tip — the second parent.
ProposalHead plumbing.Hash
// Docs is every document the merge carried over, sorted by path.
Docs []MergedDoc
}
// Merge splices a proposal onto the approved head and moves the approved branch
// to the resulting two-parent merge commit.
//
// The model, exactly as designed:
//
// for d in F: # F is document ids, not paths
// if blob(path(d)@H) != blob(path(d)@B): # changed under us since B
// return 409 stale
// newTree = tree(H) with each d's blob replaced (at its path in H)
// commit newTree with parents [H, P.head]
//
// Two things about it are load-bearing and easy to get wrong:
//
// - Each changed document is resolved to its path *on the approved head*
// through its frontmatter id before its blob is compared. A rename between
// B and H therefore neither raises a conflict nor resurrects the document
// at the path it was moved away from: the proposal's blob is written at the
// head's path, and the old path is never touched.
// - There is no text merge. go-git v5 supports only FastForwardMerge, and the
// whole-document write grain makes three-way merging unnecessary anyway.
// A conflict is always "your base moved, re-propose" (*StaleError), which
// is trivial for an agent and comprehensible for a human.
//
// The build runs under the space write lock and ends in a compare-and-swap on
// the approved ref. Losing that swap means a human push landed underneath, so
// the merge is rebuilt against the new head rather than failing — up to a
// bounded number of attempts, after which it is ErrRefRace and nothing moved.
func (r *Repo) Merge(ctx context.Context, req MergeRequest) (MergeResult, error) {
ctx, cancel := r.withTimeout(ctx)
defer cancel()
if !IsProposalBranch(req.Branch) {
return MergeResult{}, fmt.Errorf("%w: %q is not a %s* branch", ErrBadRev, req.Branch, ProposalPrefix)
}
if req.Base == "" {
return MergeResult{}, fmt.Errorf("gitx: merge of %q needs the proposal's base revision", req.Branch)
}
if err := req.Meta.validate(); err != nil {
return MergeResult{}, err
}
unlock, err := r.lock(ctx)
if err != nil {
return MergeResult{}, err
}
defer unlock()
approvedRef := plumbing.NewBranchReferenceName(r.approved)
var lastErr error
for attempt := 0; attempt < r.casBudget(); attempt++ {
if err := ctx.Err(); err != nil {
return MergeResult{}, err
}
old, err := r.repo.Reference(approvedRef, false)
if err != nil {
return MergeResult{}, fmt.Errorf("%w: approved branch %q in %s: %v",
ErrNotFound, r.approved, r.ref, err)
}
res, err := r.buildMerge(ctx, req, old.Hash())
if err != nil {
return MergeResult{}, err
}
r.raceHook()
err = r.repo.Storer.CheckAndSetReference(plumbing.NewHashReference(approvedRef, res.Commit), old)
if err == nil {
return res, nil
}
if !errors.Is(err, storage.ErrReferenceHasChanged) {
return MergeResult{}, fmt.Errorf("gitx: update %s in %s: %w", approvedRef, r.ref, err)
}
lastErr = err
}
return MergeResult{}, fmt.Errorf("%w: %s in %s after %d attempts: %v",
ErrRefRace, approvedRef, r.ref, r.casBudget(), lastErr)
}
// buildMerge does everything except moving the ref: it is called afresh on each
// compare-and-swap attempt, against the head it was handed.
func (r *Repo) buildMerge(ctx context.Context, req MergeRequest, head plumbing.Hash) (MergeResult, error) {
proposalHead, err := r.BranchHead(ctx, req.Branch)
if err != nil {
return MergeResult{}, err
}
base, err := r.ResolveRev(ctx, req.Base)
if err != nil {
return MergeResult{}, err
}
// The base must still be on the approved branch. If it is not, the approved
// branch was rewritten under the proposal and every comparison below would
// be against a revision that is no longer part of the history.
onBranch, err := r.IsAncestor(ctx, base, head)
if err != nil {
return MergeResult{}, err
}
if !onBranch {
return MergeResult{}, &StaleError{Reason: StaleBaseDetached, Base: base, Head: head}
}
baseTree, err := r.treeOf(base)
if err != nil {
return MergeResult{}, err
}
headTree, err := r.treeOf(head)
if err != nil {
return MergeResult{}, err
}
proposalTree, err := r.treeOf(proposalHead)
if err != nil {
return MergeResult{}, err
}
changed, err := r.changedDocs(ctx, req.Branch, baseTree, proposalTree)
if err != nil {
return MergeResult{}, err
}
if len(changed) == 0 {
return MergeResult{}, fmt.Errorf("%w: %q changes no document against its base %s",
ErrUnsupportedChange, req.Branch, base)
}
baseIdx, err := r.buildDocIndex(ctx, baseTree)
if err != nil {
return MergeResult{}, fmt.Errorf("gitx: index documents at base %s: %w", base, err)
}
headIdx, err := r.buildDocIndex(ctx, headTree)
if err != nil {
return MergeResult{}, fmt.Errorf("gitx: index documents at approved head %s: %w", head, err)
}
node, err := r.loadTree(headTree, 0)
if err != nil {
return MergeResult{}, err
}
docs := make([]MergedDoc, 0, len(changed))
for _, c := range changed {
if baseIdx.duplicated[c.docID] || headIdx.duplicated[c.docID] {
return MergeResult{}, fmt.Errorf("%w: %s appears more than once on the approved branch",
ErrDuplicateDocID, c.docID)
}
basePath, inBase := baseIdx.byID[c.docID]
headPath, inHead := headIdx.byID[c.docID]
switch {
case inBase && inHead:
// The design's comparison, resolved through the id rather than the
// path: a rename between B and H is not a change to the document.
baseBlob, err := blobAt(baseTree, basePath)
if err != nil {
return MergeResult{}, err
}
headBlob, err := blobAt(headTree, headPath)
if err != nil {
return MergeResult{}, err
}
if baseBlob != headBlob {
return MergeResult{}, &StaleError{
Reason: StaleDocChanged, DocID: c.docID, Path: headPath, Base: base, Head: head,
}
}
case inBase && !inHead:
return MergeResult{}, &StaleError{
Reason: StaleDocRemoved, DocID: c.docID, Path: basePath, Base: base, Head: head,
}
case !inBase && inHead:
return MergeResult{}, &StaleError{
Reason: StaleDocAppeared, DocID: c.docID, Path: headPath, Base: base, Head: head,
}
default:
// A genuinely new document. Its path on the head must be free, or
// the splice would overwrite a document the proposal never read.
if _, taken := headIdx.byPath[c.path]; taken {
return MergeResult{}, &StaleError{
Reason: StalePathTaken, DocID: c.docID, Path: c.path, Base: base, Head: head,
}
}
}
target := c.path
if inHead {
target = headPath
}
if err := node.set(target, c.blob); err != nil {
return MergeResult{}, err
}
docs = append(docs, MergedDoc{
DocID: c.docID,
Path: target,
ProposalPath: c.path,
Blob: c.blob,
New: !inHead,
})
}
sort.Slice(docs, func(i, j int) bool { return docs[i].Path < docs[j].Path })
treeHash, err := node.write(r.repo.Storer)
if err != nil {
return MergeResult{}, err
}
// Two parents, approved head first. This is the whole of the "merge": a
// real merge commit, so the proposal stays visible in git log, built with
// explicit ParentHashes rather than any merge strategy.
parents := []plumbing.Hash{head, proposalHead}
commit, err := r.writeCommit(req.Meta, treeHash, parents)
if err != nil {
return MergeResult{}, err
}
return MergeResult{
Commit: commit,
Tree: treeHash,
ApprovedHead: head,
ProposalHead: proposalHead,
Docs: docs,
}, nil
}
// changedDoc is one document the proposal touched, as it exists on the proposal
// branch.
type changedDoc struct {
path string
blob plumbing.Hash
docID string
}
// changedDocs is F: the documents a proposal changed against its base.
//
// Only additions and modifications of markdown documents are expressible. A
// deletion, a rename, or an edit to a non-document path (an attachment, or
// .spec.yml) is refused rather than guessed at — the write plane is a
// whole-document PUT and gives an agent no way to say "delete this" or "move
// this", and those operations are human-push-only by design.
func (r *Repo) changedDocs(ctx context.Context, branch string, baseTree, proposalTree *object.Tree) ([]changedDoc, error) {
changes, err := object.DiffTreeWithOptions(ctx, baseTree, proposalTree, object.DefaultDiffTreeOptions)
if err != nil {
return nil, fmt.Errorf("gitx: diff %q against its base in %s: %w", branch, r.ref, err)
}
out := make([]changedDoc, 0, len(changes))
seenID := make(map[string]string, len(changes))
for _, c := range changes {
action, err := c.Action()
if err != nil {
return nil, fmt.Errorf("gitx: classify change in %q: %w", branch, err)
}
switch action {
case merkletrie.Delete:
return nil, fmt.Errorf("%w: %q deletes %q; deletion is human-push-only",
ErrUnsupportedChange, branch, c.From.Name)
case merkletrie.Modify:
if c.From.Name != c.To.Name {
return nil, fmt.Errorf("%w: %q renames %q to %q; rename is human-push-only",
ErrUnsupportedChange, branch, c.From.Name, c.To.Name)
}
}
path := c.To.Name
if !strings.HasSuffix(path, core.DocExt) {
return nil, fmt.Errorf("%w: %q changes %q, which is not a document; the merge is keyed by document id",
ErrUnsupportedChange, branch, path)
}
if err := core.ValidateDocPath(path); err != nil {
return nil, err
}
switch c.To.TreeEntry.Mode {
case filemode.Regular, filemode.Executable:
default:
return nil, fmt.Errorf("%w: %q at %q in %q is not a document blob",
ErrUnsupportedEntry, c.To.TreeEntry.Mode, path, branch)
}
data, err := r.readBlob(c.To.TreeEntry.Hash, path)
if err != nil {
return nil, err
}
fm, _, err := core.ParseDocument(data)
if err != nil {
return nil, fmt.Errorf("gitx: %q in %q: %w", path, branch, err)
}
if err := core.ValidateDocID(fm.ID); err != nil {
return nil, fmt.Errorf("gitx: %q in %q: %w", path, branch, err)
}
if prev, dup := seenID[fm.ID]; dup {
return nil, fmt.Errorf("%w: %q changes %s at both %q and %q",
ErrDuplicateDocID, branch, fm.ID, prev, path)
}
seenID[fm.ID] = path
out = append(out, changedDoc{path: path, blob: c.To.TreeEntry.Hash, docID: fm.ID})
}
sort.Slice(out, func(i, j int) bool { return out[i].path < out[j].path })
return out, nil
}
// docIndex maps a tree's documents both ways.
type docIndex struct {
// byID maps a document id to its path. Documents whose frontmatter is
// missing, unparseable or carries no valid id are absent from it.
byID map[string]string
// byPath maps every document path to its id, "" when it has none. It is
// what stops a new document from being spliced over a path that is already
// occupied by something this merge cannot see.
byPath map[string]string
// duplicated names ids that appear more than once in the tree.
duplicated map[string]bool
}
// buildDocIndex walks a tree and resolves every document's id.
//
// A document whose frontmatter will not parse, or that carries no valid id, is
// recorded by path and left out of the id map rather than failing the walk. The
// registry and the update hook are what keep those out of the approved branch;
// making one malformed document — which --push-option=skip-validation can
// always produce — block every future merge in the space would turn a typo into
// an outage. It is still not silently overwritten: byPath keeps its path
// occupied, so a proposal targeting it is refused as stale.
//
// The same reasoning applies to duplicated ids: they are recorded, and only
// refused when the merge actually needs to resolve one of them.
func (r *Repo) buildDocIndex(ctx context.Context, t *object.Tree) (*docIndex, error) {
budget := r.newBudget()
var entries []docEntry
if err := r.collectDocs(ctx, t, "", 0, budget, &entries); err != nil {
return nil, err
}
idx := &docIndex{
byID: make(map[string]string, len(entries)),
byPath: make(map[string]string, len(entries)),
duplicated: map[string]bool{},
}
for _, e := range entries {
if err := ctx.Err(); err != nil {
return nil, err
}
data, err := r.readBlob(e.hash, e.path)
if err != nil {
return nil, err
}
if err := budget.read(e.path, int64(len(data))); err != nil {
return nil, err
}
idx.byPath[e.path] = ""
fm, _, err := core.ParseDocument(data)
if err != nil {
continue
}
if err := core.ValidateDocID(fm.ID); err != nil {
continue
}
idx.byPath[e.path] = fm.ID
if _, dup := idx.byID[fm.ID]; dup {
idx.duplicated[fm.ID] = true
continue
}
idx.byID[fm.ID] = e.path
}
return idx, nil
}
// blobAt returns the blob sha of a document already known to be in the tree.
func blobAt(t *object.Tree, path string) (plumbing.Hash, error) {
entry, err := t.FindEntry(path)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("gitx: %q vanished from tree %s: %w", path, t.Hash, err)
}
return entry.Hash, nil
}