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