package gitx
import (
"context"
"errors"
"strings"
"testing"
"github.com/go-git/go-git/v5/plumbing"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// mergeMeta is the merge commit description a service layer would build.
func mergeMeta(n int) CommitMeta {
return CommitMeta{
Message: "Merge proposals/1",
Trailers: []Trailer{{Key: "X-Agent-Session", Value: "8fb9c9a4"}},
Author: agent(n),
Committer: owner(n),
}
}
// TestMergeIsATwoParentTreeSplice pins the shape of the merge commit: a real
// merge commit whose first parent is the approved head and whose second is the
// proposal tip, so the proposal stays visible in git log.
func TestMergeIsATwoParentTreeSplice(t *testing.T) {
repo, _ := newSpace(t)
ctx := context.Background()
base := pushApproved(t, repo, ownerMeta("seed", 1),
Write{Path: "specs/0007.md", Content: doc("SPEC-0007", "Storage", "approved text")},
Write{Path: "specs/0008.md", Content: doc("SPEC-0008", "Other", "untouched")},
)
prop := openProposal(t, repo, "proposals/1", base.String(), meta("revise 0007", 2),
Write{Path: "specs/0007.md", Content: doc("SPEC-0007", "Storage", "proposed text")})
res, err := repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(3)})
if err != nil {
t.Fatalf("Merge: %v", err)
}
if res.ApprovedHead != base {
t.Fatalf("ApprovedHead = %s, want %s", res.ApprovedHead, base)
}
if res.ProposalHead != prop.Commit {
t.Fatalf("ProposalHead = %s, want %s", res.ProposalHead, prop.Commit)
}
c, err := repo.repo.CommitObject(res.Commit)
if err != nil {
t.Fatalf("CommitObject: %v", err)
}
if c.NumParents() != 2 {
t.Fatalf("merge commit has %d parents, want 2", c.NumParents())
}
if c.ParentHashes[0] != base || c.ParentHashes[1] != prop.Commit {
t.Fatalf("parents = %v, want [%s %s]", c.ParentHashes, base, prop.Commit)
}
if !strings.Contains(c.Message, "X-Agent-Session: 8fb9c9a4") {
t.Fatalf("merge commit message lost its provenance trailers:\n%s", c.Message)
}
// The approved branch moved to the merge commit.
head, err := repo.ApprovedHead(ctx)
if err != nil {
t.Fatal(err)
}
if head != res.Commit {
t.Fatalf("approved head = %s, want the merge commit %s", head, res.Commit)
}
// The tree is the approved tree with the one document replaced.
if got := mustRead(t, repo, DefaultApprovedBranch, "specs/0007.md"); !strings.Contains(got, "proposed text") {
t.Fatalf("merged 0007 = %q", got)
}
if got := mustRead(t, repo, DefaultApprovedBranch, "specs/0008.md"); !strings.Contains(got, "untouched") {
t.Fatalf("untouched 0008 = %q", got)
}
if len(res.Docs) != 1 || res.Docs[0].DocID != "SPEC-0007" || res.Docs[0].Path != "specs/0007.md" {
t.Fatalf("Docs = %+v", res.Docs)
}
if res.Docs[0].New || res.Docs[0].Renamed() {
t.Fatalf("an in-place edit reported New=%v Renamed=%v", res.Docs[0].New, res.Docs[0].Renamed())
}
}
// TestMergeFollowsARenameBetweenBaseAndHead is the case the whole id-keyed
// design exists for. The human moves a document on the approved branch while an
// agent is editing it at its old path. That must be neither a conflict nor a
// resurrection of the old path.
func TestMergeFollowsARenameBetweenBaseAndHead(t *testing.T) {
repo, _ := newSpace(t)
ctx := context.Background()
body := doc("SPEC-0007", "Storage", "approved text")
base := pushApproved(t, repo, ownerMeta("seed", 1),
Write{Path: "specs/0007-storage.md", Content: body})
// The agent proposes against the old path, from the old base.
openProposal(t, repo, "proposals/1", base.String(), meta("revise 0007", 2),
Write{Path: "specs/0007-storage.md", Content: doc("SPEC-0007", "Storage", "proposed text")})
// Meanwhile the human renames it — byte-identical content at a new path,
// which is what a rename is. Deletion and rename are human-push-only.
head := pushApproved(t, repo, ownerMeta("rename 0007", 3),
Write{Path: "archive/0007-storage.md", Content: body},
Write{Path: "specs/0007-storage.md"}, // nil content deletes
)
if got := docPaths(t, repo, head.String()); len(got) != 1 || got[0] != "archive/0007-storage.md" {
t.Fatalf("after the rename the approved branch holds %v", got)
}
res, err := repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(4)})
if err != nil {
t.Fatalf("Merge across a rename = %v, want it to follow the document", err)
}
// The proposal's blob landed at the document's path on the head...
if got := mustRead(t, repo, DefaultApprovedBranch, "archive/0007-storage.md"); !strings.Contains(got, "proposed text") {
t.Fatalf("merged document at its new path = %q", got)
}
// ...and the path it was moved away from was NOT resurrected.
paths := docPaths(t, repo, DefaultApprovedBranch)
if len(paths) != 1 || paths[0] != "archive/0007-storage.md" {
t.Fatalf("merged tree holds %v; the old path must not come back", paths)
}
d := res.Docs[0]
if d.DocID != "SPEC-0007" || d.Path != "archive/0007-storage.md" || d.ProposalPath != "specs/0007-storage.md" {
t.Fatalf("MergedDoc = %+v", d)
}
if !d.Renamed() {
t.Fatal("MergedDoc.Renamed() = false, want true")
}
}
// TestMergeRefusesAStaleBase covers the 409: the document changed on the
// approved branch under the proposal.
func TestMergeRefusesAStaleBase(t *testing.T) {
repo, _ := newSpace(t)
ctx := context.Background()
base := pushApproved(t, repo, ownerMeta("seed", 1),
Write{Path: "specs/0007.md", Content: doc("SPEC-0007", "Storage", "v1")})
openProposal(t, repo, "proposals/1", base.String(), meta("revise", 2),
Write{Path: "specs/0007.md", Content: doc("SPEC-0007", "Storage", "agent v2")})
head := pushApproved(t, repo, ownerMeta("human edit", 3),
Write{Path: "specs/0007.md", Content: doc("SPEC-0007", "Storage", "human v2")})
_, err := repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(4)})
if !errors.Is(err, ErrStale) {
t.Fatalf("Merge over a changed document = %v, want ErrStale", err)
}
var stale *StaleError
if !errors.As(err, &stale) {
t.Fatalf("error %v is not a *StaleError", err)
}
// The caller needs the current head to put in the 409 so the agent can
// refetch and re-propose against it.
if stale.Head != head {
t.Fatalf("StaleError.Head = %s, want the current approved head %s", stale.Head, head)
}
if stale.Base != base {
t.Fatalf("StaleError.Base = %s, want %s", stale.Base, base)
}
if stale.DocID != "SPEC-0007" || stale.Path != "specs/0007.md" {
t.Fatalf("StaleError does not name the document: %+v", stale)
}
if stale.Reason != StaleDocChanged {
t.Fatalf("StaleError.Reason = %q", stale.Reason)
}
// Nothing moved.
if got, _ := repo.ApprovedHead(ctx); got != head {
t.Fatalf("a stale merge moved the approved branch to %s", got)
}
}
func TestMergeStalenessCases(t *testing.T) {
cases := []struct {
name string
// setup runs after the base is pushed and the proposal is open; it
// makes the approved branch move underneath. root is the space's
// initial commit.
setup func(t *testing.T, r *Repo, root plumbing.Hash)
reason StaleReason
}{
{
name: "document removed from the approved branch",
setup: func(t *testing.T, r *Repo, _ plumbing.Hash) {
pushApproved(t, r, ownerMeta("delete 0007", 5), Write{Path: "specs/0007.md"})
},
reason: StaleDocRemoved,
},
{
name: "the approved branch was rewritten under the proposal",
setup: func(t *testing.T, r *Repo, root plumbing.Hash) {
// Rewind the approved branch past the base, as a force-push
// would: the recorded base is no longer an ancestor of the
// head, so every comparison would be against a revision that
// is not part of the history any more.
ref := plumbing.NewBranchReferenceName(r.ApprovedBranch())
if err := r.repo.Storer.SetReference(plumbing.NewHashReference(ref, root)); err != nil {
t.Fatal(err)
}
},
reason: StaleBaseDetached,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repo, _ := newSpace(t)
ctx := context.Background()
root, err := repo.ApprovedHead(ctx)
if err != nil {
t.Fatal(err)
}
body := doc("SPEC-0007", "Storage", "v1")
base := pushApproved(t, repo, ownerMeta("seed", 1), Write{Path: "specs/0007.md", Content: body})
openProposal(t, repo, "proposals/1", base.String(), meta("revise", 2),
Write{Path: "specs/0007.md", Content: doc("SPEC-0007", "Storage", "agent v2")})
tc.setup(t, repo, root)
_, err = repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(6)})
var stale *StaleError
if !errors.As(err, &stale) {
t.Fatalf("Merge = %v, want a *StaleError", err)
}
if stale.Reason != tc.reason {
t.Fatalf("StaleError.Reason = %q, want %q", stale.Reason, tc.reason)
}
if stale.Head.IsZero() {
t.Fatal("StaleError carries no head; the caller cannot answer the 409")
}
})
}
}
// TestMergeRefusesADocumentThatAppearedUnderIt guards the id collision: the
// approved branch gained a document with the same id after the base, so the
// proposal would silently overwrite work it never saw.
func TestMergeRefusesADocumentThatAppearedUnderIt(t *testing.T) {
repo, _ := newSpace(t)
ctx := context.Background()
base, err := repo.ApprovedHead(ctx)
if err != nil {
t.Fatal(err)
}
openProposal(t, repo, "proposals/1", base.String(), meta("add 0007", 2),
Write{Path: "specs/0007.md", Content: doc("SPEC-0007", "Storage", "agent draft")})
pushApproved(t, repo, ownerMeta("human adds the same id elsewhere", 3),
Write{Path: "archive/0007.md", Content: doc("SPEC-0007", "Storage", "human draft")})
_, err = repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(4)})
var stale *StaleError
if !errors.As(err, &stale) || stale.Reason != StaleDocAppeared {
t.Fatalf("Merge = %v, want StaleDocAppeared", err)
}
}
// TestMergeRefusesToOverwriteAnOccupiedPath covers a new document whose path is
// already taken on the head by a different document.
func TestMergeRefusesToOverwriteAnOccupiedPath(t *testing.T) {
repo, _ := newSpace(t)
ctx := context.Background()
base, err := repo.ApprovedHead(ctx)
if err != nil {
t.Fatal(err)
}
openProposal(t, repo, "proposals/1", base.String(), meta("add 0007", 2),
Write{Path: "specs/next.md", Content: doc("SPEC-0007", "Storage", "agent draft")})
pushApproved(t, repo, ownerMeta("human takes the path", 3),
Write{Path: "specs/next.md", Content: doc("SPEC-0009", "Something else", "human text")})
_, err = repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(4)})
var stale *StaleError
if !errors.As(err, &stale) || stale.Reason != StalePathTaken {
t.Fatalf("Merge = %v, want StalePathTaken", err)
}
if got := mustRead(t, repo, DefaultApprovedBranch, "specs/next.md"); !strings.Contains(got, "human text") {
t.Fatalf("the occupied path was overwritten: %q", got)
}
}
func TestMergeAddsANewDocument(t *testing.T) {
repo, _ := newSpace(t)
ctx := context.Background()
base := pushApproved(t, repo, ownerMeta("seed", 1),
Write{Path: "specs/0007.md", Content: doc("SPEC-0007", "Storage", "v1")})
openProposal(t, repo, "proposals/1", base.String(), meta("add 0010", 2),
Write{Path: "notes/2026-07-22.md", Content: doc("NOTE-0010", "Daily", "new note")})
res, err := repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(3)})
if err != nil {
t.Fatalf("Merge: %v", err)
}
if len(res.Docs) != 1 || !res.Docs[0].New {
t.Fatalf("Docs = %+v, want one new document", res.Docs)
}
paths := docPaths(t, repo, DefaultApprovedBranch)
if strings.Join(paths, ",") != "notes/2026-07-22.md,specs/0007.md" {
t.Fatalf("merged tree holds %v", paths)
}
}
func TestMergeRefusesChangesTheModelCannotExpress(t *testing.T) {
ctx := context.Background()
t.Run("deletion", func(t *testing.T) {
repo, _ := newSpace(t)
base := pushApproved(t, repo, ownerMeta("seed", 1),
Write{Path: "specs/0007.md", Content: doc("SPEC-0007", "Storage", "v1")},
Write{Path: "specs/0008.md", Content: doc("SPEC-0008", "Other", "v1")})
if _, err := repo.CreateProposalBranch(ctx, "proposals/1", base.String()); err != nil {
t.Fatal(err)
}
pushBranch(t, repo, "proposals/1", meta("delete 0008", 2), Write{Path: "specs/0008.md"})
_, err := repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(3)})
if !errors.Is(err, ErrUnsupportedChange) {
t.Fatalf("Merge of a deletion = %v, want ErrUnsupportedChange", err)
}
if !strings.Contains(err.Error(), "human-push-only") {
t.Fatalf("error %q does not say where deletion belongs", err)
}
})
t.Run("rename", func(t *testing.T) {
repo, _ := newSpace(t)
body := doc("SPEC-0007", "Storage", "v1")
base := pushApproved(t, repo, ownerMeta("seed", 1), Write{Path: "specs/0007.md", Content: body})
if _, err := repo.CreateProposalBranch(ctx, "proposals/1", base.String()); err != nil {
t.Fatal(err)
}
pushBranch(t, repo, "proposals/1", meta("move 0007", 2),
Write{Path: "archive/0007.md", Content: body},
Write{Path: "specs/0007.md"})
_, err := repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(3)})
if !errors.Is(err, ErrUnsupportedChange) {
t.Fatalf("Merge of a rename = %v, want ErrUnsupportedChange", err)
}
})
t.Run("non-document path", func(t *testing.T) {
repo, _ := newSpace(t)
base := pushApproved(t, repo, ownerMeta("seed", 1),
Write{Path: "specs/0007.md", Content: doc("SPEC-0007", "Storage", "v1")})
if _, err := repo.CreateProposalBranch(ctx, "proposals/1", base.String()); err != nil {
t.Fatal(err)
}
pushBranch(t, repo, "proposals/1", meta("policy", 2),
Write{Path: core.PolicyFile, Content: []byte("review:\n auto_merge: [notes/**]\n")})
_, err := repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(3)})
if !errors.Is(err, ErrUnsupportedChange) {
t.Fatalf("Merge of a %s change = %v, want ErrUnsupportedChange", core.PolicyFile, err)
}
})
t.Run("empty proposal", func(t *testing.T) {
repo, _ := newSpace(t)
base := pushApproved(t, repo, ownerMeta("seed", 1),
Write{Path: "specs/0007.md", Content: doc("SPEC-0007", "Storage", "v1")})
if _, err := repo.CreateProposalBranch(ctx, "proposals/1", base.String()); err != nil {
t.Fatal(err)
}
_, err := repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(3)})
if !errors.Is(err, ErrUnsupportedChange) {
t.Fatalf("Merge of an empty proposal = %v, want ErrUnsupportedChange", err)
}
})
t.Run("document with no id", func(t *testing.T) {
repo, _ := newSpace(t)
base, err := repo.ApprovedHead(ctx)
if err != nil {
t.Fatal(err)
}
openProposal(t, repo, "proposals/1", base.String(), meta("no id", 2),
Write{Path: "notes/x.md", Content: []byte("---\ntitle: No id\nstatus: draft\n---\n\nbody\n")})
_, err = repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(3)})
if !errors.Is(err, core.ErrInvalidDocID) {
t.Fatalf("Merge of an id-less document = %v, want core.ErrInvalidDocID", err)
}
})
t.Run("two documents sharing an id", func(t *testing.T) {
repo, _ := newSpace(t)
base, err := repo.ApprovedHead(ctx)
if err != nil {
t.Fatal(err)
}
openProposal(t, repo, "proposals/1", base.String(), meta("dupe", 2),
Write{Path: "notes/a.md", Content: doc("NOTE-0001", "A", "a")},
Write{Path: "notes/b.md", Content: doc("NOTE-0001", "B", "b")})
_, err = repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(3)})
if !errors.Is(err, ErrDuplicateDocID) {
t.Fatalf("Merge of two documents sharing an id = %v, want ErrDuplicateDocID", err)
}
})
}
func TestMergeRejectsBadRequests(t *testing.T) {
repo, _ := newSpace(t)
ctx := context.Background()
base, err := repo.ApprovedHead(ctx)
if err != nil {
t.Fatal(err)
}
openProposal(t, repo, "proposals/1", base.String(), meta("add", 2),
Write{Path: "notes/a.md", Content: doc("NOTE-0001", "A", "a")})
if _, err := repo.Merge(ctx, MergeRequest{Branch: "main", Base: base.String(), Meta: mergeMeta(3)}); !errors.Is(err, ErrBadRev) {
t.Fatalf("Merge of the approved branch = %v, want ErrBadRev", err)
}
if _, err := repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Meta: mergeMeta(3)}); err == nil {
t.Fatal("Merge with no base succeeded")
}
if _, err := repo.Merge(ctx, MergeRequest{Branch: "proposals/99", Base: base.String(), Meta: mergeMeta(3)}); !errors.Is(err, ErrNotFound) {
t.Fatalf("Merge of an absent branch = %v, want ErrNotFound", err)
}
if _, err := repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String()}); err == nil {
t.Fatal("Merge with no commit identity succeeded")
}
}
// TestMergeToleratesAMalformedDocumentElsewhere: one unparseable document on
// the approved branch — which --push-option=skip-validation can always produce —
// must not make every future merge in the space impossible.
func TestMergeToleratesAMalformedDocumentElsewhere(t *testing.T) {
repo, _ := newSpace(t)
ctx := context.Background()
base := pushApproved(t, repo, ownerMeta("seed", 1),
Write{Path: "specs/0007.md", Content: doc("SPEC-0007", "Storage", "v1")},
Write{Path: "notes/broken.md", Content: []byte("no frontmatter at all\n")},
)
openProposal(t, repo, "proposals/1", base.String(), meta("revise", 2),
Write{Path: "specs/0007.md", Content: doc("SPEC-0007", "Storage", "v2")})
if _, err := repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(3)}); err != nil {
t.Fatalf("Merge alongside a malformed document = %v, want success", err)
}
if got := mustRead(t, repo, DefaultApprovedBranch, "specs/0007.md"); !strings.Contains(got, "v2") {
t.Fatalf("merged document = %q", got)
}
// But its path is still occupied: a proposal targeting it is refused, not
// silently allowed to overwrite it.
head, err := repo.ApprovedHead(ctx)
if err != nil {
t.Fatal(err)
}
openProposal(t, repo, "proposals/2", head.String(), meta("claim the path", 4),
Write{Path: "notes/broken.md", Content: doc("NOTE-0002", "Mine now", "text")})
_, err = repo.Merge(ctx, MergeRequest{Branch: "proposals/2", Base: head.String(), Meta: mergeMeta(5)})
var stale *StaleError
if !errors.As(err, &stale) || stale.Reason != StalePathTaken {
t.Fatalf("Merge over a malformed document = %v, want StalePathTaken", err)
}
}
// TestMergeRetriesALostRefCAS proves the compare-and-swap retry: the approved
// branch moves between the build and the swap, exactly as a concurrent native
// receive-pack push would move it, and the merge rebuilds against the new head
// rather than failing.
func TestMergeRetriesALostRefCAS(t *testing.T) {
repo, _ := newSpace(t)
ctx := context.Background()
base := pushApproved(t, repo, ownerMeta("seed", 1),
Write{Path: "specs/0007.md", Content: doc("SPEC-0007", "Storage", "v1")})
openProposal(t, repo, "proposals/1", base.String(), meta("add a note", 2),
Write{Path: "notes/a.md", Content: doc("NOTE-0001", "A", "agent note")})
// Move the approved branch out from under the merge, once, between the
// build and the swap.
var raced bool
repo.beforeCAS = func() {
if raced {
return
}
raced = true
pushApproved(t, repo, ownerMeta("concurrent human push", 3),
Write{Path: "specs/0009.md", Content: doc("SPEC-0009", "Late", "human text")})
}
res, err := repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(4)})
if err != nil {
t.Fatalf("Merge that lost a CAS = %v, want a retry", err)
}
if !raced {
t.Fatal("the race hook never fired")
}
// The retry rebuilt onto the human's commit, so both changes survive.
paths := docPaths(t, repo, DefaultApprovedBranch)
if strings.Join(paths, ",") != "notes/a.md,specs/0007.md,specs/0009.md" {
t.Fatalf("merged tree holds %v; the concurrent push was lost", paths)
}
c, err := repo.repo.CommitObject(res.Commit)
if err != nil {
t.Fatal(err)
}
if c.ParentHashes[0] == base {
t.Fatal("the merge kept the stale first parent instead of rebuilding")
}
}
func TestMergeGivesUpAfterTooManyLostCAS(t *testing.T) {
repo, _ := newSpace(t)
ctx := context.Background()
base := pushApproved(t, repo, ownerMeta("seed", 1),
Write{Path: "specs/0007.md", Content: doc("SPEC-0007", "Storage", "v1")})
openProposal(t, repo, "proposals/1", base.String(), meta("add a note", 2),
Write{Path: "notes/a.md", Content: doc("NOTE-0001", "A", "agent note")})
repo.attemptLimit = 2
n := 0
repo.beforeCAS = func() {
n++
pushApproved(t, repo, ownerMeta("relentless human", 2+n),
Write{Path: "notes/human.md", Content: doc("NOTE-9999", "H", strings.Repeat("z", n))})
}
_, err := repo.Merge(ctx, MergeRequest{Branch: "proposals/1", Base: base.String(), Meta: mergeMeta(9)})
if !errors.Is(err, ErrRefRace) {
t.Fatalf("Merge that never wins the CAS = %v, want ErrRefRace", err)
}
if n != 2 {
t.Fatalf("merge made %d attempts, want 2", n)
}
}