detached ee46681

test(cli): run the remote CLI end to end against a real forge

commit
ee466818f44799bfed3d3ad0015175f8ff82c078
author
brice · · 2026-10-10 09:45 UTC
parent
b217ad3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +502 -0
A cmd/ermite/cli_e2e_test.go +502 -0
@@ -0,0 +1,502 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "bufio"
7+ "encoding/json"
8+ "os/exec"
9+ "slices"
10+ "strings"
11+ "syscall"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/adapters/client"
19+ "bnema.dev/ermite/internal/domain"
20+)
21+
22+// cliHarness drives the CLI against a real forge.
23+type cliHarness struct {
24+ *harness
25+ url string
26+}
27+
28+func newCLIHarness(t *testing.T) *cliHarness {
29+ t.Helper()
30+ h := newHarness(t, harnessOpts{})
31+ return &cliHarness{harness: h, url: h.srv.URL}
32+}
33+
34+// vars is the CLI environment for a token ("" is anonymous).
35+func (c *cliHarness) vars(secret string) map[string]string {
36+ v := map[string]string{client.EnvURL: c.url}
37+ if secret != "" {
38+ v[client.EnvToken] = secret
39+ }
40+ return v
41+}
42+
43+// cli runs the command tree in process.
44+func (c *cliHarness) cli(t *testing.T, secret string, args ...string) result {
45+ t.Helper()
46+ return runCLI(t, c.vars(secret), "", args...)
47+}
48+
49+// bin runs the built binary; stdout and stderr are returned with the exit code.
50+func (c *cliHarness) bin(t *testing.T, secret string, args ...string) (stdout, stderr string, code int) {
51+ t.Helper()
52+ var env []string
53+ for k, v := range c.vars(secret) {
54+ env = append(env, k+"="+v)
55+ }
56+ return runBinary(t, env, args...)
57+}
58+
59+// keys returns the sorted field names of a JSON object.
60+func keys(t *testing.T, raw string) []string {
61+ t.Helper()
62+ var m map[string]json.RawMessage
63+ require.NoError(t, json.Unmarshal([]byte(raw), &m), raw)
64+ var ks []string
65+ for k := range m {
66+ ks = append(ks, k)
67+ }
68+ slices.Sort(ks)
69+ return ks
70+}
71+
72+func decode[T any](t *testing.T, raw string) T {
73+ t.Helper()
74+ var v T
75+ require.NoError(t, json.Unmarshal([]byte(raw), &v), raw)
76+ return v
77+}
78+
79+// requireOneLineError checks a failed run: exit 1, a single "ermite: …" line.
80+func requireOneLineError(t *testing.T, stderr string, code int, contains ...string) {
81+ t.Helper()
82+ assert.Equal(t, 1, code)
83+ assert.Equal(t, 1, strings.Count(stderr, "\n"), "one line: %q", stderr)
84+ assert.True(t, strings.HasPrefix(stderr, "ermite: "), stderr)
85+ for _, s := range contains {
86+ assert.Contains(t, stderr, s)
87+ }
88+}
89+
90+// watchProc is a running `ermite watch --json`.
91+type watchProc struct {
92+ cmd *exec.Cmd
93+ lines chan string
94+ stderr *syncBuffer
95+ done chan error
96+}
97+
98+// startWatch runs the binary and waits until the server has the stream.
99+func (c *cliHarness) startWatch(t *testing.T, secret string, args ...string) *watchProc {
100+ t.Helper()
101+ cmd := exec.Command(binary(t), append([]string{"watch", "--json"}, args...)...)
102+ cmd.Env = baseEnv()
103+ for k, v := range c.vars(secret) {
104+ cmd.Env = append(cmd.Env, k+"="+v)
105+ }
106+ w := &watchProc{cmd: cmd, lines: make(chan string, 64), stderr: &syncBuffer{}, done: make(chan error, 1)}
107+ cmd.Stderr = w.stderr
108+ out, err := cmd.StdoutPipe()
109+ require.NoError(t, err)
110+ require.NoError(t, cmd.Start())
111+ go func() {
112+ sc := bufio.NewScanner(out)
113+ for sc.Scan() {
114+ w.lines <- sc.Text()
115+ }
116+ close(w.lines)
117+ w.done <- cmd.Wait()
118+ }()
119+ t.Cleanup(func() { _ = cmd.Process.Kill() })
120+
121+ require.Eventually(t, func() bool { return c.forge.EventLog.Watchers() >= 1 }, 10*time.Second, 10*time.Millisecond,
122+ "the watch never reached the server; stderr: %s", w.stderr.String())
123+ return w
124+}
125+
126+// next decodes the next event line or fails after d.
127+func (w *watchProc) next(t *testing.T, d time.Duration) (raw string) {
128+ t.Helper()
129+ select {
130+ case l, ok := <-w.lines:
131+ if !ok {
132+ t.Fatalf("watch ended; stderr: %s", w.stderr.String())
133+ }
134+ return l
135+ case <-time.After(d):
136+ t.Fatalf("no event within %v; stderr: %s", d, w.stderr.String())
137+ }
138+ return ""
139+}
140+
141+// interrupt sends SIGINT and returns the exit code.
142+func (w *watchProc) interrupt(t *testing.T) int {
143+ t.Helper()
144+ require.NoError(t, w.cmd.Process.Signal(syscall.SIGINT))
145+ select {
146+ case err := <-w.done:
147+ if err == nil {
148+ return 0
149+ }
150+ var exit *exec.ExitError
151+ require.ErrorAs(t, err, &exit)
152+ return exit.ExitCode()
153+ case <-time.After(10 * time.Second):
154+ t.Fatal("watch did not stop on SIGINT")
155+ }
156+ return -1
157+}
158+
159+type tokenOut struct {
160+ ID string `json:"id"`
161+ Label string `json:"label"`
162+ Scopes []string `json:"scopes"`
163+ Repos []string `json:"repos"`
164+ ExpiresAt *string `json:"expires_at"`
165+ CreatedAt *string `json:"created_at"`
166+ LastUsedAt *string `json:"last_used_at"`
167+ Secret string `json:"secret"`
168+}
169+
170+type repoOut struct {
171+ Name string `json:"name"`
172+ Description string `json:"description"`
173+ Visibility string `json:"visibility"`
174+ DefaultBranch string `json:"default_branch"`
175+ CreatedAt *string `json:"created_at"`
176+ PushedAt *string `json:"pushed_at"`
177+}
178+
179+type eventOut struct {
180+ ID string `json:"id"`
181+ Time *string `json:"time"`
182+ Type string `json:"type"`
183+ Repo string `json:"repo"`
184+ Repository *repoOut
185+ RefUpdates []struct {
186+ Ref string `json:"ref"`
187+ OldID string `json:"old_id"`
188+ NewID string `json:"new_id"`
189+ } `json:"ref_updates"`
190+}
191+
192+var repoKeys = []string{"created_at", "default_branch", "description", "name", "pushed_at", "visibility"}
193+
194+// AC6: the CLI end to end against a real forge.
195+func TestAC6CLIEndToEnd(t *testing.T) {
196+ c := newCLIHarness(t)
197+
198+ // Root creates the daily token with the cli preset (binary: secret alone on stdout).
199+ stdout, stderr, code := c.bin(t, adminToken, "token", "create", "daily", "--preset", "cli")
200+ require.Equal(t, 0, code, stderr)
201+ secret := strings.TrimSpace(stdout)
202+ require.True(t, strings.HasPrefix(secret, "ermite_"), stdout)
203+ assert.Equal(t, 1, strings.Count(stdout, "\n"), "the secret is the only thing on stdout")
204+ assert.Contains(t, stderr, "created token")
205+ assert.NotContains(t, stderr, secret)
206+
207+ // Follow events with that token, through the binary.
208+ w := c.startWatch(t, secret)
209+
210+ // Create. Private by default; here public with a description.
211+ res := c.cli(t, secret, "repo", "create", "proj", "--description", "first", "--visibility", "public", "--json").ok(t)
212+ assert.Equal(t, repoKeys, keys(t, res.stdout))
213+ created := decode[repoOut](t, res.stdout)
214+ assert.Equal(t, "proj", created.Name)
215+ assert.Equal(t, "first", created.Description)
216+ assert.Equal(t, "public", created.Visibility)
217+ assert.Equal(t, "main", created.DefaultBranch)
218+ require.NotNil(t, created.CreatedAt)
219+ _, err := time.Parse(time.RFC3339, *created.CreatedAt)
220+ require.NoError(t, err)
221+ assert.Nil(t, created.PushedAt, "no push yet")
222+
223+ ev := decode[eventOut](t, w.next(t, time.Second))
224+ assert.Equal(t, "repo_created", ev.Type)
225+ assert.Equal(t, "proj", ev.Repo)
226+ require.NotNil(t, ev.Repository)
227+ assert.Equal(t, "public", ev.Repository.Visibility)
228+ assert.NotEmpty(t, ev.ID)
229+ assert.Contains(t, ev.ID, "-", "ids are <boot>-<seq>, opaque to the CLI")
230+ require.NotNil(t, ev.Time)
231+
232+ // Creating it again, or with a bad name, is a one-line friendly error.
233+ stdout, stderr, code = c.bin(t, secret, "repo", "create", "proj")
234+ assert.Empty(t, stdout)
235+ requireOneLineError(t, stderr, code, "already exists")
236+ stdout, stderr, code = c.bin(t, secret, "repo", "create", "Bad_Name")
237+ assert.Empty(t, stdout)
238+ requireOneLineError(t, stderr, code, "invalid request")
239+
240+ // Editing the default branch to one that does not exist fails and changes nothing.
241+ _, stderr, code = c.bin(t, secret, "repo", "edit", "proj", "--default-branch", "dev", "--description", "never")
242+ requireOneLineError(t, stderr, code, "precondition failed")
243+ res = c.cli(t, secret, "repo", "show", "proj", "--json").ok(t)
244+ assert.Equal(t, "first", decode[repoOut](t, res.stdout).Description)
245+
246+ // Push two branches with git, using the same token.
247+ wt := c.workTree("main", "README.md")
248+ c.mustGit(wt, "checkout", "-q", "-b", "dev")
249+ c.writeFile(wt, "dev.txt", []byte("dev\n"))
250+ c.mustGit(wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "main", "dev")
251+
252+ ev = decode[eventOut](t, w.next(t, time.Second))
253+ assert.Equal(t, "pushed", ev.Type)
254+ assert.Equal(t, "proj", ev.Repo)
255+ var refs []string
256+ for _, u := range ev.RefUpdates {
257+ refs = append(refs, u.Ref)
258+ assert.Empty(t, u.OldID)
259+ assert.Len(t, u.NewID, 40)
260+ }
261+ assert.ElementsMatch(t, []string{"refs/heads/main", "refs/heads/dev"}, refs)
262+
263+ // Edit description, visibility and default branch in one call.
264+ res = c.cli(t, secret, "repo", "edit", "proj", "--description", "the project", "--visibility", "private", "--default-branch", "dev", "--json").ok(t)
265+ assert.Equal(t, repoKeys, keys(t, res.stdout))
266+ edited := decode[repoOut](t, res.stdout)
267+ assert.Equal(t, "the project", edited.Description)
268+ assert.Equal(t, "private", edited.Visibility)
269+ assert.Equal(t, "dev", edited.DefaultBranch)
270+ require.NotNil(t, edited.PushedAt, "set by the push")
271+ assert.Equal(t, "refs/heads/dev", c.bareGit("proj", "symbolic-ref", "HEAD"))
272+
273+ ev = decode[eventOut](t, w.next(t, time.Second))
274+ assert.Equal(t, "repo_updated", ev.Type)
275+ require.NotNil(t, ev.Repository)
276+ assert.Equal(t, "dev", ev.Repository.DefaultBranch)
277+ assert.Equal(t, "private", ev.Repository.Visibility)
278+
279+ // Clearing the description takes an explicit empty value.
280+ res = c.cli(t, secret, "repo", "edit", "proj", "--description", "", "--json").ok(t)
281+ assert.Empty(t, decode[repoOut](t, res.stdout).Description)
282+ assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
283+
284+ // list and show: same shape, human tables too.
285+ res = c.cli(t, secret, "repo", "list", "--json").ok(t)
286+ list := decode[[]json.RawMessage](t, res.stdout)
287+ require.Len(t, list, 1)
288+ assert.Equal(t, repoKeys, keys(t, string(list[0])))
289+ assert.Equal(t, "proj", decode[repoOut](t, string(list[0])).Name)
290+
291+ res = c.cli(t, secret, "repo", "show", "proj", "--json").ok(t)
292+ assert.Equal(t, repoKeys, keys(t, res.stdout))
293+ shown := decode[repoOut](t, res.stdout)
294+ assert.Equal(t, "dev", shown.DefaultBranch)
295+ assert.Equal(t, "private", shown.Visibility)
296+
297+ res = c.cli(t, secret, "repo", "list").ok(t)
298+ lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
299+ require.Len(t, lines, 2)
300+ assert.Regexp(t, `^NAME\s+VISIBILITY\s+BRANCH\s+LAST PUSH\s+DESCRIPTION$`, lines[0])
301+ assert.Regexp(t, `^proj\s+private\s+dev\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}`, lines[1])
302+ res = c.cli(t, secret, "repo", "show", "proj").ok(t)
303+ assert.Regexp(t, `(?m)^default branch\s+dev$`, res.stdout)
304+
305+ // Private now: invisible to anonymous callers (not_found, never forbidden).
306+ _, stderr, code = c.bin(t, "", "repo", "show", "proj")
307+ requireOneLineError(t, stderr, code, "not found")
308+ res = c.cli(t, "", "repo", "list", "--json").ok(t)
309+ assert.JSONEq(t, `[]`, res.stdout)
310+
311+ // version: no token needed, nothing missing.
312+ res = c.cli(t, "", "version", "--json").ok(t)
313+ assert.Empty(t, res.stderr, "no missing-feature warning")
314+ type versionOut struct {
315+ Client struct{ Version string }
316+ Server struct {
317+ URL string
318+ Version string
319+ Features []string
320+ MissingFeatures []string `json:"missing_features"`
321+ }
322+ }
323+ ver := decode[versionOut](t, res.stdout)
324+ assert.Equal(t, c.url, ver.Server.URL)
325+ assert.NotEmpty(t, ver.Server.Version)
326+ assert.ElementsMatch(t, []string{"repo.update", "events.resume"}, ver.Server.Features)
327+ assert.Equal(t, []string{}, ver.Server.MissingFeatures)
328+ stdout, stderr, code = c.bin(t, secret, "version")
329+ require.Equal(t, 0, code, stderr)
330+ assert.Contains(t, stdout, "server")
331+ assert.Empty(t, stderr)
332+
333+ // Tokens: create a second one, list, revoke it, see it stop working.
334+ // (through the binary: the in-process runs use a fixed clock)
335+ stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json")
336+ require.Equal(t, 0, code, stderr)
337+ res = result{stdout: stdout}
338+ assert.Equal(t, []string{"created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout))
339+ tmp := decode[tokenOut](t, res.stdout)
340+ assert.Equal(t, []string{"repo:read"}, tmp.Scopes)
341+ assert.Equal(t, []string{"proj"}, tmp.Repos)
342+ require.NotNil(t, tmp.ExpiresAt)
343+ exp, err := time.Parse(time.RFC3339, *tmp.ExpiresAt)
344+ require.NoError(t, err)
345+ assert.WithinDuration(t, time.Now().Add(7*24*time.Hour), exp, time.Minute)
346+ assert.True(t, strings.HasPrefix(tmp.Secret, "ermite_"))
347+
348+ // The ci token reads the private repo it is limited to, and cannot write or manage tokens.
349+ res = c.cli(t, tmp.Secret, "repo", "show", "proj", "--json").ok(t)
350+ assert.Equal(t, "proj", decode[repoOut](t, res.stdout).Name)
351+ _, stderr, code = c.bin(t, tmp.Secret, "repo", "create", "nope")
352+ requireOneLineError(t, stderr, code, "permission denied")
353+ _, stderr, code = c.bin(t, tmp.Secret, "token", "list")
354+ requireOneLineError(t, stderr, code, "permission denied")
355+
356+ res = c.cli(t, secret, "token", "list", "--json").ok(t)
357+ tokens := decode[[]tokenOut](t, res.stdout)
358+ require.Len(t, tokens, 2)
359+ byLabel := map[string]tokenOut{}
360+ for _, tk := range tokens {
361+ assert.Empty(t, tk.Secret, "list never shows secrets")
362+ byLabel[tk.Label] = tk
363+ }
364+ assert.Len(t, byLabel["daily"].Scopes, len(client.AllScopes()), "the cli preset grants every scope")
365+ assert.Equal(t, tmp.ID, byLabel["tmp"].ID)
366+ assert.NotContains(t, res.stdout, `"secret"`)
367+ res = c.cli(t, secret, "token", "list").ok(t)
368+ assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+EXPIRES\s+LAST USED$`, res.stdout)
369+ assert.Contains(t, res.stdout, tmp.ID)
370+
371+ stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID)
372+ require.Equal(t, 0, code, stderr)
373+ assert.Equal(t, "revoked "+tmp.ID+"\n", stdout)
374+ _, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID)
375+ requireOneLineError(t, stderr, code, "not found")
376+ _, stderr, code = c.bin(t, tmp.Secret, "repo", "list")
377+ requireOneLineError(t, stderr, code, "not authenticated", "ERMITE_TOKEN")
378+ res = c.cli(t, secret, "token", "list", "--json").ok(t)
379+ assert.Len(t, decode[[]tokenOut](t, res.stdout), 1)
380+
381+ // Delete: refuses without confirmation, then --yes; the repo is gone and the name free.
382+ stdout, stderr, code = c.bin(t, secret, "repo", "delete", "proj")
383+ assert.Empty(t, stdout)
384+ assert.Equal(t, 1, code)
385+ assert.Contains(t, stderr, "ermite: aborted")
386+ c.cli(t, secret, "repo", "show", "proj").ok(t)
387+
388+ stdout, stderr, code = c.bin(t, secret, "repo", "delete", "proj", "--yes")
389+ require.Equal(t, 0, code, stderr)
390+ assert.Equal(t, "deleted proj\n", stdout)
391+ ev = decode[eventOut](t, w.next(t, time.Second))
392+ assert.Equal(t, "repo_deleted", ev.Type)
393+ assert.Equal(t, "proj", ev.Repo)
394+
395+ stdout, stderr, code = c.bin(t, secret, "repo", "show", "proj")
396+ assert.Empty(t, stdout)
397+ requireOneLineError(t, stderr, code, "not found")
398+ res = c.cli(t, secret, "repo", "list", "--json").ok(t)
399+ assert.JSONEq(t, `[]`, res.stdout)
400+
401+ // Ctrl-C ends the watch cleanly.
402+ assert.Equal(t, 0, w.interrupt(t))
403+ assert.NotContains(t, w.stderr.String(), "connection lost")
404+
405+ // No secret reached the server's logs.
406+ assert.NotContains(t, c.logs.String(), secret)
407+ assert.NotContains(t, c.logs.String(), tmp.Secret)
408+}
409+
410+// A bad or missing token gets the hint about ERMITE_TOKEN; the real server
411+// answers on the Connect protocol, so the CLI sees `unauthenticated`.
412+func TestAC6CLIUnauthenticatedHint(t *testing.T) {
413+ c := newCLIHarness(t)
414+ c.seed("pub", domain.VisibilityPublic)
415+
416+ bad := "ermite_" + strings.Repeat("x", 43)
417+ stdout, stderr, code := c.bin(t, bad, "repo", "list")
418+ assert.Empty(t, stdout)
419+ requireOneLineError(t, stderr, code, "not authenticated", "ERMITE_TOKEN")
420+ assert.NotContains(t, stderr, bad)
421+
422+ // Anonymous reads of public repositories work, and anonymous writes ask for a token.
423+ res := c.cli(t, "", "repo", "show", "pub", "--json").ok(t)
424+ assert.Equal(t, "pub", decode[repoOut](t, res.stdout).Name)
425+ _, stderr, code = c.bin(t, "", "repo", "create", "x")
426+ requireOneLineError(t, stderr, code, "ERMITE_TOKEN")
427+ _, stderr, code = c.bin(t, "", "token", "list")
428+ requireOneLineError(t, stderr, code, "ERMITE_TOKEN")
429+}
430+
431+// Once the server blocks a client for failed authentications, the CLI says so
432+// in one line instead of showing a raw error.
433+func TestAC6CLIBlockedClient(t *testing.T) {
434+ c := newCLIHarness(t)
435+ bad := "ermite_" + strings.Repeat("y", 43)
436+ var last result
437+ for range 50 {
438+ last = c.cli(t, bad, "repo", "list")
439+ require.Error(t, last.err)
440+ if strings.Contains(last.err.Error(), "too many") {
441+ break
442+ }
443+ assert.Contains(t, last.err.Error(), "not authenticated")
444+ }
445+ require.Contains(t, last.err.Error(), "too many requests", "the limiter never blocked")
446+ assert.NotContains(t, last.err.Error(), "\n")
447+
448+ _, stderr, code := c.bin(t, adminToken, "repo", "list")
449+ requireOneLineError(t, stderr, code, "too many requests")
450+}
451+
452+// The client survives a dropped connection: it reconnects with backoff and
453+// resumes after the last event it printed, without losing or repeating events.
454+func TestAC6CLIWatchResumesAfterDrop(t *testing.T) {
455+ c := newCLIHarness(t)
456+ w := c.startWatch(t, adminToken)
457+
458+ c.cli(t, adminToken, "repo", "create", "one").ok(t)
459+ first := decode[eventOut](t, w.next(t, time.Second))
460+ assert.Equal(t, "one", first.Repo)
461+
462+ // Cut every connection, then make an event happen while the CLI is away.
463+ c.srv.CloseClientConnections()
464+ c.cli(t, adminToken, "repo", "create", "two").ok(t)
465+ c.cli(t, adminToken, "repo", "create", "three").ok(t)
466+
467+ second := decode[eventOut](t, w.next(t, 10*time.Second))
468+ third := decode[eventOut](t, w.next(t, 2*time.Second))
469+ assert.Equal(t, []string{"two", "three"}, []string{second.Repo, third.Repo}, "replayed in order, none repeated")
470+ assert.Equal(t, "repo_created", second.Type)
471+ assert.NotEqual(t, first.ID, second.ID)
472+
473+ // Live again after the replay.
474+ c.cli(t, adminToken, "repo", "create", "four").ok(t)
475+ assert.Equal(t, "four", decode[eventOut](t, w.next(t, time.Second)).Repo)
476+
477+ assert.Equal(t, 0, w.interrupt(t))
478+ assert.Contains(t, w.stderr.String(), "ermite: connection lost")
479+ assert.Contains(t, w.stderr.String(), "ermite: reconnected")
480+ assert.NotContains(t, w.stderr.String(), "no resume point", "the first event gave a resume point")
481+}
482+
483+// A watch on one repository only sees that repository, and an unknown one is
484+// a one-line not_found without any retry.
485+func TestAC6CLIWatchOneRepo(t *testing.T) {
486+ c := newCLIHarness(t)
487+ c.seed("a", domain.VisibilityPublic)
488+ c.seed("b", domain.VisibilityPublic)
489+
490+ stdout, stderr, code := c.bin(t, adminToken, "watch", "missing")
491+ assert.Empty(t, stdout)
492+ requireOneLineError(t, stderr, code, "not found")
493+
494+ w := c.startWatch(t, adminToken, "a")
495+ wt := c.workTree("main", "f")
496+ c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/b.git"), "main")
497+ c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/a.git"), "main")
498+ ev := decode[eventOut](t, w.next(t, time.Second))
499+ assert.Equal(t, "pushed", ev.Type)
500+ assert.Equal(t, "a", ev.Repo)
501+ assert.Equal(t, 0, w.interrupt(t))
502+}
@@ -0,0 +1,502 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "bufio"
7+ "encoding/json"
8+ "os/exec"
9+ "slices"
10+ "strings"
11+ "syscall"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/adapters/client"
19+ "bnema.dev/ermite/internal/domain"
20+)
21+
22+// cliHarness drives the CLI against a real forge.
23+type cliHarness struct {
24+ *harness
25+ url string
26+}
27+
28+func newCLIHarness(t *testing.T) *cliHarness {
29+ t.Helper()
30+ h := newHarness(t, harnessOpts{})
31+ return &cliHarness{harness: h, url: h.srv.URL}
32+}
33+
34+// vars is the CLI environment for a token ("" is anonymous).
35+func (c *cliHarness) vars(secret string) map[string]string {
36+ v := map[string]string{client.EnvURL: c.url}
37+ if secret != "" {
38+ v[client.EnvToken] = secret
39+ }
40+ return v
41+}
42+
43+// cli runs the command tree in process.
44+func (c *cliHarness) cli(t *testing.T, secret string, args ...string) result {
45+ t.Helper()
46+ return runCLI(t, c.vars(secret), "", args...)
47+}
48+
49+// bin runs the built binary; stdout and stderr are returned with the exit code.
50+func (c *cliHarness) bin(t *testing.T, secret string, args ...string) (stdout, stderr string, code int) {
51+ t.Helper()
52+ var env []string
53+ for k, v := range c.vars(secret) {
54+ env = append(env, k+"="+v)
55+ }
56+ return runBinary(t, env, args...)
57+}
58+
59+// keys returns the sorted field names of a JSON object.
60+func keys(t *testing.T, raw string) []string {
61+ t.Helper()
62+ var m map[string]json.RawMessage
63+ require.NoError(t, json.Unmarshal([]byte(raw), &m), raw)
64+ var ks []string
65+ for k := range m {
66+ ks = append(ks, k)
67+ }
68+ slices.Sort(ks)
69+ return ks
70+}
71+
72+func decode[T any](t *testing.T, raw string) T {
73+ t.Helper()
74+ var v T
75+ require.NoError(t, json.Unmarshal([]byte(raw), &v), raw)
76+ return v
77+}
78+
79+// requireOneLineError checks a failed run: exit 1, a single "ermite: …" line.
80+func requireOneLineError(t *testing.T, stderr string, code int, contains ...string) {
81+ t.Helper()
82+ assert.Equal(t, 1, code)
83+ assert.Equal(t, 1, strings.Count(stderr, "\n"), "one line: %q", stderr)
84+ assert.True(t, strings.HasPrefix(stderr, "ermite: "), stderr)
85+ for _, s := range contains {
86+ assert.Contains(t, stderr, s)
87+ }
88+}
89+
90+// watchProc is a running `ermite watch --json`.
91+type watchProc struct {
92+ cmd *exec.Cmd
93+ lines chan string
94+ stderr *syncBuffer
95+ done chan error
96+}
97+
98+// startWatch runs the binary and waits until the server has the stream.
99+func (c *cliHarness) startWatch(t *testing.T, secret string, args ...string) *watchProc {
100+ t.Helper()
101+ cmd := exec.Command(binary(t), append([]string{"watch", "--json"}, args...)...)
102+ cmd.Env = baseEnv()
103+ for k, v := range c.vars(secret) {
104+ cmd.Env = append(cmd.Env, k+"="+v)
105+ }
106+ w := &watchProc{cmd: cmd, lines: make(chan string, 64), stderr: &syncBuffer{}, done: make(chan error, 1)}
107+ cmd.Stderr = w.stderr
108+ out, err := cmd.StdoutPipe()
109+ require.NoError(t, err)
110+ require.NoError(t, cmd.Start())
111+ go func() {
112+ sc := bufio.NewScanner(out)
113+ for sc.Scan() {
114+ w.lines <- sc.Text()
115+ }
116+ close(w.lines)
117+ w.done <- cmd.Wait()
118+ }()
119+ t.Cleanup(func() { _ = cmd.Process.Kill() })
120+
121+ require.Eventually(t, func() bool { return c.forge.EventLog.Watchers() >= 1 }, 10*time.Second, 10*time.Millisecond,
122+ "the watch never reached the server; stderr: %s", w.stderr.String())
123+ return w
124+}
125+
126+// next decodes the next event line or fails after d.
127+func (w *watchProc) next(t *testing.T, d time.Duration) (raw string) {
128+ t.Helper()
129+ select {
130+ case l, ok := <-w.lines:
131+ if !ok {
132+ t.Fatalf("watch ended; stderr: %s", w.stderr.String())
133+ }
134+ return l
135+ case <-time.After(d):
136+ t.Fatalf("no event within %v; stderr: %s", d, w.stderr.String())
137+ }
138+ return ""
139+}
140+
141+// interrupt sends SIGINT and returns the exit code.
142+func (w *watchProc) interrupt(t *testing.T) int {
143+ t.Helper()
144+ require.NoError(t, w.cmd.Process.Signal(syscall.SIGINT))
145+ select {
146+ case err := <-w.done:
147+ if err == nil {
148+ return 0
149+ }
150+ var exit *exec.ExitError
151+ require.ErrorAs(t, err, &exit)
152+ return exit.ExitCode()
153+ case <-time.After(10 * time.Second):
154+ t.Fatal("watch did not stop on SIGINT")
155+ }
156+ return -1
157+}
158+
159+type tokenOut struct {
160+ ID string `json:"id"`
161+ Label string `json:"label"`
162+ Scopes []string `json:"scopes"`
163+ Repos []string `json:"repos"`
164+ ExpiresAt *string `json:"expires_at"`
165+ CreatedAt *string `json:"created_at"`
166+ LastUsedAt *string `json:"last_used_at"`
167+ Secret string `json:"secret"`
168+}
169+
170+type repoOut struct {
171+ Name string `json:"name"`
172+ Description string `json:"description"`
173+ Visibility string `json:"visibility"`
174+ DefaultBranch string `json:"default_branch"`
175+ CreatedAt *string `json:"created_at"`
176+ PushedAt *string `json:"pushed_at"`
177+}
178+
179+type eventOut struct {
180+ ID string `json:"id"`
181+ Time *string `json:"time"`
182+ Type string `json:"type"`
183+ Repo string `json:"repo"`
184+ Repository *repoOut
185+ RefUpdates []struct {
186+ Ref string `json:"ref"`
187+ OldID string `json:"old_id"`
188+ NewID string `json:"new_id"`
189+ } `json:"ref_updates"`
190+}
191+
192+var repoKeys = []string{"created_at", "default_branch", "description", "name", "pushed_at", "visibility"}
193+
194+// AC6: the CLI end to end against a real forge.
195+func TestAC6CLIEndToEnd(t *testing.T) {
196+ c := newCLIHarness(t)
197+
198+ // Root creates the daily token with the cli preset (binary: secret alone on stdout).
199+ stdout, stderr, code := c.bin(t, adminToken, "token", "create", "daily", "--preset", "cli")
200+ require.Equal(t, 0, code, stderr)
201+ secret := strings.TrimSpace(stdout)
202+ require.True(t, strings.HasPrefix(secret, "ermite_"), stdout)
203+ assert.Equal(t, 1, strings.Count(stdout, "\n"), "the secret is the only thing on stdout")
204+ assert.Contains(t, stderr, "created token")
205+ assert.NotContains(t, stderr, secret)
206+
207+ // Follow events with that token, through the binary.
208+ w := c.startWatch(t, secret)
209+
210+ // Create. Private by default; here public with a description.
211+ res := c.cli(t, secret, "repo", "create", "proj", "--description", "first", "--visibility", "public", "--json").ok(t)
212+ assert.Equal(t, repoKeys, keys(t, res.stdout))
213+ created := decode[repoOut](t, res.stdout)
214+ assert.Equal(t, "proj", created.Name)
215+ assert.Equal(t, "first", created.Description)
216+ assert.Equal(t, "public", created.Visibility)
217+ assert.Equal(t, "main", created.DefaultBranch)
218+ require.NotNil(t, created.CreatedAt)
219+ _, err := time.Parse(time.RFC3339, *created.CreatedAt)
220+ require.NoError(t, err)
221+ assert.Nil(t, created.PushedAt, "no push yet")
222+
223+ ev := decode[eventOut](t, w.next(t, time.Second))
224+ assert.Equal(t, "repo_created", ev.Type)
225+ assert.Equal(t, "proj", ev.Repo)
226+ require.NotNil(t, ev.Repository)
227+ assert.Equal(t, "public", ev.Repository.Visibility)
228+ assert.NotEmpty(t, ev.ID)
229+ assert.Contains(t, ev.ID, "-", "ids are <boot>-<seq>, opaque to the CLI")
230+ require.NotNil(t, ev.Time)
231+
232+ // Creating it again, or with a bad name, is a one-line friendly error.
233+ stdout, stderr, code = c.bin(t, secret, "repo", "create", "proj")
234+ assert.Empty(t, stdout)
235+ requireOneLineError(t, stderr, code, "already exists")
236+ stdout, stderr, code = c.bin(t, secret, "repo", "create", "Bad_Name")
237+ assert.Empty(t, stdout)
238+ requireOneLineError(t, stderr, code, "invalid request")
239+
240+ // Editing the default branch to one that does not exist fails and changes nothing.
241+ _, stderr, code = c.bin(t, secret, "repo", "edit", "proj", "--default-branch", "dev", "--description", "never")
242+ requireOneLineError(t, stderr, code, "precondition failed")
243+ res = c.cli(t, secret, "repo", "show", "proj", "--json").ok(t)
244+ assert.Equal(t, "first", decode[repoOut](t, res.stdout).Description)
245+
246+ // Push two branches with git, using the same token.
247+ wt := c.workTree("main", "README.md")
248+ c.mustGit(wt, "checkout", "-q", "-b", "dev")
249+ c.writeFile(wt, "dev.txt", []byte("dev\n"))
250+ c.mustGit(wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "main", "dev")
251+
252+ ev = decode[eventOut](t, w.next(t, time.Second))
253+ assert.Equal(t, "pushed", ev.Type)
254+ assert.Equal(t, "proj", ev.Repo)
255+ var refs []string
256+ for _, u := range ev.RefUpdates {
257+ refs = append(refs, u.Ref)
258+ assert.Empty(t, u.OldID)
259+ assert.Len(t, u.NewID, 40)
260+ }
261+ assert.ElementsMatch(t, []string{"refs/heads/main", "refs/heads/dev"}, refs)
262+
263+ // Edit description, visibility and default branch in one call.
264+ res = c.cli(t, secret, "repo", "edit", "proj", "--description", "the project", "--visibility", "private", "--default-branch", "dev", "--json").ok(t)
265+ assert.Equal(t, repoKeys, keys(t, res.stdout))
266+ edited := decode[repoOut](t, res.stdout)
267+ assert.Equal(t, "the project", edited.Description)
268+ assert.Equal(t, "private", edited.Visibility)
269+ assert.Equal(t, "dev", edited.DefaultBranch)
270+ require.NotNil(t, edited.PushedAt, "set by the push")
271+ assert.Equal(t, "refs/heads/dev", c.bareGit("proj", "symbolic-ref", "HEAD"))
272+
273+ ev = decode[eventOut](t, w.next(t, time.Second))
274+ assert.Equal(t, "repo_updated", ev.Type)
275+ require.NotNil(t, ev.Repository)
276+ assert.Equal(t, "dev", ev.Repository.DefaultBranch)
277+ assert.Equal(t, "private", ev.Repository.Visibility)
278+
279+ // Clearing the description takes an explicit empty value.
280+ res = c.cli(t, secret, "repo", "edit", "proj", "--description", "", "--json").ok(t)
281+ assert.Empty(t, decode[repoOut](t, res.stdout).Description)
282+ assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
283+
284+ // list and show: same shape, human tables too.
285+ res = c.cli(t, secret, "repo", "list", "--json").ok(t)
286+ list := decode[[]json.RawMessage](t, res.stdout)
287+ require.Len(t, list, 1)
288+ assert.Equal(t, repoKeys, keys(t, string(list[0])))
289+ assert.Equal(t, "proj", decode[repoOut](t, string(list[0])).Name)
290+
291+ res = c.cli(t, secret, "repo", "show", "proj", "--json").ok(t)
292+ assert.Equal(t, repoKeys, keys(t, res.stdout))
293+ shown := decode[repoOut](t, res.stdout)
294+ assert.Equal(t, "dev", shown.DefaultBranch)
295+ assert.Equal(t, "private", shown.Visibility)
296+
297+ res = c.cli(t, secret, "repo", "list").ok(t)
298+ lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
299+ require.Len(t, lines, 2)
300+ assert.Regexp(t, `^NAME\s+VISIBILITY\s+BRANCH\s+LAST PUSH\s+DESCRIPTION$`, lines[0])
301+ assert.Regexp(t, `^proj\s+private\s+dev\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}`, lines[1])
302+ res = c.cli(t, secret, "repo", "show", "proj").ok(t)
303+ assert.Regexp(t, `(?m)^default branch\s+dev$`, res.stdout)
304+
305+ // Private now: invisible to anonymous callers (not_found, never forbidden).
306+ _, stderr, code = c.bin(t, "", "repo", "show", "proj")
307+ requireOneLineError(t, stderr, code, "not found")
308+ res = c.cli(t, "", "repo", "list", "--json").ok(t)
309+ assert.JSONEq(t, `[]`, res.stdout)
310+
311+ // version: no token needed, nothing missing.
312+ res = c.cli(t, "", "version", "--json").ok(t)
313+ assert.Empty(t, res.stderr, "no missing-feature warning")
314+ type versionOut struct {
315+ Client struct{ Version string }
316+ Server struct {
317+ URL string
318+ Version string
319+ Features []string
320+ MissingFeatures []string `json:"missing_features"`
321+ }
322+ }
323+ ver := decode[versionOut](t, res.stdout)
324+ assert.Equal(t, c.url, ver.Server.URL)
325+ assert.NotEmpty(t, ver.Server.Version)
326+ assert.ElementsMatch(t, []string{"repo.update", "events.resume"}, ver.Server.Features)
327+ assert.Equal(t, []string{}, ver.Server.MissingFeatures)
328+ stdout, stderr, code = c.bin(t, secret, "version")
329+ require.Equal(t, 0, code, stderr)
330+ assert.Contains(t, stdout, "server")
331+ assert.Empty(t, stderr)
332+
333+ // Tokens: create a second one, list, revoke it, see it stop working.
334+ // (through the binary: the in-process runs use a fixed clock)
335+ stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json")
336+ require.Equal(t, 0, code, stderr)
337+ res = result{stdout: stdout}
338+ assert.Equal(t, []string{"created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout))
339+ tmp := decode[tokenOut](t, res.stdout)
340+ assert.Equal(t, []string{"repo:read"}, tmp.Scopes)
341+ assert.Equal(t, []string{"proj"}, tmp.Repos)
342+ require.NotNil(t, tmp.ExpiresAt)
343+ exp, err := time.Parse(time.RFC3339, *tmp.ExpiresAt)
344+ require.NoError(t, err)
345+ assert.WithinDuration(t, time.Now().Add(7*24*time.Hour), exp, time.Minute)
346+ assert.True(t, strings.HasPrefix(tmp.Secret, "ermite_"))
347+
348+ // The ci token reads the private repo it is limited to, and cannot write or manage tokens.
349+ res = c.cli(t, tmp.Secret, "repo", "show", "proj", "--json").ok(t)
350+ assert.Equal(t, "proj", decode[repoOut](t, res.stdout).Name)
351+ _, stderr, code = c.bin(t, tmp.Secret, "repo", "create", "nope")
352+ requireOneLineError(t, stderr, code, "permission denied")
353+ _, stderr, code = c.bin(t, tmp.Secret, "token", "list")
354+ requireOneLineError(t, stderr, code, "permission denied")
355+
356+ res = c.cli(t, secret, "token", "list", "--json").ok(t)
357+ tokens := decode[[]tokenOut](t, res.stdout)
358+ require.Len(t, tokens, 2)
359+ byLabel := map[string]tokenOut{}
360+ for _, tk := range tokens {
361+ assert.Empty(t, tk.Secret, "list never shows secrets")
362+ byLabel[tk.Label] = tk
363+ }
364+ assert.Len(t, byLabel["daily"].Scopes, len(client.AllScopes()), "the cli preset grants every scope")
365+ assert.Equal(t, tmp.ID, byLabel["tmp"].ID)
366+ assert.NotContains(t, res.stdout, `"secret"`)
367+ res = c.cli(t, secret, "token", "list").ok(t)
368+ assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+EXPIRES\s+LAST USED$`, res.stdout)
369+ assert.Contains(t, res.stdout, tmp.ID)
370+
371+ stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID)
372+ require.Equal(t, 0, code, stderr)
373+ assert.Equal(t, "revoked "+tmp.ID+"\n", stdout)
374+ _, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID)
375+ requireOneLineError(t, stderr, code, "not found")
376+ _, stderr, code = c.bin(t, tmp.Secret, "repo", "list")
377+ requireOneLineError(t, stderr, code, "not authenticated", "ERMITE_TOKEN")
378+ res = c.cli(t, secret, "token", "list", "--json").ok(t)
379+ assert.Len(t, decode[[]tokenOut](t, res.stdout), 1)
380+
381+ // Delete: refuses without confirmation, then --yes; the repo is gone and the name free.
382+ stdout, stderr, code = c.bin(t, secret, "repo", "delete", "proj")
383+ assert.Empty(t, stdout)
384+ assert.Equal(t, 1, code)
385+ assert.Contains(t, stderr, "ermite: aborted")
386+ c.cli(t, secret, "repo", "show", "proj").ok(t)
387+
388+ stdout, stderr, code = c.bin(t, secret, "repo", "delete", "proj", "--yes")
389+ require.Equal(t, 0, code, stderr)
390+ assert.Equal(t, "deleted proj\n", stdout)
391+ ev = decode[eventOut](t, w.next(t, time.Second))
392+ assert.Equal(t, "repo_deleted", ev.Type)
393+ assert.Equal(t, "proj", ev.Repo)
394+
395+ stdout, stderr, code = c.bin(t, secret, "repo", "show", "proj")
396+ assert.Empty(t, stdout)
397+ requireOneLineError(t, stderr, code, "not found")
398+ res = c.cli(t, secret, "repo", "list", "--json").ok(t)
399+ assert.JSONEq(t, `[]`, res.stdout)
400+
401+ // Ctrl-C ends the watch cleanly.
402+ assert.Equal(t, 0, w.interrupt(t))
403+ assert.NotContains(t, w.stderr.String(), "connection lost")
404+
405+ // No secret reached the server's logs.
406+ assert.NotContains(t, c.logs.String(), secret)
407+ assert.NotContains(t, c.logs.String(), tmp.Secret)
408+}
409+
410+// A bad or missing token gets the hint about ERMITE_TOKEN; the real server
411+// answers on the Connect protocol, so the CLI sees `unauthenticated`.
412+func TestAC6CLIUnauthenticatedHint(t *testing.T) {
413+ c := newCLIHarness(t)
414+ c.seed("pub", domain.VisibilityPublic)
415+
416+ bad := "ermite_" + strings.Repeat("x", 43)
417+ stdout, stderr, code := c.bin(t, bad, "repo", "list")
418+ assert.Empty(t, stdout)
419+ requireOneLineError(t, stderr, code, "not authenticated", "ERMITE_TOKEN")
420+ assert.NotContains(t, stderr, bad)
421+
422+ // Anonymous reads of public repositories work, and anonymous writes ask for a token.
423+ res := c.cli(t, "", "repo", "show", "pub", "--json").ok(t)
424+ assert.Equal(t, "pub", decode[repoOut](t, res.stdout).Name)
425+ _, stderr, code = c.bin(t, "", "repo", "create", "x")
426+ requireOneLineError(t, stderr, code, "ERMITE_TOKEN")
427+ _, stderr, code = c.bin(t, "", "token", "list")
428+ requireOneLineError(t, stderr, code, "ERMITE_TOKEN")
429+}
430+
431+// Once the server blocks a client for failed authentications, the CLI says so
432+// in one line instead of showing a raw error.
433+func TestAC6CLIBlockedClient(t *testing.T) {
434+ c := newCLIHarness(t)
435+ bad := "ermite_" + strings.Repeat("y", 43)
436+ var last result
437+ for range 50 {
438+ last = c.cli(t, bad, "repo", "list")
439+ require.Error(t, last.err)
440+ if strings.Contains(last.err.Error(), "too many") {
441+ break
442+ }
443+ assert.Contains(t, last.err.Error(), "not authenticated")
444+ }
445+ require.Contains(t, last.err.Error(), "too many requests", "the limiter never blocked")
446+ assert.NotContains(t, last.err.Error(), "\n")
447+
448+ _, stderr, code := c.bin(t, adminToken, "repo", "list")
449+ requireOneLineError(t, stderr, code, "too many requests")
450+}
451+
452+// The client survives a dropped connection: it reconnects with backoff and
453+// resumes after the last event it printed, without losing or repeating events.
454+func TestAC6CLIWatchResumesAfterDrop(t *testing.T) {
455+ c := newCLIHarness(t)
456+ w := c.startWatch(t, adminToken)
457+
458+ c.cli(t, adminToken, "repo", "create", "one").ok(t)
459+ first := decode[eventOut](t, w.next(t, time.Second))
460+ assert.Equal(t, "one", first.Repo)
461+
462+ // Cut every connection, then make an event happen while the CLI is away.
463+ c.srv.CloseClientConnections()
464+ c.cli(t, adminToken, "repo", "create", "two").ok(t)
465+ c.cli(t, adminToken, "repo", "create", "three").ok(t)
466+
467+ second := decode[eventOut](t, w.next(t, 10*time.Second))
468+ third := decode[eventOut](t, w.next(t, 2*time.Second))
469+ assert.Equal(t, []string{"two", "three"}, []string{second.Repo, third.Repo}, "replayed in order, none repeated")
470+ assert.Equal(t, "repo_created", second.Type)
471+ assert.NotEqual(t, first.ID, second.ID)
472+
473+ // Live again after the replay.
474+ c.cli(t, adminToken, "repo", "create", "four").ok(t)
475+ assert.Equal(t, "four", decode[eventOut](t, w.next(t, time.Second)).Repo)
476+
477+ assert.Equal(t, 0, w.interrupt(t))
478+ assert.Contains(t, w.stderr.String(), "ermite: connection lost")
479+ assert.Contains(t, w.stderr.String(), "ermite: reconnected")
480+ assert.NotContains(t, w.stderr.String(), "no resume point", "the first event gave a resume point")
481+}
482+
483+// A watch on one repository only sees that repository, and an unknown one is
484+// a one-line not_found without any retry.
485+func TestAC6CLIWatchOneRepo(t *testing.T) {
486+ c := newCLIHarness(t)
487+ c.seed("a", domain.VisibilityPublic)
488+ c.seed("b", domain.VisibilityPublic)
489+
490+ stdout, stderr, code := c.bin(t, adminToken, "watch", "missing")
491+ assert.Empty(t, stdout)
492+ requireOneLineError(t, stderr, code, "not found")
493+
494+ w := c.startWatch(t, adminToken, "a")
495+ wt := c.workTree("main", "f")
496+ c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/b.git"), "main")
497+ c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/a.git"), "main")
498+ ev := decode[eventOut](t, w.next(t, time.Second))
499+ assert.Equal(t, "pushed", ev.Type)
500+ assert.Equal(t, "a", ev.Repo)
501+ assert.Equal(t, 0, w.interrupt(t))
502+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close