krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: e2e/mr_test.go · raw
1package e2e
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "golang.org/x/crypto/ssh"
11
12 "gitbay.org/gitbay/internal/sig"
13)
14
15type mrShow struct {
16 Number int64 `json:"number"`
17 State string `json:"state"`
18 Source string `json:"source"`
19 HeadSHA string `json:"head_sha"`
20 Reviews []struct {
21 Reviewer string `json:"reviewer"`
22 Verdict string `json:"verdict"`
23 Stale bool `json:"stale"`
24 } `json:"reviews"`
25}
26
27func (i *instance) mrShow(t *testing.T, key, repo, n string) mrShow {
28 t.Helper()
29 out, errOut, code := i.ssh(t, key, "", "mr", "show", repo, n, "--json")
30 if code != 0 {
31 t.Fatalf("mr show: exit %d, %s", code, errOut)
32 }
33 var env struct {
34 Data mrShow `json:"data"`
35 }
36 if err := json.Unmarshal([]byte(out), &env); err != nil {
37 t.Fatalf("mr show JSON: %v\n%s", err, out)
38 }
39 return env.Data
40}
41
42func TestMergeRequests(t *testing.T) {
43 inst := startInstance(t)
44
45 aliceKey := inst.newKey(t, "alice")
46 bobKey := inst.newKey(t, "bob")
47 inst.admin(t, "admin", "user", "create", "alice",
48 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
49 inst.admin(t, "admin", "user", "create", "bob",
50 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
51
52 // Alice's upstream repo with an initial commit.
53 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
54 t.Fatalf("repo create: %s", errOut)
55 }
56 aliceEnv := inst.gitEnv(aliceKey)
57 aliceWork := t.TempDir()
58 mustGit(t, aliceWork, aliceEnv, "clone", inst.sshURL("alice/lib"), "w")
59 aliceDir := filepath.Join(aliceWork, "w")
60 os.WriteFile(filepath.Join(aliceDir, "lib.txt"), []byte("v1\n"), 0o644)
61 mustGit(t, aliceDir, aliceEnv, "checkout", "-q", "-b", "main")
62 mustGit(t, aliceDir, aliceEnv, "add", ".")
63 mustGit(t, aliceDir, aliceEnv, "commit", "-q", "-m", "base")
64 mustGit(t, aliceDir, aliceEnv, "push", "-q", "origin", "main")
65
66 // Bob forks and pushes a feature branch to his fork.
67 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/lib"); code != 0 {
68 t.Fatalf("fork: %s", errOut)
69 }
70 bobEnv := inst.gitEnv(bobKey)
71 bobWork := t.TempDir()
72 mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("bob/lib"), "w")
73 bobDir := filepath.Join(bobWork, "w")
74 mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feature", "origin/main")
75 os.WriteFile(filepath.Join(bobDir, "feature.txt"), []byte("bob's work\n"), 0o644)
76 mustGit(t, bobDir, bobEnv, "add", ".")
77 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "add feature")
78 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feature")
79
80 // MR from the fork into alice/lib.
81 out, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
82 "--source", "bob/lib:feature", "--target", "main", "--title", "'add feature'", "--json")
83 if code != 0 {
84 t.Fatalf("mr create: %s", errOut)
85 }
86 if !strings.Contains(out, `"number":1`) {
87 t.Fatalf("mr create output: %s", out)
88 }
89
90 // The MR head ref is fetchable from the TARGET repo by a reader.
91 fetchDir := t.TempDir()
92 mustGit(t, fetchDir, aliceEnv, "clone", "-q", inst.sshURL("alice/lib"), "c")
93 mustGit(t, filepath.Join(fetchDir, "c"), aliceEnv, "fetch", "-q", "origin", "refs/merge-requests/1/head")
94
95 // Alice approves.
96 if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "review", "alice/lib", "1", "--approve"); code != 0 {
97 t.Fatalf("review: %s", errOut)
98 }
99 show := inst.mrShow(t, aliceKey, "alice/lib", "1")
100 if len(show.Reviews) != 1 || show.Reviews[0].Stale {
101 t.Fatalf("fresh review wrong: %+v", show.Reviews)
102 }
103 firstHead := show.HeadSHA
104
105 // Bob force-pushes the source branch: the MR head updates and the
106 // review goes stale.
107 mustGit(t, bobDir, bobEnv, "commit", "-q", "--amend", "-m", "add feature (amended)")
108 mustGit(t, bobDir, bobEnv, "push", "-q", "--force", "origin", "feature")
109 show = inst.mrShow(t, aliceKey, "alice/lib", "1")
110 if show.HeadSHA == firstHead {
111 t.Fatal("MR head not updated after force-push")
112 }
113 if len(show.Reviews) != 1 || !show.Reviews[0].Stale {
114 t.Fatalf("review not marked stale: %+v", show.Reviews)
115 }
116
117 // Target advances, so fast-forward is impossible: default merge makes a
118 // merge commit authored by the merging user.
119 mustGit(t, aliceDir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "mainline moves on")
120 mustGit(t, aliceDir, aliceEnv, "push", "-q", "origin", "main")
121 out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "1", "--json")
122 if code != 0 {
123 t.Fatalf("merge: exit %d, %s", code, errOut)
124 }
125 if !strings.Contains(out, `"strategy":"merge"`) {
126 t.Fatalf("expected merge-commit strategy: %s", out)
127 }
128 if inst.mrShow(t, aliceKey, "alice/lib", "1").State != "merged" {
129 t.Fatal("MR not marked merged")
130 }
131 mustGit(t, aliceDir, aliceEnv, "pull", "-q", "origin", "main")
132 if _, err := os.Stat(filepath.Join(aliceDir, "feature.txt")); err != nil {
133 t.Fatal("merged content missing from main")
134 }
135 // The merge commit carries the merging user's identity and is unsigned.
136 tip := strings.TrimSpace(mustGit(t, aliceDir, aliceEnv, "log", "-1", "--format=%an <%ae>"))
137 if tip != "alice <alice@example.test>" {
138 t.Fatalf("merge commit identity: %q", tip)
139 }
140 logOut, _, _ := inst.ssh(t, aliceKey, "", "repo", "log", "alice/lib", "--limit", "1")
141 if !strings.Contains(logOut, "unsigned") {
142 t.Fatalf("merge commit should display unsigned:\n%s", logOut)
143 }
144
145 // --- require_signed_commits: push-time and merge-time policy ---
146
147 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "create", "alice/sec"); code != 0 {
148 t.Fatalf("create sec: %s", errOut)
149 }
150 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/sec", "on"); code != 0 {
151 t.Fatalf("require-signed: %s", errOut)
152 }
153 secWork := t.TempDir()
154 mustGit(t, secWork, aliceEnv, "clone", inst.sshURL("alice/sec"), "w")
155 secDir := filepath.Join(secWork, "w")
156
157 // Unsigned push is rejected at pre-receive.
158 os.WriteFile(filepath.Join(secDir, "a.txt"), []byte("a\n"), 0o644)
159 mustGit(t, secDir, aliceEnv, "checkout", "-q", "-b", "main")
160 mustGit(t, secDir, aliceEnv, "add", ".")
161 mustGit(t, secDir, aliceEnv, "commit", "-q", "-m", "unsigned attempt")
162 pushOut, pushCode := gitRun(t, secDir, aliceEnv, "push", "origin", "main")
163 if pushCode == 0 {
164 t.Fatal("unsigned push accepted into require-signed repo")
165 }
166 if !strings.Contains(pushOut, "requires signed commits") {
167 t.Fatalf("unsigned push message:\n%s", pushOut)
168 }
169
170 // SSHSIG-signed commits go through.
171 raw, _ := os.ReadFile(aliceKey)
172 signer, err := ssh.ParsePrivateKey(raw)
173 if err != nil {
174 t.Fatal(err)
175 }
176 signAlice := func(p []byte) string {
177 s, err := sig.MarshalSSHSig(signer, p)
178 if err != nil {
179 t.Fatal(err)
180 }
181 return string(s)
182 }
183 buildCommits(t, secDir, aliceEnv, []commitSpec{
184 {authorEmail: "alice@example.test", subject: "signed base", sign: signAlice},
185 })
186 mustGit(t, secDir, aliceEnv, "push", "-q", "origin", "main")
187
188 // A signed feature branch and a same-repo MR.
189 base := strings.TrimSpace(mustGit(t, secDir, aliceEnv, "rev-parse", "main"))
190 tree := strings.TrimSpace(mustGit(t, secDir, aliceEnv, "rev-parse", "main^{tree}"))
191 buildChain(t, secDir, aliceEnv, tree, base, []commitSpec{
192 {authorEmail: "alice@example.test", subject: "signed feature", sign: signAlice},
193 })
194 // buildChain moved refs/heads/main; restore and use a feature branch.
195 feat := strings.TrimSpace(mustGit(t, secDir, aliceEnv, "rev-parse", "main"))
196 mustGit(t, secDir, aliceEnv, "update-ref", "refs/heads/main", base)
197 mustGit(t, secDir, aliceEnv, "update-ref", "refs/heads/feat", feat)
198 mustGit(t, secDir, aliceEnv, "push", "-q", "origin", "feat")
199
200 if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "create", "alice/sec",
201 "--source", "feat", "--target", "main", "--title", "'signed work'"); code != 0 {
202 t.Fatalf("sec mr create: %s", errOut)
203 }
204
205 // An explicit merge-commit strategy is refused with exit 4 and rebase
206 // instructions.
207 _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/sec", "1", "--strategy", "merge")
208 if code != 4 {
209 t.Fatalf("merge-commit on require-signed: exit %d (want 4), %s", code, errOut)
210 }
211 if !strings.Contains(errOut, "only fast-forward") || !strings.Contains(errOut, "rebase") {
212 t.Fatalf("refusal message: %s", errOut)
213 }
214
215 // Fast-forward merge of verified commits succeeds.
216 out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/sec", "1", "--json")
217 if code != 0 {
218 t.Fatalf("ff merge: %s", errOut)
219 }
220 if !strings.Contains(out, `"strategy":"ff"`) {
221 t.Fatalf("expected ff: %s", out)
222 }
223
224 // --- fork deletion leaves the MR diff intact ---
225
226 mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "second", "origin/main")
227 os.WriteFile(filepath.Join(bobDir, "second.txt"), []byte("more\n"), 0o644)
228 mustGit(t, bobDir, bobEnv, "add", ".")
229 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "second feature")
230 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "second")
231 if _, errOut, code = inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
232 "--source", "bob/lib:second", "--target", "main", "--title", "'second'"); code != 0 {
233 t.Fatalf("mr 2 create: %s", errOut)
234 }
235 if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "delete", "bob/lib", "--yes"); code != 0 {
236 t.Fatalf("fork delete: %s", errOut)
237 }
238 show = inst.mrShow(t, aliceKey, "alice/lib", "2")
239 if show.State != "source_gone" {
240 t.Fatalf("MR 2 state after fork deletion: %s", show.State)
241 }
242 diffOut, errOut, code := inst.ssh(t, aliceKey, "", "mr", "diff", "alice/lib", "2")
243 if code != 0 || !strings.Contains(diffOut, "second.txt") {
244 t.Fatalf("diff after fork deletion: exit %d\n%s%s", code, diffOut, errOut)
245 }
246 // And it can still be merged: the target owns the objects.
247 if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "2"); code != 0 {
248 t.Fatalf("merge after fork deletion: %s", errOut)
249 }
250
251 // Web read views.
252 status, body := inst.get(t, "/alice/lib/mrs?state=all")
253 if status != 200 || !strings.Contains(body, "add feature") || !strings.Contains(body, "second") {
254 t.Fatalf("mrs page: %d\n%s", status, body)
255 }
256 status, body = inst.get(t, "/alice/lib/mrs/1")
257 if status != 200 || !strings.Contains(body, "stale") || !strings.Contains(body, "merged") {
258 t.Fatalf("mr detail: %d\n%s", status, body)
259 }
260}