krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: e2e/cli_test.go · raw
1package e2e
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "testing"
11)
12
13func buildGitbayCLI(t *testing.T) string {
14 t.Helper()
15 bin := filepath.Join(t.TempDir(), "gitbay")
16 cmd := exec.Command("go", "build", "-o", bin, "gitbay.org/gitbay/cmd/gitbay")
17 cmd.Dir = ".."
18 if out, err := cmd.CombinedOutput(); err != nil {
19 t.Fatalf("build gitbay: %v\n%s", err, out)
20 }
21 return bin
22}
23
24// cli runs the forge binary with an isolated config home.
25type cli struct {
26 bin string
27 configDir string
28 inst *instance
29 key string
30}
31
32func (c *cli) run(t *testing.T, dir, stdin string, args ...string) (string, string, int) {
33 t.Helper()
34 cmd := exec.Command(c.bin, args...)
35 cmd.Dir = dir
36 cmd.Env = append(os.Environ(),
37 "XDG_CONFIG_HOME="+c.configDir,
38 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
39 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
40 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
41 "EDITOR=", // no editor in tests: bodies come from flags
42 )
43 if stdin != "" {
44 cmd.Stdin = strings.NewReader(stdin)
45 }
46 var out, errOut strings.Builder
47 cmd.Stdout = &out
48 cmd.Stderr = &errOut
49 err := cmd.Run()
50 code := 0
51 if ee, ok := err.(*exec.ExitError); ok {
52 code = ee.ExitCode()
53 } else if err != nil {
54 t.Fatalf("gitbay %v: %v", args, err)
55 }
56 return out.String(), errOut.String(), code
57}
58
59func (c *cli) must(t *testing.T, dir, stdin string, args ...string) string {
60 t.Helper()
61 out, errOut, code := c.run(t, dir, stdin, args...)
62 if code != 0 {
63 t.Fatalf("gitbay %v: exit %d\nstdout: %s\nstderr: %s", args, code, out, errOut)
64 }
65 return out
66}
67
68func TestCLI(t *testing.T) {
69 inst := startInstance(t)
70 aliceKey := inst.newKey(t, "alice")
71 inst.admin(t, "admin", "user", "create", "alice",
72 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
73
74 c := &cli{
75 bin: buildGitbayCLI(t),
76 configDir: t.TempDir(),
77 inst: inst,
78 key: aliceKey,
79 }
80
81 // Configure the instance, with ssh options so the test's throwaway key
82 // and known_hosts are used.
83 c.must(t, "", "", "remote", "add", "test", "127.0.0.1",
84 "--port", fmt.Sprint(inst.port),
85 "--ssh-option", "-i", "--ssh-option", aliceKey,
86 "--ssh-option", "-oIdentitiesOnly=yes",
87 "--ssh-option", "-oStrictHostKeyChecking=no",
88 "--ssh-option", "-oUserKnownHostsFile="+filepath.Join(inst.sshDir, "kh"),
89 "--ssh-option", "-oBatchMode=yes",
90 "--default")
91 if out := c.must(t, "", "", "remote", "list"); !strings.Contains(out, "test\tgit@127.0.0.1") || !strings.Contains(out, "(default)") {
92 t.Fatalf("remote list: %s", out)
93 }
94
95 // whoami through the CLI, JSON passthrough intact.
96 out := c.must(t, "", "", "auth", "whoami", "--json")
97 var env struct {
98 ProtocolVersion int `json:"protocol_version"`
99 Data struct {
100 Username string `json:"username"`
101 } `json:"data"`
102 }
103 if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.Username != "alice" || env.ProtocolVersion != 1 {
104 t.Fatalf("whoami via CLI: %v %s", err, out)
105 }
106
107 // Repo create + clone through the CLI.
108 c.must(t, "", "", "repo", "create", "alice/proj")
109 work := t.TempDir()
110 c.must(t, work, "", "repo", "clone", "alice/proj")
111 dir := filepath.Join(work, "proj")
112 if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil {
113 t.Fatal("clone did not produce a repo")
114 }
115
116 // Push some content (plain git, using the clone's remote).
117 cliGitEnv := inst.gitEnv(aliceKey)
118 os.WriteFile(filepath.Join(dir, "README"), []byte("hi\n"), 0o644)
119 mustGit(t, dir, cliGitEnv, "checkout", "-q", "-b", "main")
120 mustGit(t, dir, cliGitEnv, "add", ".")
121 mustGit(t, dir, cliGitEnv, "commit", "-q", "-m", "init")
122 mustGit(t, dir, cliGitEnv, "push", "-q", "origin", "main")
123
124 // Inside the clone, the repo argument is inferred from origin.
125 c.must(t, dir, "", "issue", "create", "--title", "inferred repo works", "--body", "body")
126 out = c.must(t, dir, "", "issue", "list")
127 if !strings.Contains(out, "inferred repo works") {
128 t.Fatalf("issue list in clone: %s", out)
129 }
130 // Explicit owner/name still works from anywhere.
131 out = c.must(t, "", "", "issue", "show", "alice/proj", "1")
132 if !strings.Contains(out, "inferred repo works") {
133 t.Fatalf("issue show explicit: %s", out)
134 }
135 // Outside a clone with no explicit repo: usage error, not a hang.
136 _, errOut, code := c.run(t, "", "", "issue", "list")
137 if code != 2 || !strings.Contains(errOut, "none inferable") {
138 t.Fatalf("bare issue list outside clone: exit %d, %s", code, errOut)
139 }
140
141 // Exit codes pass through: missing issue is 3.
142 if _, _, code := c.run(t, dir, "", "issue", "show", "99"); code != 3 {
143 t.Fatalf("missing issue via CLI: exit %d, want 3", code)
144 }
145
146 // MR flow: branch, push, create (inferred), checkout, merge.
147 mustGit(t, dir, cliGitEnv, "checkout", "-q", "-b", "feature")
148 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("feature\n"), 0o644)
149 mustGit(t, dir, cliGitEnv, "add", ".")
150 mustGit(t, dir, cliGitEnv, "commit", "-q", "-m", "feature work")
151 mustGit(t, dir, cliGitEnv, "push", "-q", "origin", "feature")
152 c.must(t, dir, "", "mr", "create", "--source", "feature", "--target", "main", "--title", "via cli")
153
154 // mr checkout uses the clone's own git; the MR ref comes from origin.
155 mustGit(t, dir, cliGitEnv, "checkout", "-q", "main")
156 cmd := exec.Command(c.bin, "mr", "checkout", "1")
157 cmd.Dir = dir
158 cmd.Env = append(cliGitEnv, "XDG_CONFIG_HOME="+c.configDir)
159 if out, err := cmd.CombinedOutput(); err != nil {
160 t.Fatalf("mr checkout: %v\n%s", err, out)
161 }
162 branch := strings.TrimSpace(mustGit(t, dir, cliGitEnv, "rev-parse", "--abbrev-ref", "HEAD"))
163 if branch != "mr/1" {
164 t.Fatalf("mr checkout branch = %s", branch)
165 }
166 if _, err := os.Stat(filepath.Join(dir, "f.txt")); err != nil {
167 t.Fatal("mr checkout content missing")
168 }
169
170 c.must(t, dir, "", "mr", "merge", "1")
171 out = c.must(t, dir, "", "mr", "show", "1")
172 if !strings.Contains(out, "merged") {
173 t.Fatalf("mr not merged: %s", out)
174 }
175
176 // keys add reads the public key from CLI stdin.
177 secondKey := inst.newKey(t, "alice2")
178 pub, _ := os.ReadFile(secondKey + ".pub")
179 c.must(t, "", string(pub), "auth", "keys", "add", "--scope", "git")
180 if out = c.must(t, "", "", "auth", "keys", "list"); len(strings.Split(strings.TrimSpace(out), "\n")) != 2 {
181 t.Fatalf("keys list: %s", out)
182 }
183
184 // forge init: new local project, repo created server-side, origin set.
185 proj := filepath.Join(t.TempDir(), "newthing")
186 os.MkdirAll(proj, 0o755)
187 c.must(t, proj, "", "init", "--private")
188 originOut := mustGit(t, proj, cliGitEnv, "remote", "get-url", "origin")
189 if !strings.Contains(originOut, "/alice/newthing.git") {
190 t.Fatalf("init origin: %s", originOut)
191 }
192 if out = c.must(t, "", "", "repo", "show", "alice/newthing"); !strings.Contains(out, "private") {
193 t.Fatalf("init-created repo: %s", out)
194 }
195
196 // A clone whose origin points at a FOREIGN host must not hijack the
197 // command: the configured default instance is used, and repo inference
198 // is dropped rather than guessed across hosts.
199 foreign := filepath.Join(t.TempDir(), "f")
200 os.MkdirAll(foreign, 0o755)
201 mustGit(t, foreign, cliGitEnv, "init", "-q")
202 mustGit(t, foreign, cliGitEnv, "remote", "add", "origin", "git@github.example:someone/thing.git")
203 out = c.must(t, foreign, "", "auth", "whoami")
204 if strings.TrimSpace(out) != "alice" {
205 t.Fatalf("foreign-origin whoami hit the wrong host: %q", out)
206 }
207 _, errOut2, code2 := c.run(t, foreign, "", "issue", "list")
208 if code2 != 2 || !strings.Contains(errOut2, "none inferable") {
209 t.Fatalf("foreign-origin repo inference: exit %d, %s", code2, errOut2)
210 }
211
212 // Man pages and completions generate.
213 manDir := t.TempDir()
214 c.must(t, "", "", "man", "--dir", manDir)
215 if entries, _ := os.ReadDir(manDir); len(entries) < 10 {
216 t.Fatalf("man pages: only %d generated", len(entries))
217 }
218 if out = c.must(t, "", "", "completion", "zsh"); !strings.Contains(out, "compdef") {
219 t.Fatal("zsh completion missing")
220 }
221}