v0.35.0 e86dbd6

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

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 4

Tags 45

↑↓ moveenter openesc close