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) } }