v0.31.0 d5e0505

cli_repo_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"errors"
7	"testing"
8
9	"connectrpc.com/connect"
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/require"
12	"google.golang.org/protobuf/proto"
13
14	"bnema.dev/ermite-api/gen/ermite"
15)
16
17var (
18	repoAlpha = &ermite.Repo{
19		Name: "alpha", Description: "The first one", Topics: []string{"cli", "go"}, Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
20		DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),
21		Settings: &ermite.RepoSettings{
22			AllowedMergeModes:  []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},
23			FailingChecksBlock: true,
24		},
25	}
26	repoBeta = &ermite.Repo{
27		Name: "beta", Visibility: ermite.Visibility_VISIBILITY_PRIVATE,
28		DefaultBranch: "trunk", CreatedAt: ts("2026-02-02T00:00:00Z"),
29		Settings: &ermite.RepoSettings{AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH}},
30	}
31)
32
33func envFor(url string) map[string]string {
34	return map[string]string{"ERMITE_URL": url, "ERMITE_TOKEN": "ermite_tok"}
35}
36
37func TestRepoList(t *testing.T) {
38	both := []*ermite.Repo{repoAlpha, repoBeta}
39	tests := []struct {
40		name       string
41		repos      []*ermite.Repo
42		args       []string
43		wantStdout string
44		wantJSON   bool // compare stdout as JSON rather than byte for byte
45		wantStderr string
46	}{
47		{
48			name: "table", repos: both, args: []string{"repo", "list"},
49			wantStdout: "" +
50				"NAME   VISIBILITY  BRANCH  LAST PUSH         DESCRIPTION\n" +
51				"alpha  public      main    2026-03-04 05:06  The first one\n" +
52				"beta   private     trunk   never             \n",
53		},
54		{
55			name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
56			wantStdout: `[
57				{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"},
58				{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}
59			]`,
60		},
61		{
62			name: "empty, table", args: []string{"repo", "list"},
63			wantStderr: "ermite: no repositories\n",
64		},
65		{
66			name: "empty, json is [] not null", args: []string{"repo", "list", "--json"},
67			wantStdout: "[]\n",
68		},
69	}
70	for _, tt := range tests {
71		t.Run(tt.name, func(t *testing.T) {
72			f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
73				return &ermite.ListReposResponse{Repos: tt.repos}, nil
74			}}
75			res := runCLI(t, envFor(f.serve(t)), "", tt.args...).ok(t)
76			if tt.wantJSON {
77				assert.JSONEq(t, tt.wantStdout, res.stdout)
78			} else {
79				assert.Equal(t, tt.wantStdout, res.stdout)
80			}
81			assert.Equal(t, tt.wantStderr, res.stderr)
82			assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization"))
83			assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent"))
84		})
85	}
86}
87
88func TestRepoListFollowsPages(t *testing.T) {
89	f := &fakeAPI{listRepos: func(r *ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
90		if r.GetPageToken() == "" {
91			return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}, NextPageToken: "p2"}, nil
92		}
93		return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoBeta}}, nil
94	}}
95	res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t)
96	assert.Contains(t, res.stdout, `"alpha"`)
97	assert.Contains(t, res.stdout, `"beta"`)
98	_, reqs := f.snapshot()
99	require.Len(t, reqs, 2)
100	assert.Equal(t, "p2", reqs[1].(*ermite.ListReposRequest).GetPageToken())
101}
102
103func TestRepoShow(t *testing.T) {
104	f := &fakeAPI{getRepo: func(r *ermite.GetRepoRequest) (*ermite.GetRepoResponse, error) {
105		return &ermite.GetRepoResponse{Repo: repoAlpha}, nil
106	}}
107	url := f.serve(t)
108	res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
109	assert.Equal(t, ""+
110		"name                  alpha\n"+
111		"visibility            public\n"+
112		"default branch        main\n"+
113		"description           The first one\n"+
114		"topics                cli, go\n"+
115		"created               2026-01-02 03:04\n"+
116		"last push             2026-03-04 05:06\n"+
117		"merge modes           fast-forward, squash\n"+
118		"failing checks block  true\n"+
119		"allow auto-merge      false\n"+
120		"issues                off\n", res.stdout)
121	_, reqs := f.snapshot()
122	assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
123
124	res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
125	assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"}`, res.stdout)
126}
127
128func TestRepoShowNeedsOneArg(t *testing.T) {
129	res := runCLI(t, envFor("http://127.0.0.1:1"), "", "repo", "show")
130	require.Error(t, res.err)
131}
132
133func TestRepoCreateMapsFlags(t *testing.T) {
134	tests := []struct {
135		name   string
136		args   []string
137		want   ermite.Visibility
138		desc   string
139		topics []string
140	}{
141		{"default is private", []string{"x"}, ermite.Visibility_VISIBILITY_PRIVATE, "", nil},
142		{"--visibility public", []string{"x", "--visibility", "public", "--description", "d d"}, ermite.Visibility_VISIBILITY_PUBLIC, "d d", nil},
143		{"case insensitive", []string{"x", "--visibility", "Public"}, ermite.Visibility_VISIBILITY_PUBLIC, "", nil},
144		{"--topics", []string{"x", "--topics", "go,cli"}, ermite.Visibility_VISIBILITY_PRIVATE, "", []string{"go", "cli"}},
145	}
146	for _, tt := range tests {
147		t.Run(tt.name, func(t *testing.T) {
148			f := &fakeAPI{createRepo: func(r *ermite.CreateRepoRequest) (*ermite.CreateRepoResponse, error) {
149				return &ermite.CreateRepoResponse{Repo: repoAlpha}, nil
150			}}
151			runCLI(t, envFor(f.serve(t)), "", append([]string{"repo", "create"}, tt.args...)...).ok(t)
152			_, reqs := f.snapshot()
153			require.Len(t, reqs, 1)
154			got := reqs[0].(*ermite.CreateRepoRequest)
155			assert.Equal(t, "x", got.GetName())
156			assert.Equal(t, tt.want, got.GetVisibility())
157			assert.Equal(t, tt.desc, got.GetDescription())
158			assert.Equal(t, tt.topics, got.GetTopics())
159		})
160	}
161}
162
163func TestRepoCreateRejectsBadFlagsWithoutCalling(t *testing.T) {
164	f := &fakeAPI{}
165	url := f.serve(t)
166	for _, args := range [][]string{
167		{"repo", "create", "x", "--visibility", "secret"},
168	} {
169		res := runCLI(t, envFor(url), "", args...)
170		require.Error(t, res.err, "%v", args)
171	}
172	calls, _ := f.snapshot()
173	assert.Empty(t, calls)
174}
175
176func TestRepoCreateJSON(t *testing.T) {
177	f := &fakeAPI{createRepo: func(r *ermite.CreateRepoRequest) (*ermite.CreateRepoResponse, error) {
178		return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
179	}}
180	res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
181	assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}`, res.stdout)
182}
183
184func TestRepoEditMask(t *testing.T) {
185	tests := []struct {
186		name  string
187		args  []string
188		paths []string
189		check func(*testing.T, *ermite.UpdateRepoRequest)
190	}{
191		{"description only", []string{"--description", "new"}, []string{"description"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
192			assert.Equal(t, "new", r.GetDescription())
193		}},
194		{"clear description", []string{"--description", ""}, []string{"description"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
195			assert.Empty(t, r.GetDescription())
196		}},
197		{"topics", []string{"--topics", "Go,cli"}, []string{"topics"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
198			assert.Equal(t, []string{"Go", "cli"}, r.GetTopics())
199		}},
200		{"clear topics", []string{"--topics", ""}, []string{"topics"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
201			assert.Empty(t, r.GetTopics())
202		}},
203		{"visibility", []string{"--visibility", "public"}, []string{"visibility"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
204			assert.Equal(t, ermite.Visibility_VISIBILITY_PUBLIC, r.GetVisibility())
205		}},
206		{"default branch", []string{"--default-branch", "refs/heads/dev"}, []string{"default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
207			assert.Equal(t, "dev", r.GetDefaultBranch())
208		}},
209		{"merge modes", []string{"--merge-modes", "squash,ff"}, []string{"settings.allowed_merge_modes"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
210			assert.Equal(t, []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH, ermite.MergeMode_MERGE_MODE_FAST_FORWARD}, r.GetSettings().GetAllowedMergeModes())
211		}},
212		{"failing checks do not block", []string{"--failing-checks-block=false"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
213			assert.False(t, r.GetSettings().GetFailingChecksBlock())
214			assert.Empty(t, r.GetSettings().GetAllowedMergeModes())
215		}},
216		{"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
217			assert.True(t, r.GetSettings().GetFailingChecksBlock())
218		}},
219		{"allow auto-merge", []string{"--allow-auto-merge"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
220			assert.True(t, r.GetSettings().GetAllowAutoMerge())
221			assert.False(t, r.GetSettings().GetFailingChecksBlock())
222		}},
223		{"forbid auto-merge", []string{"--allow-auto-merge=false"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
224			assert.False(t, r.GetSettings().GetAllowAutoMerge())
225		}},
226		{"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"},
227			[]string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}},
228	}
229	for _, tt := range tests {
230		t.Run(tt.name, func(t *testing.T) {
231			f := &fakeAPI{updateRepo: func(r *ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) {
232				return &ermite.UpdateRepoResponse{Repo: repoAlpha}, nil
233			}}
234			runCLI(t, envFor(f.serve(t)), "", append([]string{"repo", "edit", "alpha"}, tt.args...)...).ok(t)
235			_, reqs := f.snapshot()
236			require.Len(t, reqs, 1)
237			got := reqs[0].(*ermite.UpdateRepoRequest)
238			assert.Equal(t, "alpha", got.GetRepo())
239			assert.ElementsMatch(t, tt.paths, got.GetUpdateMask().GetPaths())
240			tt.check(t, got)
241		})
242	}
243}
244
245func TestRepoEditRejects(t *testing.T) {
246	f := &fakeAPI{}
247	url := f.serve(t)
248	for _, tt := range []struct {
249		args []string
250		want string
251	}{
252		{[]string{"repo", "edit", "alpha"}, "nothing to change"},
253		{[]string{"repo", "edit", "alpha", "--visibility", "x"}, "invalid visibility"},
254		{[]string{"repo", "edit", "alpha", "--default-branch", " "}, "must not be empty"},
255		{[]string{"repo", "edit", "alpha", "--merge-modes", "rebase"}, "invalid merge mode"},
256		{[]string{"repo", "edit", "alpha", "--merge-modes", ""}, "at least one mode"},
257	} {
258		res := runCLI(t, envFor(url), "", tt.args...)
259		require.Error(t, res.err)
260		assert.Contains(t, res.err.Error(), tt.want)
261	}
262	calls, _ := f.snapshot()
263	assert.Empty(t, calls)
264}
265
266func TestRepoDeleteConfirmation(t *testing.T) {
267	newFake := func() *fakeAPI {
268		return &fakeAPI{deleteRepo: func(*ermite.DeleteRepoRequest) (*ermite.DeleteRepoResponse, error) {
269			return &ermite.DeleteRepoResponse{}, nil
270		}}
271	}
272	t.Run("typed name deletes", func(t *testing.T) {
273		f := newFake()
274		res := runCLI(t, envFor(f.serve(t)), "alpha\n", "repo", "delete", "alpha").ok(t)
275		assert.Equal(t, "deleted alpha\n", res.stdout)
276		assert.Contains(t, res.stderr, "Type its name to confirm")
277		calls, reqs := f.snapshot()
278		assert.Equal(t, []string{"DeleteRepo"}, calls)
279		assert.Equal(t, "alpha", reqs[0].(*ermite.DeleteRepoRequest).GetRepo())
280	})
281	t.Run("last line without newline is accepted", func(t *testing.T) {
282		f := newFake()
283		runCLI(t, envFor(f.serve(t)), "alpha", "repo", "delete", "alpha").ok(t)
284		calls, _ := f.snapshot()
285		assert.Equal(t, []string{"DeleteRepo"}, calls)
286	})
287	t.Run("wrong name aborts", func(t *testing.T) {
288		f := newFake()
289		res := runCLI(t, envFor(f.serve(t)), "beta\n", "repo", "delete", "alpha")
290		require.Error(t, res.err)
291		assert.Contains(t, res.err.Error(), "did not match")
292		calls, _ := f.snapshot()
293		assert.Empty(t, calls)
294	})
295	t.Run("empty stdin aborts", func(t *testing.T) {
296		f := newFake()
297		res := runCLI(t, envFor(f.serve(t)), "", "repo", "delete", "alpha")
298		require.Error(t, res.err)
299		assert.Contains(t, res.err.Error(), "--yes")
300		calls, _ := f.snapshot()
301		assert.Empty(t, calls)
302	})
303	t.Run("--yes skips the question", func(t *testing.T) {
304		f := newFake()
305		res := runCLI(t, envFor(f.serve(t)), "", "repo", "delete", "alpha", "--yes").ok(t)
306		assert.Equal(t, "deleted alpha\n", res.stdout)
307		assert.NotContains(t, res.stderr, "confirm")
308	})
309	t.Run("-y", func(t *testing.T) {
310		f := newFake()
311		runCLI(t, envFor(f.serve(t)), "", "repo", "delete", "alpha", "-y").ok(t)
312	})
313}
314
315func TestRepoErrorsAreFriendly(t *testing.T) {
316	f := &fakeAPI{getRepo: func(*ermite.GetRepoRequest) (*ermite.GetRepoResponse, error) {
317		return nil, connect.NewError(connect.CodeNotFound, errors.New("repository \"nope\" not found"))
318	}}
319	res := runCLI(t, envFor(f.serve(t)), "", "repo", "show", "nope")
320	require.Error(t, res.err)
321	assert.Equal(t, "not found: repository \"nope\" not found", res.err.Error())
322}
323
324func TestUnauthenticatedHintsAtToken(t *testing.T) {
325	f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
326		return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid token"))
327	}}
328	res := runCLI(t, map[string]string{"ERMITE_URL": f.serve(t)}, "", "repo", "list")
329	require.Error(t, res.err)
330	assert.Contains(t, res.err.Error(), "ERMITE_TOKEN")
331}
332
333func TestMissingURL(t *testing.T) {
334	res := runCLI(t, nil, "", "repo", "list")
335	require.Error(t, res.err)
336	assert.Contains(t, res.err.Error(), "ERMITE_URL is not set")
337}
338
339func TestInvalidURL(t *testing.T) {
340	res := runCLI(t, map[string]string{"ERMITE_URL": "git.example.com"}, "", "repo", "list")
341	require.Error(t, res.err)
342	assert.Contains(t, res.err.Error(), "ERMITE_URL")
343	assert.Contains(t, res.err.Error(), "http")
344}
345
346func TestUnreachableServer(t *testing.T) {
347	res := runCLI(t, envFor("http://127.0.0.1:1"), "", "repo", "list")
348	require.Error(t, res.err)
349	assert.Contains(t, res.err.Error(), "cannot reach the server at http://127.0.0.1:1")
350	assert.NotContains(t, res.err.Error(), "\n")
351}
352
353func TestInsecureTokenWarningOnStderr(t *testing.T) {
354	// A non-loopback http URL: nothing listens there, the warning comes first.
355	res := runCLI(t, map[string]string{"ERMITE_URL": "http://ermite.invalid:1", "ERMITE_TOKEN": "ermite_tok"}, "", "repo", "list")
356	require.Error(t, res.err)
357	assert.Contains(t, res.stderr, "ermite: warning: ERMITE_TOKEN is sent over plain http to ermite.invalid")
358	assert.NotContains(t, res.stderr, "ermite_tok")
359}
360
361func TestLoopbackHTTPDoesNotWarn(t *testing.T) {
362	f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
363		return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
364	}}
365	res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t)
366	assert.Empty(t, res.stderr)
367}
368
369func TestTerminalControlCharactersAreNeutralised(t *testing.T) {
370	evil := &ermite.Repo{Name: "x", Description: "a\x1b[31mred\nline", Visibility: ermite.Visibility_VISIBILITY_PUBLIC}
371	f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
372		return &ermite.ListReposResponse{Repos: []*ermite.Repo{evil}}, nil
373	}}
374	res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t)
375	assert.NotContains(t, res.stdout, "\x1b")
376	assert.Equal(t, 2, len(splitLines(res.stdout)), "header and one row")
377}
378
379func splitLines(s string) []string {
380	var out []string
381	cur := ""
382	for _, r := range s {
383		if r == '\n' {
384			out = append(out, cur)
385			cur = ""
386			continue
387		}
388		cur += string(r)
389	}
390	if cur != "" {
391		out = append(out, cur)
392	}
393	return out
394}
395
396func TestRepoNameCompletion(t *testing.T) {
397	f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
398		return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil
399	}}
400	vars := envFor(f.serve(t))
401	res := runCLI(t, vars, "", "__complete", "repo", "show", "a").ok(t)
402	assert.Contains(t, res.stdout, "alpha\tpublic, The first one")
403	assert.NotContains(t, res.stdout, "beta")
404
405	res = runCLI(t, vars, "", "__complete", "repo", "delete", "").ok(t)
406	assert.Contains(t, res.stdout, "alpha")
407	assert.Contains(t, res.stdout, "beta")
408
409	// A second argument gets no repository names.
410	res = runCLI(t, vars, "", "__complete", "repo", "show", "alpha", "").ok(t)
411	assert.NotContains(t, res.stdout, "beta")
412
413	res = runCLI(t, vars, "", "__complete", "watch", "").ok(t)
414	assert.Contains(t, res.stdout, "alpha")
415}
416
417func TestCompletionIsSilentOnErrors(t *testing.T) {
418	denied := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
419		return nil, connect.NewError(connect.CodeUnauthenticated, nil)
420	}}
421	for name, vars := range map[string]map[string]string{
422		"no config":   nil,
423		"bad url":     {"ERMITE_URL": "nope"},
424		"unreachable": envFor("http://ermite.invalid:1"),
425		"rejected":    envFor(denied.serve(t)),
426	} {
427		t.Run(name, func(t *testing.T) {
428			res := runCLI(t, vars, "", "__complete", "repo", "show", "").ok(t)
429			assert.NotContains(t, res.stdout, "alpha")
430			assert.NotContains(t, res.stderr, "warning", "completion must not print warnings")
431			assert.Contains(t, res.stdout, ":4", "cobra's no-file-completion directive")
432		})
433	}
434}
435
436func TestShellCompletionScriptIsAvailable(t *testing.T) {
437	res := runCLI(t, nil, "", "completion", "bash").ok(t)
438	assert.Contains(t, res.stdout, "bash completion")
439}
440
441func TestToJSONRepoFromProto(t *testing.T) {
442	r := toJSONRepo(proto.Clone(repoBeta).(*ermite.Repo))
443	assert.Nil(t, r.PushedAt)
444	assert.Equal(t, "private", r.Visibility)
445	assert.Equal(t, "unknown", toJSONRepo(&ermite.Repo{}).Visibility)
446}
447
448func TestRepoEditAllowAutoMerge(t *testing.T) {
449	updated := &ermite.Repo{Name: "alpha", Visibility: ermite.Visibility_VISIBILITY_PUBLIC, DefaultBranch: "main",
450		Settings: &ermite.RepoSettings{AllowAutoMerge: true}}
451	f := &fakeAPI{updateRepo: func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) {
452		return &ermite.UpdateRepoResponse{Repo: updated}, nil
453	}}
454	url := f.serve(t)
455
456	res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge").ok(t)
457	assert.Regexp(t, `(?m)^allow auto-merge\s+true$`, res.stdout)
458	_, reqs := f.snapshot()
459	got := reqs[0].(*ermite.UpdateRepoRequest)
460	assert.Equal(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())
461	assert.True(t, got.GetSettings().GetAllowAutoMerge())
462
463	res = runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge", "--failing-checks-block=false", "--json").ok(t)
464	assert.Contains(t, res.stdout, `"allow_auto_merge": true`)
465	_, reqs = f.snapshot()
466	got = reqs[1].(*ermite.UpdateRepoRequest)
467	assert.ElementsMatch(t, []string{"settings.allow_auto_merge", "settings.failing_checks_block"}, got.GetUpdateMask().GetPaths())
468}

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 43

↑↓ moveenter openesc close