A build machine's or a clone's git configuration must not run code or change the merge a check judges (novox/hq ADR 0323).
116 lines
5.3 KiB
Go
116 lines
5.3 KiB
Go
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/<base> 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
|
|
}
|