//go:build integration package buildstep import ( "context" "os" "os/exec" "path/filepath" "reflect" "strings" "testing" "git.novox.be/novox/mesh-sdk/go/forgekey" ) // The merge on real git repositories starts git processes, so it is an integration test (the unit level keeps its bound, // issue 895); the unit tests of merge_test.go prove the commands and the refusals with a fake runner. 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") } } // 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) } }