| @@ -0,0 +1,145 @@ |
| 1 | package e2e |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | // `mr rebase` replays a merge request's branch onto its target and |
| 12 | // re-pushes it, which is what makes a fast-forward merge possible again |
| 13 | // after the target has moved. The git work is local, so the replayed |
| 14 | // commits are signed by whatever the user's git config signs with and the |
| 15 | // server is never asked to vouch for a commit it did not receive already |
| 16 | // signed (#175). |
| 17 | func TestCLIMRRebase(t *testing.T) { |
| 18 | inst := startInstance(t) |
| 19 | aliceKey := inst.newKey(t, "alice") |
| 20 | inst.admin(t, "admin", "user", "create", "alice", |
| 21 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") |
| 22 | |
| 23 | c := &cli{bin: buildGitbayCLI(t), configDir: t.TempDir(), inst: inst, key: aliceKey} |
| 24 | c.must(t, "", "", "remote", "add", "test", "127.0.0.1", |
| 25 | "--port", fmt.Sprint(inst.port), |
| 26 | "--ssh-option", "-i", "--ssh-option", aliceKey, |
| 27 | "--ssh-option", "-oIdentitiesOnly=yes", |
| 28 | "--ssh-option", "-oStrictHostKeyChecking=no", |
| 29 | "--ssh-option", "-oUserKnownHostsFile="+filepath.Join(inst.sshDir, "kh"), |
| 30 | "--ssh-option", "-oBatchMode=yes", |
| 31 | "--default") |
| 32 | c.must(t, "", "", "repo", "create", "alice/app") |
| 33 | |
| 34 | env := inst.gitEnv(aliceKey) |
| 35 | work := t.TempDir() |
| 36 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") |
| 37 | dir := filepath.Join(work, "w") |
| 38 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) |
| 39 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") |
| 40 | mustGit(t, dir, env, "add", ".") |
| 41 | mustGit(t, dir, env, "commit", "-q", "-m", "base") |
| 42 | mustGit(t, dir, env, "push", "-q", "origin", "main") |
| 43 | |
| 44 | // A branch off main, and a merge request for it. |
| 45 | mustGit(t, dir, env, "checkout", "-q", "-b", "feat") |
| 46 | os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644) |
| 47 | mustGit(t, dir, env, "add", ".") |
| 48 | mustGit(t, dir, env, "commit", "-q", "-m", "the change") |
| 49 | mustGit(t, dir, env, "push", "-q", "origin", "feat") |
| 50 | c.must(t, dir, "", "mr", "create", "alice/app", |
| 51 | "--source", "feat", "--target", "main", "--title", "change") |
| 52 | |
| 53 | // main moves on, so feat is no longer a fast-forward. |
| 54 | mustGit(t, dir, env, "checkout", "-q", "main") |
| 55 | os.WriteFile(filepath.Join(dir, "c.txt"), []byte("c\n"), 0o644) |
| 56 | mustGit(t, dir, env, "add", ".") |
| 57 | mustGit(t, dir, env, "commit", "-q", "-m", "moved on") |
| 58 | mustGit(t, dir, env, "push", "-q", "origin", "main") |
| 59 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/app", "1", "--strategy", "ff"); code == 0 { |
| 60 | t.Fatal("fast-forward merged a diverged branch") |
| 61 | } else if !strings.Contains(errOut, "fast-forward not possible") { |
| 62 | t.Fatalf("unexpected refusal: %s", errOut) |
| 63 | } |
| 64 | |
| 65 | // Rebase, and the same merge now lands. |
| 66 | out, errOut, code := c.run(t, dir, "", "mr", "rebase", "1") |
| 67 | if code != 0 { |
| 68 | t.Fatalf("mr rebase: exit %d\n%s\n%s", code, out, errOut) |
| 69 | } |
| 70 | if !strings.Contains(out, "gitbay mr merge 1") { |
| 71 | t.Errorf("rebase does not name the next step: %s", out) |
| 72 | } |
| 73 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/app", "1", "--strategy", "ff"); code != 0 { |
| 74 | t.Fatalf("fast-forward still refused after a rebase: %s", errOut) |
| 75 | } |
| 76 | |
| 77 | // The rebase moved the branch rather than merging main into it: the |
| 78 | // change is one commit on top of what main had. |
| 79 | out, _, _ = inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json") |
| 80 | if !strings.Contains(out, `"state":"merged"`) { |
| 81 | t.Fatalf("merge request not merged:\n%s", out) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // The guards: a dirty tree, and a source in a fork this clone cannot push. |
| 86 | func TestCLIMRRebaseRefusals(t *testing.T) { |
| 87 | inst := startInstance(t) |
| 88 | aliceKey := inst.newKey(t, "alice") |
| 89 | bobKey := inst.newKey(t, "bob") |
| 90 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") |
| 91 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") |
| 92 | |
| 93 | c := &cli{bin: buildGitbayCLI(t), configDir: t.TempDir(), inst: inst, key: aliceKey} |
| 94 | c.must(t, "", "", "remote", "add", "test", "127.0.0.1", |
| 95 | "--port", fmt.Sprint(inst.port), |
| 96 | "--ssh-option", "-i", "--ssh-option", aliceKey, |
| 97 | "--ssh-option", "-oIdentitiesOnly=yes", |
| 98 | "--ssh-option", "-oStrictHostKeyChecking=no", |
| 99 | "--ssh-option", "-oUserKnownHostsFile="+filepath.Join(inst.sshDir, "kh"), |
| 100 | "--ssh-option", "-oBatchMode=yes", |
| 101 | "--default") |
| 102 | c.must(t, "", "", "repo", "create", "alice/app") |
| 103 | |
| 104 | env := inst.gitEnv(aliceKey) |
| 105 | work := t.TempDir() |
| 106 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") |
| 107 | dir := filepath.Join(work, "w") |
| 108 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) |
| 109 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") |
| 110 | mustGit(t, dir, env, "add", ".") |
| 111 | mustGit(t, dir, env, "commit", "-q", "-m", "base") |
| 112 | mustGit(t, dir, env, "push", "-q", "origin", "main") |
| 113 | |
| 114 | // bob forks, pushes, and opens a merge request from the fork. |
| 115 | inst.ssh(t, bobKey, "", "repo", "fork", "alice/app") |
| 116 | benv := inst.gitEnv(bobKey) |
| 117 | bwork := t.TempDir() |
| 118 | mustGit(t, bwork, benv, "clone", inst.sshURL("bob/app"), "w") |
| 119 | bdir := filepath.Join(bwork, "w") |
| 120 | mustGit(t, bdir, benv, "checkout", "-q", "-b", "feat") |
| 121 | os.WriteFile(filepath.Join(bdir, "b.txt"), []byte("b\n"), 0o644) |
| 122 | mustGit(t, bdir, benv, "add", ".") |
| 123 | mustGit(t, bdir, benv, "commit", "-q", "-m", "from the fork") |
| 124 | mustGit(t, bdir, benv, "push", "-q", "origin", "feat") |
| 125 | inst.ssh(t, bobKey, "", "mr", "create", "alice/app", |
| 126 | "--source", "bob/app:feat", "--target", "main", "--title", "forked") |
| 127 | |
| 128 | // Alice's clone of the target cannot rebase a branch that lives in |
| 129 | // bob's fork, and says which repository to do it in. |
| 130 | _, errOut, code := c.run(t, dir, "", "mr", "rebase", "1") |
| 131 | if code == 0 { |
| 132 | t.Fatal("rebased a fork's branch from the target's clone") |
| 133 | } |
| 134 | if !strings.Contains(errOut, "bob/app:feat") { |
| 135 | t.Errorf("refusal does not name the fork: %s", errOut) |
| 136 | } |
| 137 | |
| 138 | // A dirty tree is refused before any round trip, so the merge request |
| 139 | // number never has to be valid for this one. |
| 140 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("edited\n"), 0o644) |
| 141 | if _, errOut, code := c.run(t, dir, "", "mr", "rebase", "1"); code == 0 || |
| 142 | !strings.Contains(errOut, "uncommitted changes") { |
| 143 | t.Errorf("dirty tree not refused: exit %d %s", code, errOut) |
| 144 | } |
| 145 | } |