v0.1.0 3f267b5

tokens_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app_test
4
5import (
6	"context"
7	"encoding/base64"
8	"errors"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/mock"
15	"github.com/stretchr/testify/require"
16
17	"bnema.dev/ermite/internal/app"
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports/mocks"
20)
21
22type tokenFixture struct {
23	store  *mocks.TokenStore
24	clock  *mocks.Clock
25	tokens *app.Tokens
26	auth   *app.Auth
27}
28
29var rootSecret = "root-secret-0123456789abcdef0123456789"
30
31func newTokenFixture(t *testing.T) *tokenFixture {
32	t.Helper()
33	f := &tokenFixture{store: mocks.NewTokenStore(t), clock: mocks.NewClock(t)}
34	f.clock.EXPECT().Now().Return(now).Maybe()
35	f.tokens = app.NewTokens(f.store, f.clock)
36	f.auth = app.NewAuth(domain.HashTokenSecret(rootSecret), f.store, f.clock)
37	return f
38}
39
40func scopes(s ...domain.Scope) []domain.Scope { return s }
41
42func names(t *testing.T, ss ...string) []domain.RepoName {
43	t.Helper()
44	var out []domain.RepoName
45	for _, s := range ss {
46		out = append(out, name(t, s))
47	}
48	return out
49}
50
51func TestTokensCreate(t *testing.T) {
52	ctx := context.Background()
53
54	t.Run("root creates a token with a well-formed secret", func(t *testing.T) {
55		f := newTokenFixture(t)
56		var stored domain.Token
57		f.store.EXPECT().Create(ctx, mock.Anything).Run(func(_ context.Context, tok domain.Token) { stored = tok }).Return(nil)
58
59		exp := now.Add(48 * time.Hour)
60		got, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{
61			Label:     "ci",
62			Scopes:    scopes(domain.ScopeRepoWrite, domain.ScopeRepoRead, domain.ScopeRepoRead),
63			Repos:     names(t, "b", "a", "b"),
64			ExpiresAt: exp,
65		})
66		require.NoError(t, err)
67
68		assert.True(t, strings.HasPrefix(got.Secret, "ermite_"))
69		raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(got.Secret, "ermite_"))
70		require.NoError(t, err)
71		assert.Len(t, raw, 32)
72
73		assert.Equal(t, stored, got.Token)
74		assert.Equal(t, domain.HashTokenSecret(got.Secret), got.Token.Hash)
75		assert.Len(t, got.Token.ID, 16)
76		assert.Equal(t, scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), got.Token.Scopes, "canonical order, deduplicated")
77		assert.Equal(t, names(t, "a", "b"), got.Token.Repos, "sorted, deduplicated")
78		assert.Equal(t, now, got.Token.CreatedAt)
79		assert.True(t, exp.Equal(got.Token.ExpiresAt))
80		assert.NotContains(t, got.Token.Label, got.Secret)
81	})
82
83	t.Run("secrets are unique", func(t *testing.T) {
84		f := newTokenFixture(t)
85		f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
86		a, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
87		require.NoError(t, err)
88		b, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
89		require.NoError(t, err)
90		assert.NotEqual(t, a.Secret, b.Secret)
91		assert.NotEqual(t, a.Token.ID, b.Token.ID)
92	})
93
94	t.Run("retries on a collision", func(t *testing.T) {
95		f := newTokenFixture(t)
96		f.store.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists).Once()
97		f.store.EXPECT().Create(ctx, mock.Anything).Return(nil).Once()
98		_, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
99		require.NoError(t, err)
100	})
101
102	t.Run("store failure", func(t *testing.T) {
103		f := newTokenFixture(t)
104		boom := errors.New("boom")
105		f.store.EXPECT().Create(ctx, mock.Anything).Return(boom)
106		_, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
107		assert.ErrorIs(t, err, boom)
108	})
109
110	invalid := []struct {
111		name   string
112		params app.CreateTokenParams
113		want   error
114	}{
115		{"empty label", app.CreateTokenParams{Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
116		{"long label", app.CreateTokenParams{Label: strings.Repeat("x", 101), Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
117		{"bad utf8 label", app.CreateTokenParams{Label: "\xff", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
118		{"no scopes", app.CreateTokenParams{Label: "x"}, domain.ErrInvalidToken},
119		{"unknown scope", app.CreateTokenParams{Label: "x", Scopes: scopes("nope")}, domain.ErrInvalidScope},
120		{"past expiry", app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: now}, domain.ErrInvalidToken},
121		{"zero repo name", app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: []domain.RepoName{{}}}, domain.ErrInvalidToken},
122	}
123	for _, tt := range invalid {
124		t.Run("invalid/"+tt.name, func(t *testing.T) {
125			f := newTokenFixture(t)
126			_, err := f.tokens.Create(ctx, domain.Root(), tt.params)
127			assert.ErrorIs(t, err, tt.want)
128		})
129	}
130}
131
132func TestTokensCreateNoEscalation(t *testing.T) {
133	ctx := context.Background()
134	exp := now.Add(time.Hour)
135	admin := func(repos []domain.RepoName, expires time.Time, extra ...domain.Scope) domain.Principal {
136		return domain.TokenPrincipal(domain.Token{
137			ID:        "creator",
138			Scopes:    append(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), extra...),
139			Repos:     repos,
140			ExpiresAt: expires,
141		})
142	}
143
144	tests := []struct {
145		name      string
146		principal domain.Principal
147		params    app.CreateTokenParams
148		want      error // nil = allowed
149	}{
150		{"anonymous", domain.Anonymous(), app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrUnauthenticated},
151		{"token without token:admin",
152			domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)}),
153			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
154		{"subset of scopes", admin(nil, time.Time{}),
155			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, nil},
156		{"same scopes incl. token:admin", admin(nil, time.Time{}),
157			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite)}, nil},
158		{"scope it lacks", admin(nil, time.Time{}),
159			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin)}, domain.ErrForbidden},
160		{"limited creator, same repos", admin(names(t, "a", "b"), time.Time{}),
161			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "a")}, nil},
162		{"limited creator, repo outside its list", admin(names(t, "a"), time.Time{}),
163			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "a", "z")}, domain.ErrForbidden},
164		{"limited creator, unlimited child", admin(names(t, "a"), time.Time{}),
165			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
166		{"unlimited creator may limit", admin(nil, time.Time{}),
167			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "z")}, nil},
168		{"expiring creator, child never expires", admin(nil, exp),
169			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
170		{"expiring creator, child outlasts", admin(nil, exp),
171			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp.Add(time.Second)}, domain.ErrForbidden},
172		{"expiring creator, child within", admin(nil, exp),
173			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp.Add(-time.Second)}, nil},
174		{"expiring creator, child same expiry", admin(nil, exp),
175			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil},
176		{"root: everything", domain.Root(),
177			app.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil},
178	}
179	for _, tt := range tests {
180		t.Run(tt.name, func(t *testing.T) {
181			f := newTokenFixture(t)
182			if tt.want == nil {
183				f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
184			}
185			got, err := f.tokens.Create(ctx, tt.principal, tt.params)
186			if tt.want != nil {
187				assert.ErrorIs(t, err, tt.want)
188				assert.Empty(t, got.Secret)
189				return
190			}
191			require.NoError(t, err)
192			assert.NotEmpty(t, got.Secret)
193		})
194	}
195}
196
197func TestTokensListRevoke(t *testing.T) {
198	ctx := context.Background()
199	tokenAdmin := domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: scopes(domain.ScopeTokenAdmin)})
200	writer := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)})
201
202	t.Run("list for the admin token and root", func(t *testing.T) {
203		f := newTokenFixture(t)
204		want := []domain.Token{{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}}
205		f.store.EXPECT().List(ctx).Return(want, nil).Twice()
206		for _, p := range []domain.Principal{domain.Root(), tokenAdmin} {
207			got, err := f.tokens.List(ctx, p)
208			require.NoError(t, err)
209			assert.Equal(t, want, got)
210		}
211		_, err := f.tokens.List(ctx, writer)
212		assert.ErrorIs(t, err, domain.ErrForbidden)
213		_, err = f.tokens.List(ctx, domain.Anonymous())
214		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
215	})
216
217	t.Run("revoke", func(t *testing.T) {
218		f := newTokenFixture(t)
219		f.store.EXPECT().Get(ctx, "a").Return(domain.Token{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}, nil).Once()
220		f.store.EXPECT().Revoke(ctx, "a").Return(nil).Twice()
221		f.store.EXPECT().Revoke(ctx, "zz").Return(domain.ErrNotFound).Once()
222		require.NoError(t, f.tokens.Revoke(ctx, tokenAdmin, "a"))
223		require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "a"))
224		assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Root(), "zz"), domain.ErrNotFound)
225		assert.ErrorIs(t, f.tokens.Revoke(ctx, writer, "a"), domain.ErrForbidden)
226		assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Anonymous(), "a"), domain.ErrUnauthenticated)
227	})
228}
229
230// A token admin sees and revokes only the tokens it could have created: the
231// no-escalation rule of Create, applied to scopes, repositories and expiry.
232func TestTokensAdminReach(t *testing.T) {
233	ctx := context.Background()
234	exp := now.Add(48 * time.Hour)
235	admin := func(repos []domain.RepoName, expires time.Time, extra ...domain.Scope) domain.Principal {
236		return domain.TokenPrincipal(domain.Token{
237			ID:        "caller",
238			Scopes:    append(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), extra...),
239			Repos:     repos,
240			ExpiresAt: expires,
241		})
242	}
243	tok := func(id string, sc []domain.Scope, repos []domain.RepoName, expires time.Time) domain.Token {
244		return domain.Token{ID: id, Scopes: sc, Repos: repos, ExpiresAt: expires}
245	}
246	rw := scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite)
247	ab, a := names(t, "a", "b"), names(t, "a")
248
249	tests := []struct {
250		name   string
251		caller domain.Principal
252		target domain.Token
253		within bool
254	}{
255		{"fewer scopes", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead), nil, time.Time{}), true},
256		{"the same rights", admin(ab, exp), tok("t", rw, ab, exp), true},
257		{"a scope the caller lacks", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), nil, time.Time{}), false},
258		{"root-only reach: a token:admin caller cannot reach a mr:merge token", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeMRMerge), nil, time.Time{}), false},
259		{"unlimited token, limited caller", admin(a, time.Time{}), tok("t", rw, nil, time.Time{}), false},
260		{"repo outside the caller's list", admin(a, time.Time{}), tok("t", rw, ab, time.Time{}), false},
261		{"repo inside the caller's list", admin(ab, time.Time{}), tok("t", rw, a, time.Time{}), true},
262		{"limited token, unlimited caller", admin(nil, time.Time{}), tok("t", rw, ab, time.Time{}), true},
263		{"never expires, caller expires", admin(nil, exp), tok("t", rw, nil, time.Time{}), false},
264		{"expires later than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(time.Hour)), false},
265		{"expires sooner than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(-time.Hour)), true},
266		{"expiring token, caller never expires", admin(nil, time.Time{}), tok("t", rw, nil, exp), true},
267	}
268	for _, tt := range tests {
269		t.Run("list: "+tt.name, func(t *testing.T) {
270			f := newTokenFixture(t)
271			other := tok("other", scopes(domain.ScopeRepoRead), nil, time.Time{})
272			f.store.EXPECT().List(ctx).Return([]domain.Token{tt.target, other}, nil)
273			got, err := f.tokens.List(ctx, tt.caller)
274			require.NoError(t, err)
275			if !tt.within {
276				// Only the token that is within reach of every caller shows.
277				assert.NotContains(t, got, tt.target)
278				return
279			}
280			assert.Contains(t, got, tt.target)
281		})
282		t.Run("revoke: "+tt.name, func(t *testing.T) {
283			f := newTokenFixture(t)
284			f.store.EXPECT().Get(ctx, "t").Return(tt.target, nil)
285			if tt.within {
286				f.store.EXPECT().Revoke(ctx, "t").Return(nil)
287				require.NoError(t, f.tokens.Revoke(ctx, tt.caller, "t"))
288				return
289			}
290			// Not leaked: the same answer as for a token that does not exist, and nothing is deleted.
291			assert.ErrorIs(t, f.tokens.Revoke(ctx, tt.caller, "t"), domain.ErrNotFound)
292		})
293	}
294
295	t.Run("root lists and revokes everything", func(t *testing.T) {
296		f := newTokenFixture(t)
297		all := []domain.Token{tok("x", domain.AllScopes(), nil, time.Time{}), tok("y", scopes(domain.ScopeMRMerge), a, exp)}
298		f.store.EXPECT().List(ctx).Return(all, nil)
299		got, err := f.tokens.List(ctx, domain.Root())
300		require.NoError(t, err)
301		assert.Equal(t, all, got)
302		f.store.EXPECT().Revoke(ctx, "x").Return(nil) // no Get: root has no limits
303		require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "x"))
304	})
305
306	t.Run("an unknown id is not found for everyone", func(t *testing.T) {
307		f := newTokenFixture(t)
308		f.store.EXPECT().Get(ctx, "nope").Return(domain.Token{}, domain.ErrNotFound)
309		assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "nope"), domain.ErrNotFound)
310	})
311
312	t.Run("a store failure on the lookup is returned", func(t *testing.T) {
313		f := newTokenFixture(t)
314		boom := errors.New("boom")
315		f.store.EXPECT().Get(ctx, "t").Return(domain.Token{}, boom)
316		assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "t"), boom)
317	})
318}
319
320func TestAuthRevalidate(t *testing.T) {
321	ctx := context.Background()
322	p := domain.TokenPrincipal(domain.Token{ID: "t1", Scopes: scopes(domain.ScopeRepoWrite)})
323
324	t.Run("root is always valid", func(t *testing.T) {
325		f := newTokenFixture(t)
326		require.NoError(t, f.auth.Revalidate(ctx, domain.Root()))
327	})
328	t.Run("anonymous is not", func(t *testing.T) {
329		f := newTokenFixture(t)
330		assert.ErrorIs(t, f.auth.Revalidate(ctx, domain.Anonymous()), domain.ErrUnauthenticated)
331	})
332	t.Run("a stored token that exists", func(t *testing.T) {
333		f := newTokenFixture(t)
334		f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now.Add(time.Hour)}, nil)
335		require.NoError(t, f.auth.Revalidate(ctx, p))
336	})
337	t.Run("revoked", func(t *testing.T) {
338		f := newTokenFixture(t)
339		f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, domain.ErrNotFound)
340		assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated)
341	})
342	t.Run("expired", func(t *testing.T) {
343		f := newTokenFixture(t)
344		f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now}, nil)
345		assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated)
346	})
347	t.Run("a store failure is an error, not a pass", func(t *testing.T) {
348		f := newTokenFixture(t)
349		boom := errors.New("boom")
350		f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, boom)
351		err := f.auth.Revalidate(ctx, p)
352		require.ErrorIs(t, err, boom)
353		assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
354	})
355}
356
357func TestAuthenticate(t *testing.T) {
358	ctx := context.Background()
359	secret := "ermite_stored-secret"
360	hash := domain.HashTokenSecret(secret)
361	tok := domain.Token{
362		ID: "t1", Hash: hash, Scopes: scopes(domain.ScopeRepoWrite), Repos: names(t, "a"),
363		ExpiresAt: now.Add(time.Hour),
364	}
365
366	t.Run("root", func(t *testing.T) {
367		f := newTokenFixture(t)
368		p, err := f.auth.Authenticate(ctx, rootSecret)
369		require.NoError(t, err)
370		assert.True(t, p.IsRoot())
371	})
372
373	t.Run("empty and unknown secrets", func(t *testing.T) {
374		f := newTokenFixture(t)
375		_, err := f.auth.Authenticate(ctx, "")
376		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
377		f.store.EXPECT().FindByHash(ctx, domain.HashTokenSecret("nope")).Return(domain.Token{}, domain.ErrNotFound)
378		_, err = f.auth.Authenticate(ctx, "nope")
379		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
380	})
381
382	t.Run("stored token first use touches", func(t *testing.T) {
383		f := newTokenFixture(t)
384		f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil)
385		f.store.EXPECT().Touch(ctx, "t1", now).Return(nil)
386		p, err := f.auth.Authenticate(ctx, secret)
387		require.NoError(t, err)
388		assert.Equal(t, "t1", p.TokenID())
389		assert.True(t, p.Allows(domain.ScopeRepoWrite, name(t, "a")))
390		assert.False(t, p.Allows(domain.ScopeRepoWrite, name(t, "b")))
391	})
392
393	t.Run("touch at most once a minute", func(t *testing.T) {
394		f := newTokenFixture(t)
395		recent := tok
396		recent.LastUsedAt = now.Add(-30 * time.Second)
397		f.store.EXPECT().FindByHash(ctx, hash).Return(recent, nil)
398		_, err := f.auth.Authenticate(ctx, secret) // no Touch expected
399		require.NoError(t, err)
400
401		f2 := newTokenFixture(t)
402		old := tok
403		old.LastUsedAt = now.Add(-time.Minute)
404		f2.store.EXPECT().FindByHash(ctx, hash).Return(old, nil)
405		f2.store.EXPECT().Touch(ctx, "t1", now).Return(nil)
406		_, err = f2.auth.Authenticate(ctx, secret)
407		require.NoError(t, err)
408	})
409
410	t.Run("touch failure does not fail authentication", func(t *testing.T) {
411		f := newTokenFixture(t)
412		f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil)
413		f.store.EXPECT().Touch(ctx, "t1", now).Return(errors.New("disk"))
414		_, err := f.auth.Authenticate(ctx, secret)
415		require.NoError(t, err)
416	})
417
418	t.Run("expired", func(t *testing.T) {
419		f := newTokenFixture(t)
420		exp := tok
421		exp.ExpiresAt = now
422		f.store.EXPECT().FindByHash(ctx, hash).Return(exp, nil)
423		_, err := f.auth.Authenticate(ctx, secret)
424		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
425	})
426
427	t.Run("store error is not an authentication failure", func(t *testing.T) {
428		f := newTokenFixture(t)
429		boom := errors.New("boom")
430		f.store.EXPECT().FindByHash(ctx, hash).Return(domain.Token{}, boom)
431		_, err := f.auth.Authenticate(ctx, secret)
432		assert.ErrorIs(t, err, boom)
433		assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
434	})
435}

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 44

↑↓ moveenter openesc close