v0.30.0 c899179

cli_e2e_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
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.
23type cliHarness struct {
24	*harness
25	url string
26}
27
28func 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).
35func (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.
44func (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.
50func (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.
60func 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
72func 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.
80func 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`.
91type 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.
99func (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 returns the next event line or fails after d. Mergeability events,
127// which arrive asynchronously after a push or an opening, are skipped.
128func (w *watchProc) next(t *testing.T, d time.Duration) (raw string) {
129	t.Helper()
130	for {
131		l := w.nextAny(t, d)
132		if !strings.Contains(l, `"type":"`+eventMRMergeability+`"`) {
133			return l
134		}
135	}
136}
137
138// nextAny returns the next event line, whatever its kind, or fails after d.
139func (w *watchProc) nextAny(t *testing.T, d time.Duration) (raw string) {
140	t.Helper()
141	select {
142	case l, ok := <-w.lines:
143		if !ok {
144			t.Fatalf("watch ended; stderr: %s", w.stderr.String())
145		}
146		return l
147	case <-time.After(d):
148		t.Fatalf("no event within %v; stderr: %s", d, w.stderr.String())
149	}
150	return ""
151}
152
153// interrupt sends SIGINT and returns the exit code.
154func (w *watchProc) interrupt(t *testing.T) int {
155	t.Helper()
156	require.NoError(t, w.cmd.Process.Signal(syscall.SIGINT))
157	select {
158	case err := <-w.done:
159		if err == nil {
160			return 0
161		}
162		var exit *exec.ExitError
163		require.ErrorAs(t, err, &exit)
164		return exit.ExitCode()
165	case <-time.After(10 * time.Second):
166		t.Fatal("watch did not stop on SIGINT")
167	}
168	return -1
169}
170
171type tokenOut struct {
172	ID         string   `json:"id"`
173	Label      string   `json:"label"`
174	Scopes     []string `json:"scopes"`
175	Repos      []string `json:"repos"`
176	ExpiresAt  *string  `json:"expires_at"`
177	CreatedAt  *string  `json:"created_at"`
178	LastUsedAt *string  `json:"last_used_at"`
179	Secret     string   `json:"secret"`
180}
181
182type repoOut struct {
183	Name          string   `json:"name"`
184	Description   string   `json:"description"`
185	Topics        []string `json:"topics"`
186	Visibility    string   `json:"visibility"`
187	DefaultBranch string   `json:"default_branch"`
188	CreatedAt     *string  `json:"created_at"`
189	PushedAt      *string  `json:"pushed_at"`
190}
191
192type eventOut struct {
193	ID         string  `json:"id"`
194	Time       *string `json:"time"`
195	Type       string  `json:"type"`
196	Repo       string  `json:"repo"`
197	Repository *repoOut
198	RefUpdates []struct {
199		Ref   string `json:"ref"`
200		OldID string `json:"old_id"`
201		NewID string `json:"new_id"`
202	} `json:"ref_updates"`
203	MergeRequest *mrOut `json:"merge_request"`
204}
205
206var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}
207
208// AC6: the CLI end to end against a real forge.
209func TestAC6CLIEndToEnd(t *testing.T) {
210	c := newCLIHarness(t)
211
212	// Root creates the daily token with the cli preset (binary: secret alone on stdout).
213	stdout, stderr, code := c.bin(t, adminToken, "token", "create", "daily", "--preset", "cli")
214	require.Equal(t, 0, code, stderr)
215	secret := strings.TrimSpace(stdout)
216	require.True(t, strings.HasPrefix(secret, "ermite_"), stdout)
217	assert.Equal(t, 1, strings.Count(stdout, "\n"), "the secret is the only thing on stdout")
218	assert.Contains(t, stderr, "created token")
219	assert.NotContains(t, stderr, secret)
220
221	// Follow events with that token, through the binary.
222	w := c.startWatch(t, secret)
223
224	// Create. Private by default; here public with a description.
225	res := c.cli(t, secret, "repo", "create", "proj", "--description", "first", "--topics", "Go,cli,go", "--visibility", "public", "--json").ok(t)
226	assert.Equal(t, repoKeys, keys(t, res.stdout))
227	created := decode[repoOut](t, res.stdout)
228	assert.Equal(t, "proj", created.Name)
229	assert.Equal(t, "first", created.Description)
230	assert.Equal(t, []string{"cli", "go"}, created.Topics, "normalized: lowercase, sorted, deduplicated")
231	assert.Equal(t, "public", created.Visibility)
232	assert.Equal(t, "main", created.DefaultBranch)
233	require.NotNil(t, created.CreatedAt)
234	_, err := time.Parse(time.RFC3339, *created.CreatedAt)
235	require.NoError(t, err)
236	assert.Nil(t, created.PushedAt, "no push yet")
237
238	ev := decode[eventOut](t, w.next(t, time.Second))
239	assert.Equal(t, "repo_created", ev.Type)
240	assert.Equal(t, "proj", ev.Repo)
241	require.NotNil(t, ev.Repository)
242	assert.Equal(t, "public", ev.Repository.Visibility)
243	assert.NotEmpty(t, ev.ID)
244	assert.Contains(t, ev.ID, "-", "ids are <boot>-<seq>, opaque to the CLI")
245	require.NotNil(t, ev.Time)
246
247	// Creating it again, or with a bad name, is a one-line friendly error.
248	stdout, stderr, code = c.bin(t, secret, "repo", "create", "proj")
249	assert.Empty(t, stdout)
250	requireOneLineError(t, stderr, code, "already exists")
251	stdout, stderr, code = c.bin(t, secret, "repo", "create", "Bad_Name")
252	assert.Empty(t, stdout)
253	requireOneLineError(t, stderr, code, "invalid request")
254
255	// Editing the default branch to one that does not exist fails and changes nothing.
256	_, stderr, code = c.bin(t, secret, "repo", "edit", "proj", "--default-branch", "dev", "--description", "never")
257	requireOneLineError(t, stderr, code, "precondition failed")
258	res = c.cli(t, secret, "repo", "show", "proj", "--json").ok(t)
259	assert.Equal(t, "first", decode[repoOut](t, res.stdout).Description)
260
261	// Push two branches with git, using the same token.
262	wt := c.workTree("main", "README.md")
263	c.mustGit(wt, "checkout", "-q", "-b", "dev")
264	c.writeFile(wt, "dev.txt", []byte("dev\n"))
265	c.mustGit(wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "main", "dev")
266
267	ev = decode[eventOut](t, w.next(t, time.Second))
268	assert.Equal(t, "pushed", ev.Type)
269	assert.Equal(t, "proj", ev.Repo)
270	var refs []string
271	for _, u := range ev.RefUpdates {
272		refs = append(refs, u.Ref)
273		assert.Empty(t, u.OldID)
274		assert.Len(t, u.NewID, 40)
275	}
276	assert.ElementsMatch(t, []string{"refs/heads/main", "refs/heads/dev"}, refs)
277
278	// Edit description, visibility and default branch in one call.
279	res = c.cli(t, secret, "repo", "edit", "proj", "--description", "the project", "--visibility", "private", "--default-branch", "dev", "--json").ok(t)
280	assert.Equal(t, repoKeys, keys(t, res.stdout))
281	edited := decode[repoOut](t, res.stdout)
282	assert.Equal(t, "the project", edited.Description)
283	assert.Equal(t, "private", edited.Visibility)
284	assert.Equal(t, "dev", edited.DefaultBranch)
285	require.NotNil(t, edited.PushedAt, "set by the push")
286	assert.Equal(t, "refs/heads/dev", c.bareGit("proj", "symbolic-ref", "HEAD"))
287
288	ev = decode[eventOut](t, w.next(t, time.Second))
289	assert.Equal(t, "repo_updated", ev.Type)
290	require.NotNil(t, ev.Repository)
291	assert.Equal(t, "dev", ev.Repository.DefaultBranch)
292	assert.Equal(t, "private", ev.Repository.Visibility)
293
294	// Clearing the description takes an explicit empty value.
295	res = c.cli(t, secret, "repo", "edit", "proj", "--description", "", "--json").ok(t)
296	assert.Empty(t, decode[repoOut](t, res.stdout).Description)
297	assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
298
299	// Topics are replaced as a whole and cleared with an empty value.
300	res = c.cli(t, secret, "repo", "edit", "proj", "--topics", "forge,git", "--json").ok(t)
301	assert.Equal(t, []string{"forge", "git"}, decode[repoOut](t, res.stdout).Topics)
302	assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
303	_, stderr, code = c.bin(t, secret, "repo", "edit", "proj", "--topics", "Bad_Topic")
304	requireOneLineError(t, stderr, code, "invalid request")
305	res = c.cli(t, secret, "repo", "edit", "proj", "--topics", "", "--json").ok(t)
306	assert.Empty(t, decode[repoOut](t, res.stdout).Topics)
307	assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
308
309	// list and show: same shape, human tables too.
310	res = c.cli(t, secret, "repo", "list", "--json").ok(t)
311	list := decode[[]json.RawMessage](t, res.stdout)
312	require.Len(t, list, 1)
313	assert.Equal(t, repoKeys, keys(t, string(list[0])))
314	assert.Equal(t, "proj", decode[repoOut](t, string(list[0])).Name)
315
316	res = c.cli(t, secret, "repo", "show", "proj", "--json").ok(t)
317	assert.Equal(t, repoKeys, keys(t, res.stdout))
318	shown := decode[repoOut](t, res.stdout)
319	assert.Equal(t, "dev", shown.DefaultBranch)
320	assert.Equal(t, "private", shown.Visibility)
321
322	res = c.cli(t, secret, "repo", "list").ok(t)
323	lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
324	require.Len(t, lines, 2)
325	assert.Regexp(t, `^NAME\s+VISIBILITY\s+BRANCH\s+LAST PUSH\s+DESCRIPTION$`, lines[0])
326	assert.Regexp(t, `^proj\s+private\s+dev\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}`, lines[1])
327	res = c.cli(t, secret, "repo", "show", "proj").ok(t)
328	assert.Regexp(t, `(?m)^default branch\s+dev$`, res.stdout)
329
330	// Private now: invisible to anonymous callers (not_found, never forbidden).
331	_, stderr, code = c.bin(t, "", "repo", "show", "proj")
332	requireOneLineError(t, stderr, code, "not found")
333	res = c.cli(t, "", "repo", "list", "--json").ok(t)
334	assert.JSONEq(t, `[]`, res.stdout)
335
336	// version: no token needed. This server runs on http://127.0.0.1, where
337	// passkeys are not available: they are the only feature missing.
338	res = c.cli(t, "", "version", "--json").ok(t)
339	assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr)
340	assert.Contains(t, res.stderr, "the server does not support passkeys")
341	type versionOut struct {
342		Client struct{ Version string }
343		Server struct {
344			URL             string
345			Version         string
346			Features        []string
347			MissingFeatures []string `json:"missing_features"`
348		}
349	}
350	ver := decode[versionOut](t, res.stdout)
351	assert.Equal(t, c.url, ver.Server.URL)
352	assert.NotEmpty(t, ver.Server.Version)
353	assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
354	assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355	stdout, stderr, code = c.bin(t, secret, "version")
356	require.Equal(t, 0, code, stderr)
357	assert.Contains(t, stdout, "server")
358	assert.Equal(t, 1, strings.Count(stderr, "warning:"), stderr)
359	assert.Contains(t, stderr, "the server does not support passkeys")
360
361	// Tokens: create a second one, list, revoke it, see it stop working.
362	// (through the binary: the in-process runs use a fixed clock)
363	stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json")
364	require.Equal(t, 0, code, stderr)
365	res = result{stdout: stdout}
366	assert.Equal(t, []string{"branch_prefixes", "created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout))
367	tmp := decode[tokenOut](t, res.stdout)
368	assert.Equal(t, []string{"repo:read"}, tmp.Scopes)
369	assert.Equal(t, []string{"proj"}, tmp.Repos)
370	require.NotNil(t, tmp.ExpiresAt)
371	exp, err := time.Parse(time.RFC3339, *tmp.ExpiresAt)
372	require.NoError(t, err)
373	assert.WithinDuration(t, time.Now().Add(7*24*time.Hour), exp, time.Minute)
374	assert.True(t, strings.HasPrefix(tmp.Secret, "ermite_"))
375
376	// The ci token reads the private repo it is limited to, and cannot write or manage tokens.
377	res = c.cli(t, tmp.Secret, "repo", "show", "proj", "--json").ok(t)
378	assert.Equal(t, "proj", decode[repoOut](t, res.stdout).Name)
379	_, stderr, code = c.bin(t, tmp.Secret, "repo", "create", "nope")
380	requireOneLineError(t, stderr, code, "permission denied")
381	_, stderr, code = c.bin(t, tmp.Secret, "token", "list")
382	requireOneLineError(t, stderr, code, "permission denied")
383
384	res = c.cli(t, secret, "token", "list", "--json").ok(t)
385	tokens := decode[[]tokenOut](t, res.stdout)
386	require.Len(t, tokens, 2)
387	byLabel := map[string]tokenOut{}
388	for _, tk := range tokens {
389		assert.Empty(t, tk.Secret, "list never shows secrets")
390		byLabel[tk.Label] = tk
391	}
392	assert.Len(t, byLabel["daily"].Scopes, len(client.AllScopes()), "the cli preset grants every scope")
393	assert.Equal(t, tmp.ID, byLabel["tmp"].ID)
394	assert.NotContains(t, res.stdout, `"secret"`)
395	res = c.cli(t, secret, "token", "list").ok(t)
396	assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+BRANCHES\s+EXPIRES\s+LAST USED$`, res.stdout)
397	assert.Contains(t, res.stdout, tmp.ID)
398
399	stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID)
400	require.Equal(t, 0, code, stderr)
401	assert.Equal(t, "revoked "+tmp.ID+"\n", stdout)
402	_, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID)
403	requireOneLineError(t, stderr, code, "not found")
404	_, stderr, code = c.bin(t, tmp.Secret, "repo", "list")
405	requireOneLineError(t, stderr, code, "not authenticated", "ERMITE_TOKEN")
406	res = c.cli(t, secret, "token", "list", "--json").ok(t)
407	assert.Len(t, decode[[]tokenOut](t, res.stdout), 1)
408
409	// Delete: refuses without confirmation, then --yes; the repo is gone and the name free.
410	stdout, stderr, code = c.bin(t, secret, "repo", "delete", "proj")
411	assert.Empty(t, stdout)
412	assert.Equal(t, 1, code)
413	assert.Contains(t, stderr, "ermite: aborted")
414	c.cli(t, secret, "repo", "show", "proj").ok(t)
415
416	stdout, stderr, code = c.bin(t, secret, "repo", "delete", "proj", "--yes")
417	require.Equal(t, 0, code, stderr)
418	assert.Equal(t, "deleted proj\n", stdout)
419	ev = decode[eventOut](t, w.next(t, time.Second))
420	assert.Equal(t, "repo_deleted", ev.Type)
421	assert.Equal(t, "proj", ev.Repo)
422
423	stdout, stderr, code = c.bin(t, secret, "repo", "show", "proj")
424	assert.Empty(t, stdout)
425	requireOneLineError(t, stderr, code, "not found")
426	res = c.cli(t, secret, "repo", "list", "--json").ok(t)
427	assert.JSONEq(t, `[]`, res.stdout)
428
429	// Ctrl-C ends the watch cleanly.
430	assert.Equal(t, 0, w.interrupt(t))
431	assert.NotContains(t, w.stderr.String(), "connection lost")
432
433	// No secret reached the server's logs.
434	assert.NotContains(t, c.logs.String(), secret)
435	assert.NotContains(t, c.logs.String(), tmp.Secret)
436}
437
438// A bad or missing token gets the hint about ERMITE_TOKEN; the real server
439// answers on the Connect protocol, so the CLI sees `unauthenticated`.
440func TestAC6CLIUnauthenticatedHint(t *testing.T) {
441	c := newCLIHarness(t)
442	c.seed("pub", domain.VisibilityPublic)
443
444	bad := "ermite_" + strings.Repeat("x", 43)
445	stdout, stderr, code := c.bin(t, bad, "repo", "list")
446	assert.Empty(t, stdout)
447	requireOneLineError(t, stderr, code, "not authenticated", "ERMITE_TOKEN")
448	assert.NotContains(t, stderr, bad)
449
450	// Anonymous reads of public repositories work, and anonymous writes ask for a token.
451	res := c.cli(t, "", "repo", "show", "pub", "--json").ok(t)
452	assert.Equal(t, "pub", decode[repoOut](t, res.stdout).Name)
453	_, stderr, code = c.bin(t, "", "repo", "create", "x")
454	requireOneLineError(t, stderr, code, "ERMITE_TOKEN")
455	_, stderr, code = c.bin(t, "", "token", "list")
456	requireOneLineError(t, stderr, code, "ERMITE_TOKEN")
457}
458
459// Once the server blocks a client for failed authentications, the CLI says so
460// in one line instead of showing a raw error.
461func TestAC6CLIBlockedClient(t *testing.T) {
462	c := newCLIHarness(t)
463	bad := "ermite_" + strings.Repeat("y", 43)
464	var last result
465	for range 50 {
466		last = c.cli(t, bad, "repo", "list")
467		require.Error(t, last.err)
468		if strings.Contains(last.err.Error(), "too many") {
469			break
470		}
471		assert.Contains(t, last.err.Error(), "not authenticated")
472	}
473	require.Contains(t, last.err.Error(), "too many requests", "the limiter never blocked")
474	assert.NotContains(t, last.err.Error(), "\n")
475
476	_, stderr, code := c.bin(t, adminToken, "repo", "list")
477	requireOneLineError(t, stderr, code, "too many requests")
478}
479
480// The client survives a dropped connection: it reconnects with backoff and
481// resumes after the last event it printed, without losing or repeating events.
482func TestAC6CLIWatchResumesAfterDrop(t *testing.T) {
483	c := newCLIHarness(t)
484	w := c.startWatch(t, adminToken)
485
486	c.cli(t, adminToken, "repo", "create", "one").ok(t)
487	first := decode[eventOut](t, w.next(t, time.Second))
488	assert.Equal(t, "one", first.Repo)
489
490	// Cut every connection, then make an event happen while the CLI is away.
491	c.srv.CloseClientConnections()
492	c.cli(t, adminToken, "repo", "create", "two").ok(t)
493	c.cli(t, adminToken, "repo", "create", "three").ok(t)
494
495	second := decode[eventOut](t, w.next(t, 10*time.Second))
496	third := decode[eventOut](t, w.next(t, 2*time.Second))
497	assert.Equal(t, []string{"two", "three"}, []string{second.Repo, third.Repo}, "replayed in order, none repeated")
498	assert.Equal(t, "repo_created", second.Type)
499	assert.NotEqual(t, first.ID, second.ID)
500
501	// Live again after the replay.
502	c.cli(t, adminToken, "repo", "create", "four").ok(t)
503	assert.Equal(t, "four", decode[eventOut](t, w.next(t, time.Second)).Repo)
504
505	assert.Equal(t, 0, w.interrupt(t))
506	assert.Contains(t, w.stderr.String(), "ermite: connection lost")
507	assert.Contains(t, w.stderr.String(), "ermite: reconnected")
508	assert.NotContains(t, w.stderr.String(), "no resume point", "the first event gave a resume point")
509}
510
511// A watch on one repository only sees that repository, and an unknown one is
512// a one-line not_found without any retry.
513func TestAC6CLIWatchOneRepo(t *testing.T) {
514	c := newCLIHarness(t)
515	c.seed("a", domain.VisibilityPublic)
516	c.seed("b", domain.VisibilityPublic)
517
518	stdout, stderr, code := c.bin(t, adminToken, "watch", "missing")
519	assert.Empty(t, stdout)
520	requireOneLineError(t, stderr, code, "not found")
521
522	w := c.startWatch(t, adminToken, "a")
523	wt := c.workTree("main", "f")
524	c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/b.git"), "main")
525	c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/a.git"), "main")
526	ev := decode[eventOut](t, w.next(t, time.Second))
527	assert.Equal(t, "pushed", ev.Type)
528	assert.Equal(t, "a", ev.Repo)
529	assert.Equal(t, 0, w.interrupt(t))
530}

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 3

Tags 44

↑↓ moveenter openesc close