package builder import ( "context" "fmt" "strings" ) // **A merge check judges the merge, not the head** (novox/hq ADR 0323, issue 341). // // On 2026-10-09 a node-engine pull request was checked green at its head and broke the trunk when it merged: // a merge an hour earlier had added a field under the same JSON key on other lines, git merged the two edits // without a conflict, and the result did not compile. The check had judged the head alone. So a check now // takes the trunk the pull request merges into, its head at the moment the check clones it as the check's // **trunk commit**, and makes in its own clone the merge of the head into that commit, as the forge's // default merge would. The gate and the repository's own check judge that merge. // // **The merge is only checked** (ADR 0323 §2, ADR 0238 §1): it is a commit off the trunk, made in the // check's own clone, never pushed to the forge and never registered; the clone is removed when the check // ends. Nothing here writes to a remote. // // The forge keeps no merge reference for a pull request, only its head, so the builder makes it itself. // TrunkMerge is the merge a check made: the head and the trunk commit it was merged into, whole, and the merge // commit; or the files in conflict when the head does not merge cleanly. type TrunkMerge struct { Head, Trunk, Merge string Conflicts []string } // JudgedHead is one head a check judged, with the trunk commit it merged that head into, each as twelve // characters (ADR 0323 §3, §6). type JudgedHead struct { Owner, Repo string Head, Trunk string } // mergeIdentity is who a check's merge is made as: it is never pushed, so it names nobody's account. No hook of the // clone's or the build machine's git configuration runs, and no setting there changes the merge. var mergeIdentity = []string{"-c", "user.name=mesh merge check", "-c", "user.email=merge-check@mesh.invalid", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "-c", "merge.verifySignatures=false"} // mergeIntoTrunk makes, in the clone at tree, the merge of head into the head of origin/ as it stands in // that fresh clone: the trunk commit. A head that does not merge cleanly answers the files in conflict and leaves // the tree at the trunk commit; an error is that the merge could not be tried at all. func mergeIntoTrunk(ctx context.Context, run Runner, tree, base, head string) (TrunkMerge, error) { var m TrunkMerge revParse := func(ref string) (string, error) { out, err := run(ctx, tree, "git", "rev-parse", "--verify", "--quiet", ref+"^{commit}") return strings.TrimSpace(out), err } var err error if m.Head, err = revParse(head); err != nil || m.Head == "" { return m, fmt.Errorf("the head %s cannot be read in the check's clone: %v", head, err) } if m.Trunk, err = revParse("refs/remotes/origin/" + base); err != nil || m.Trunk == "" { return m, fmt.Errorf("the trunk %s cannot be read, and a check judges the head merged into it "+ "(novox/hq ADR 0323): %v", base, err) } if _, err := run(ctx, tree, "git", "checkout", "--quiet", "--detach", m.Trunk); err != nil { return m, fmt.Errorf("the trunk commit %s cannot be checked out: %w", twelve(m.Trunk), err) } message := fmt.Sprintf("Check the merge of %s into %s at %s (made for a merge check, never pushed)", twelve(m.Head), base, twelve(m.Trunk)) args := append(append([]string{}, mergeIdentity...), "merge", "--no-ff", "--no-edit", "--no-verify", "--quiet", "-m", message, m.Head) if _, err := run(ctx, tree, "git", args...); err != nil { out, _ := run(ctx, tree, "git", "diff", "--name-only", "--diff-filter=U") m.Conflicts = strings.Fields(out) _, _ = run(ctx, tree, "git", "merge", "--abort") if len(m.Conflicts) == 0 { return m, fmt.Errorf("the head %s could not be merged into trunk %s: %w", twelve(m.Head), twelve(m.Trunk), err) } return m, nil } if m.Merge, err = revParse("HEAD"); err != nil { return m, fmt.Errorf("the merge made for the check cannot be read: %w", err) } return m, nil } // changedByMerge is every path the merge changes against the trunk commit: what the gate's delivery plan is // computed from (ADR 0323 §1). Nil when it cannot be read; the caller keeps the pull request's own list. func changedByMerge(ctx context.Context, run Runner, tree string, m TrunkMerge) []string { if m.Merge == "" || m.Trunk == "" { return nil } out, err := run(ctx, tree, "git", "diff", "--name-only", "--no-renames", m.Trunk, m.Merge) if err != nil { return nil // empty-on-error: the caller keeps the pull request's own list of changed files, the safe one } return strings.Fields(out) } // conflictWords is a head that does not merge cleanly, said as its check's failure. func conflictWords(m TrunkMerge) string { return fmt.Sprintf("head %s does not merge cleanly into trunk %s: in conflict %s", twelve(m.Head), twelve(m.Trunk), strings.Join(m.Conflicts, ", ")) } // headAt is the commit a clone is checked out at, as twelve characters; empty when it cannot be read. func headAt(ctx context.Context, run Runner, tree string) string { out, err := run(ctx, tree, "git", "rev-parse", "--verify", "--quiet", "HEAD^{commit}") if err != nil { return "" } return twelve(strings.TrimSpace(out)) } // twelve is a commit as a verdict names it: its first twelve characters. func twelve(commit string) string { if len(commit) > 12 { return commit[:12] } return commit }