// Package premerge prepares the tree an accept WOULD create, without
// creating anything (RFC 0009 §premerge). It is the quarry-shaped step
// a CI pipeline runs first in premerge mode: clone read-only, open the
// target, merge the branch tip into the working tree — zit's merge
// writes no check-in — and hand the tree to the tests. Nothing here
// ever commits or pushes; ledger safety is structural, and the
// integration test asserts the store's artifact count is untouched
// even when a pipeline tries.
package premerge
import (
"bytes"
"fmt"
"os"
"os/exec"
"strings"
)
// Result says what the preparation produced.
type Result struct {
// Dir holds the merged tree, ready for the pipeline.
Dir string
// Pair identifies what was tested: branch tip and target tip. A
// verdict belongs to this pair and nothing else — the merge
// check-in an accept later writes will hash differently.
Pair string
// Conflict is the other verdict: the merge does not apply. Files
// names where, as zit reported them.
Conflict bool
Files []string
}
// Pair is the canonical spelling of a premerge subject.
func Pair(branchTip, targetTip string) string {
return "pair:" + branchTip + ":" + targetTip
}
// Prepare clones cloneURL into dir, opens targetTip, and merges
// branchTip into the working tree. It never commits: the caller runs
// its pipeline in Result.Dir and discards it.
func Prepare(cloneURL, branchTip, targetTip, dir string) (Result, error) {
res := Result{Dir: dir, Pair: Pair(branchTip, targetTip)}
if err := os.MkdirAll(dir, 0o755); err != nil {
return res, err
}
if err := run(dir, "zit", "clone", cloneURL, "."); err != nil {
return res, fmt.Errorf("premerge: clone: %w", err)
}
if err := run(dir, "zit", "open", targetTip); err != nil {
return res, fmt.Errorf("premerge: open target: %w", err)
}
// Belt to the credentials' braces: the clone saved its origin as
// the default remote, and zit autosyncs on commit by default — a
// pipeline that commits in this tree must strand that commit HERE,
// not push it home. The integration test commits on purpose and
// asserts the source ledger did not move.
if err := run(dir, "zit", "settings", "autosync", "off"); err != nil {
return res, fmt.Errorf("premerge: autosync off: %w", err)
}
out, err := runOut(dir, "zit", "merge", "--no-sync", branchTip)
if err != nil {
// A refused merge IS the verdict, not an error in the runner.
res.Conflict = true
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "CONFLICT") || strings.HasSuffix(line, ": conflict") {
res.Files = append(res.Files, line)
}
}
if len(res.Files) == 0 && strings.TrimSpace(out) != "" {
res.Files = []string{strings.TrimSpace(out)}
}
return res, nil
}
return res, nil
}
func run(dir string, name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Dir = dir
var buf bytes.Buffer
cmd.Stdout, cmd.Stderr = &buf, &buf
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(buf.String()))
}
return nil
}
func runOut(dir string, name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
cmd.Dir = dir
var buf bytes.Buffer
cmd.Stdout, cmd.Stderr = &buf, &buf
err := cmd.Run()
return buf.String(), err
}