package buildstep import ( "context" "fmt" "strings" "git.novox.be/novox/mesh-sdk/go/forgekey" ) // MergeResult is what MergeInto made of a head and a trunk commit: the trunk commit (Base) and the head, whole, and the // merge commit and the paths the merge changes against the trunk commit; or, when the head does not merge cleanly, the // files in conflict, with Merge and Changed empty. type MergeResult struct { Base, Head, Merge string Conflicts []string Changed []string } // MergeInto makes, in the workspace dir that FetchCommit left at commit, the merge of commit into the trunk commit // base, as the forge's default merge would (a merge commit, never a fast-forward), and leaves the workspace at the merge // (novox/hq ADR 0484 clause 3, ADR 0323). It is the one merge the build modules' fetch steps make, with the identity // and flags of the controller's check merge (mesh-controller internal/builder/check_merge.go): a name nobody owns, no // signature, no hook, nothing of the machine's git configuration that changes a merge. Nothing is pushed. // // A merge needs both histories, so it makes the fetch whole (the shallow clone FetchCommit made is deepened) and // fetches base by its sha. A head that does not merge cleanly is an answer, not an error: the files in conflict, the // merge undone and the workspace left at base. A head already in base answers a Merge equal to Base and no changed // paths. An error is that the merge could not be tried: a base or commit that is not a full sha, no base, a workspace // not at commit, or a fetch or git command that failed. remote and the options are those of FetchCommit; the runner's // bound must allow a whole fetch. func MergeInto(ctx context.Context, run forgekey.Runner, dir, remote, base, commit string, opts ...Option) (MergeResult, error) { var o fetchOptions for _, opt := range opts { opt(&o) } res := MergeResult{Base: base, Head: commit} if base == "" { return res, fmt.Errorf("buildstep: no base: a merge needs the trunk commit it is made onto") } if err := checkFetch(o, dir, remote, base, ""); err != nil { return res, err } if err := checkFetch(o, dir, remote, commit, ""); err != nil { return res, err } env := []string{"GIT_TERMINAL_PROMPT=0"} git := func(args ...string) (string, error) { full := append([]string{"-c", "credential.helper=", "-C", dir}, args...) r := run(ctx, env, "git", full...) if r.Status != 0 || r.Err != "" { return "", fmt.Errorf("git %s: %s", args[0], firstLine(r.Err+" "+r.Stderr)) } return r.Stdout, nil } at, err := git("rev-parse", "HEAD") if err != nil { return res, err } if head := strings.TrimSpace(at); head != commit { return res, fmt.Errorf("buildstep: the workspace is at %s, not at the commit %s", head, commit) } shallow, err := git("rev-parse", "--is-shallow-repository") if err != nil { return res, err } fetch := []string{"fetch", "--no-tags", "origin", base} if strings.TrimSpace(shallow) == "true" { fetch = []string{"fetch", "--no-tags", "--unshallow", "origin", base} } if _, err := git(fetch...); err != nil { return res, fmt.Errorf("the trunk commit %s: %w", base, err) } if _, err := git("checkout", "-q", "--detach", base); err != nil { return res, fmt.Errorf("the trunk commit %s cannot be checked out: %w", base, err) } message := fmt.Sprintf("Check the merge of %s into %s (made for a check, never pushed)", short(commit), short(base)) args := append(append([]string{}, mergeIdentity...), "merge", "--no-ff", "--no-edit", "--no-verify", "--quiet", "-m", message, commit) if _, err := git(args...); err != nil { out, _ := git("diff", "--name-only", "--diff-filter=U") res.Conflicts = strings.Fields(out) _, _ = git("merge", "--abort") if len(res.Conflicts) == 0 { return res, fmt.Errorf("the head %s could not be merged into the trunk commit %s: %w", short(commit), short(base), err) } return res, nil } merge, err := git("rev-parse", "HEAD") if err != nil { return res, fmt.Errorf("the merge cannot be read: %w", err) } res.Merge = strings.TrimSpace(merge) changed, err := git("diff", "--name-only", "--no-renames", base, res.Merge) if err != nil { return res, fmt.Errorf("the paths the merge changes cannot be read: %w", err) } res.Changed = strings.Fields(changed) return res, nil } // 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 (the controller's // check merge, internal/builder/check_merge.go). 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"} func short(commit string) string { if len(commit) > 12 { return commit[:12] } return commit }