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 }