113 lines
4.8 KiB
Go
113 lines
4.8 KiB
Go
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
|
|
}
|