diff --git a/go/buildstep/merge.go b/go/buildstep/merge.go new file mode 100644 index 0000000..6bc8df9 --- /dev/null +++ b/go/buildstep/merge.go @@ -0,0 +1,112 @@ +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 +} diff --git a/go/buildstep/merge_test.go b/go/buildstep/merge_test.go new file mode 100644 index 0000000..4c59610 --- /dev/null +++ b/go/buildstep/merge_test.go @@ -0,0 +1,232 @@ +package buildstep + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +func realGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + p := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// mergeRig is a local bare repository standing in for the forge, a work repository that pushes to it, and an empty +// workspace: the fetch step's shape (FetchCommit of the head into the workspace, then MergeInto). +type mergeRig struct { + t *testing.T + bare, work, ws string + fork, trunk string // the commit both histories share, and the trunk's head + remote string +} + +func newMergeRig(t *testing.T) *mergeRig { + root := t.TempDir() + r := &mergeRig{t: t, bare: filepath.Join(root, "r.git"), work: filepath.Join(root, "work"), ws: filepath.Join(root, "ws")} + for _, d := range []string{r.bare, r.work, r.ws} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + r.remote = "file://" + r.bare + realGit(t, r.bare, "init", "-q", "--bare") + realGit(t, r.work, "init", "-q", "-b", "main") + writeFile(t, r.work, "a.txt", "one\ntwo\nthree\n") + writeFile(t, r.work, "modules/m/module.json", "{}\n") + realGit(t, r.work, "add", ".") + realGit(t, r.work, "commit", "-q", "-m", "fork point") + r.fork = realGit(t, r.work, "rev-parse", "HEAD") + return r +} + +// head makes a commit on a branch off the fork point, pushes it as the pull request's head ref and answers its sha. +func (r *mergeRig) head(files map[string]string) string { + realGit(r.t, r.work, "checkout", "-q", "-B", "pr", r.fork) + for n, c := range files { + writeFile(r.t, r.work, n, c) + } + realGit(r.t, r.work, "add", ".") + realGit(r.t, r.work, "commit", "-q", "-m", "head") + h := realGit(r.t, r.work, "rev-parse", "HEAD") + realGit(r.t, r.work, "push", "-q", r.bare, "HEAD:refs/pull/1/head") + return h +} + +// advance moves the trunk past the fork point with a commit changing files, pushes it and answers its sha. +func (r *mergeRig) advance(files map[string]string) string { + realGit(r.t, r.work, "checkout", "-q", "-B", "main", r.fork) + for n, c := range files { + writeFile(r.t, r.work, n, c) + } + realGit(r.t, r.work, "add", ".") + realGit(r.t, r.work, "commit", "-q", "-m", "trunk moves") + r.trunk = realGit(r.t, r.work, "rev-parse", "HEAD") + realGit(r.t, r.work, "push", "-q", "-f", r.bare, "HEAD:refs/heads/main") + return r.trunk +} + +func (r *mergeRig) fetch(head string) { + if err := FetchCommit(context.Background(), forgekey.ExecRunner, r.ws, r.remote, head, "refs/pull/1/head", AllowFileRemote()); err != nil { + r.t.Fatal(err) + } +} + +// 1674/AC1: a clean merge answers the merge commit and the changed files, and leaves the workspace at the merge, which +// holds both sides' changes. +func TestMergeIntoACleanMergeAnswersTheMergeCommitAndTheChangedFiles(t *testing.T) { + r := newMergeRig(t) + head := r.head(map[string]string{"b.txt": "head\n"}) + base := r.advance(map[string]string{"a.txt": "one\ntwo\nthree\nfour\n"}) + r.fetch(head) + res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote()) + if err != nil { + t.Fatal(err) + } + if res.Base != base || res.Head != head || len(res.Merge) != 40 || res.Merge == base || res.Merge == head || len(res.Conflicts) != 0 { + t.Fatalf("result = %+v", res) + } + if !reflect.DeepEqual(res.Changed, []string{"b.txt"}) { + t.Errorf("changed = %v, want only what the head adds to the trunk: b.txt", res.Changed) + } + if at := realGit(t, r.ws, "rev-parse", "HEAD"); at != res.Merge { + t.Errorf("the workspace is at %s, not at the merge %s", at, res.Merge) + } + for f, want := range map[string]string{"b.txt": "head\n", "a.txt": "one\ntwo\nthree\nfour\n"} { + if got, _ := os.ReadFile(filepath.Join(r.ws, f)); string(got) != want { + t.Errorf("%s = %q, want %q", f, got, want) + } + } + if parents := strings.Fields(realGit(t, r.ws, "rev-list", "--parents", "-n", "1", res.Merge)); len(parents) != 3 || parents[1] != base || parents[2] != head { + t.Errorf("the merge commit's parents = %v, want the trunk commit and the head", parents) + } + if author := realGit(t, r.ws, "log", "-1", "--format=%an <%ae>", res.Merge); author != "mesh merge check " { + t.Errorf("the merge is made as %q", author) + } +} + +// 1674/AC2: a conflict is an answer: the files in conflict, no merge commit, and the workspace left at the trunk commit. +func TestMergeIntoAConflictAnswersTheFilesAndLeavesTheTreeAtTheTrunk(t *testing.T) { + r := newMergeRig(t) + head := r.head(map[string]string{"a.txt": "one\nHEAD\nthree\n", "c.txt": "c\n"}) + base := r.advance(map[string]string{"a.txt": "one\nTRUNK\nthree\n"}) + r.fetch(head) + res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote()) + if err != nil { + t.Fatalf("a conflict is an answer, not an error: %v", err) + } + if !reflect.DeepEqual(res.Conflicts, []string{"a.txt"}) || res.Merge != "" || len(res.Changed) != 0 || res.Base != base || res.Head != head { + t.Fatalf("result = %+v", res) + } + if at := realGit(t, r.ws, "rev-parse", "HEAD"); at != base { + t.Errorf("the workspace is at %s, not at the trunk commit %s", at, base) + } + if got, _ := os.ReadFile(filepath.Join(r.ws, "a.txt")); string(got) != "one\nTRUNK\nthree\n" { + t.Errorf("a.txt = %q, want the trunk's", got) + } + if st := realGit(t, r.ws, "status", "--porcelain"); st != "" { + t.Errorf("the tree is not clean after the aborted merge: %q", st) + } + if _, err := os.Stat(filepath.Join(r.ws, "c.txt")); err == nil { + t.Error("a file of the head is in a tree left at the trunk") + } +} + +// 1674/AC3: no base is refused before anything runs; so is a base or a commit that is not a full sha, a workspace that +// is not at the commit, and a remote that is not ssh://. +func TestMergeIntoRefusesWhatIsNotAMerge(t *testing.T) { + f := &fakeGit{} + for name, c := range map[string]struct{ remote, base, commit string }{ + "no base": {"ssh://mesh-forge/o/r.git", "", sha}, + "a short base": {"ssh://mesh-forge/o/r.git", "abc123", sha}, + "a branch as base": {"ssh://mesh-forge/o/r.git", "main", sha}, + "a short commit": {"ssh://mesh-forge/o/r.git", sha, "abc"}, + "an https remote": {"https://forge.example/o/r.git", sha, sha}, + } { + _, err := MergeInto(context.Background(), f.run, "/w", c.remote, c.base, c.commit) + if err == nil { + t.Errorf("%s accepted", name) + } + } + if len(f.calls) != 0 { + t.Errorf("git ran for a refused merge: %v", f.calls) + } + g := &fakeGit{rule: func(l string) forgekey.Ran { + if strings.Contains(l, "rev-parse HEAD") { + return forgekey.Ran{Stdout: strings.Repeat("f", 40) + "\n"} + } + return forgekey.Ran{} + }} + _, err := MergeInto(context.Background(), g.run, "/w", "ssh://mesh-forge/o/r.git", strings.Repeat("a", 40), sha) + if err == nil || !strings.Contains(err.Error(), "not at the commit") || len(g.calls) != 1 { + t.Errorf("a workspace at another commit: %v after %d commands", err, len(g.calls)) + } +} + +// 1674/AC4: the merge is made with the check merge's identity and flags, hooks off, and fetches with no credential. +func TestMergeIntoRunsTheCheckMergesCommands(t *testing.T) { + base := strings.Repeat("b", 40) + f := &fakeGit{rule: func(l string) forgekey.Ran { + switch { + case strings.HasSuffix(l, "rev-parse HEAD"): + return forgekey.Ran{Stdout: sha + "\n"} + case strings.Contains(l, "--is-shallow-repository"): + return forgekey.Ran{Stdout: "true\n"} + } + return forgekey.Ran{} + }} + if _, err := MergeInto(context.Background(), f.run, "/w", "ssh://mesh-forge/o/r.git", base, sha); err != nil { + t.Fatal(err) + } + all := strings.Join(f.calls, "\n") + for _, want := range []string{ + pre + "fetch --no-tags --unshallow origin " + base, + pre + "checkout -q --detach " + base, + "user.name=mesh merge check", "user.email=merge-check@mesh.invalid", "commit.gpgsign=false", "core.hooksPath=/dev/null", + "merge --no-ff --no-edit --no-verify --quiet -m", + pre + "diff --name-only --no-renames " + base, + } { + if !strings.Contains(all, want) { + t.Errorf("the commands lack %q:\n%s", want, all) + } + } + for _, c := range f.calls { + if strings.Contains(c, "https://") || !strings.Contains(c, "credential.helper= ") { + t.Errorf("command %q carries a credential or https", c) + } + } +} + +// A head already in the trunk merges into nothing: the merge is the trunk commit and no path changes. +func TestMergeIntoAHeadAlreadyInTheTrunkChangesNothing(t *testing.T) { + r := newMergeRig(t) + head := r.head(map[string]string{"b.txt": "head\n"}) + r.fork = head // the trunk continues from the head + base := r.advance(map[string]string{"a.txt": "one\ntwo\nthree\nfour\n"}) + r.fetch(head) + res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote()) + if err != nil || res.Merge != base || len(res.Changed) != 0 || len(res.Conflicts) != 0 { + t.Errorf("result = %+v, %v", res, err) + } +}