krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

main: 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	// Merged MRs keep their historical diff: after the fast-forward the
252	// live merge-base equals the head, so the recorded base must be used.
253	diffOut2, _, code := inst.ssh(t, aliceKey, "", "mr", "diff", "alice/lib", "1")
254	if code != 0 || !strings.Contains(diffOut2, "feature.txt") {
255		t.Fatalf("post-merge diff empty: %d\n%s", code, diffOut2)
256	}
257
258	// Web read views.
259	status, body := inst.get(t, "/alice/lib/mrs?state=all")
260	if status != 200 || !strings.Contains(body, "add feature") || !strings.Contains(body, "second") {
261		t.Fatalf("mrs page: %d\n%s", status, body)
262	}
263	status, body = inst.get(t, "/alice/lib/mrs/1")
264	if status != 200 || !strings.Contains(body, "stale") || !strings.Contains(body, "merged") {
265		t.Fatalf("mr detail: %d\n%s", status, body)
266	}
267	if !strings.Contains(body, "feature.txt") {
268		t.Fatalf("merged MR web diff empty:\n%s", body)
269	}
270}