detached f7f6672

feat(domain): add repository name, visibility and principal

commit
f7f6672582087b15c1f07a95f76d5f2f6a43d9a8
author
brice · · 2026-10-10 06:09 UTC
parent
0ed56a9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +315 -0
A internal/domain/errors.go +21 -0
@@ -0,0 +1,21 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "errors"
6+
7+// Sentinel errors of the domain. Callers match them with errors.Is.
8+var (
9+ // ErrNotFound means the entity does not exist or is hidden from the caller.
10+ ErrNotFound = errors.New("not found")
11+ // ErrInvalidName means a repository name breaks the naming rules.
12+ ErrInvalidName = errors.New("invalid repository name")
13+ // ErrInvalidVisibility means a visibility value is neither public nor private.
14+ ErrInvalidVisibility = errors.New("invalid visibility")
15+ // ErrAlreadyExists means the entity to create already exists.
16+ ErrAlreadyExists = errors.New("already exists")
17+ // ErrUnauthenticated means the operation needs credentials the caller lacks.
18+ ErrUnauthenticated = errors.New("unauthenticated")
19+ // ErrConflict means a concurrent change prevented the operation.
20+ ErrConflict = errors.New("conflict")
21+)
@@ -0,0 +1,21 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "errors"
6+
7+// Sentinel errors of the domain. Callers match them with errors.Is.
8+var (
9+ // ErrNotFound means the entity does not exist or is hidden from the caller.
10+ ErrNotFound = errors.New("not found")
11+ // ErrInvalidName means a repository name breaks the naming rules.
12+ ErrInvalidName = errors.New("invalid repository name")
13+ // ErrInvalidVisibility means a visibility value is neither public nor private.
14+ ErrInvalidVisibility = errors.New("invalid visibility")
15+ // ErrAlreadyExists means the entity to create already exists.
16+ ErrAlreadyExists = errors.New("already exists")
17+ // ErrUnauthenticated means the operation needs credentials the caller lacks.
18+ ErrUnauthenticated = errors.New("unauthenticated")
19+ // ErrConflict means a concurrent change prevented the operation.
20+ ErrConflict = errors.New("conflict")
21+)
A internal/domain/name.go +103 -0
@@ -0,0 +1,103 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "strings"
8+)
9+
10+// MaxRepoNameLen is the longest repository name, in characters.
11+const MaxRepoNameLen = 100
12+
13+// gitSuffix is the suffix of a Git URL and of a repository directory.
14+const gitSuffix = ".git"
15+
16+// reservedNames cannot name a repository: they are routes of the server.
17+var reservedNames = map[string]bool{
18+ "-": true,
19+ "robots.txt": true,
20+ "favicon.ico": true,
21+}
22+
23+// RepoName is a validated repository name. Only ParseRepoName and
24+// ParseRepoPath build a non-zero value; the zero value is invalid.
25+type RepoName struct{ s string }
26+
27+// String returns the name.
28+func (n RepoName) String() string { return n.s }
29+
30+// IsZero reports whether n is the invalid zero value.
31+func (n RepoName) IsZero() bool { return n.s == "" }
32+
33+// ParseRepoName validates s as a repository name. It does not strip any
34+// ".git" suffix: a name ending in ".git" is invalid.
35+//
36+// The error matches ErrInvalidName and is an *InvalidNameError.
37+func ParseRepoName(s string) (RepoName, error) {
38+ reason := nameProblem(s)
39+ if reason == "" {
40+ return RepoName{s: s}, nil
41+ }
42+ e := &InvalidNameError{Name: s, Reason: reason}
43+ if lower := strings.ToLower(s); lower != s && nameProblem(lower) == "" {
44+ e.Suggestion = lower
45+ }
46+ return RepoName{}, e
47+}
48+
49+// ParseRepoPath parses the repository segment of a URL path. It strips one
50+// trailing ".git" before validating, so "tools.git" names "tools".
51+func ParseRepoPath(segment string) (RepoName, error) {
52+ return ParseRepoName(strings.TrimSuffix(segment, gitSuffix))
53+}
54+
55+// InvalidNameError describes why a repository name is invalid. It matches
56+// ErrInvalidName.
57+type InvalidNameError struct {
58+ Name string
59+ Reason string
60+ Suggestion string // a valid alternative, empty when there is none
61+}
62+
63+// Error implements error.
64+func (e *InvalidNameError) Error() string {
65+ msg := fmt.Sprintf("invalid repository name %q: %s", e.Name, e.Reason)
66+ if e.Suggestion != "" {
67+ msg += fmt.Sprintf(" (try %q)", e.Suggestion)
68+ }
69+ return msg
70+}
71+
72+// Unwrap makes errors.Is(err, ErrInvalidName) true.
73+func (e *InvalidNameError) Unwrap() error { return ErrInvalidName }
74+
75+// nameProblem returns why s is not a valid name, or "" when it is valid.
76+func nameProblem(s string) string {
77+ if s == "" {
78+ return "name is empty"
79+ }
80+ if len(s) > MaxRepoNameLen {
81+ return fmt.Sprintf("name is longer than %d characters", MaxRepoNameLen)
82+ }
83+ for _, r := range s {
84+ switch {
85+ case r >= 'A' && r <= 'Z':
86+ return "uppercase letters are not allowed"
87+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
88+ default:
89+ return fmt.Sprintf("character %q is not allowed (use a-z, 0-9, '.', '_', '-')", r)
90+ }
91+ }
92+ switch {
93+ case strings.HasPrefix(s, ".") || strings.HasSuffix(s, "."):
94+ return "name must not start or end with '.'"
95+ case strings.Contains(s, ".."):
96+ return "name must not contain '..'"
97+ case strings.HasSuffix(s, gitSuffix):
98+ return "name must not end in '.git'"
99+ case reservedNames[s]:
100+ return "name is reserved"
101+ }
102+ return ""
103+}
@@ -0,0 +1,103 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "strings"
8+)
9+
10+// MaxRepoNameLen is the longest repository name, in characters.
11+const MaxRepoNameLen = 100
12+
13+// gitSuffix is the suffix of a Git URL and of a repository directory.
14+const gitSuffix = ".git"
15+
16+// reservedNames cannot name a repository: they are routes of the server.
17+var reservedNames = map[string]bool{
18+ "-": true,
19+ "robots.txt": true,
20+ "favicon.ico": true,
21+}
22+
23+// RepoName is a validated repository name. Only ParseRepoName and
24+// ParseRepoPath build a non-zero value; the zero value is invalid.
25+type RepoName struct{ s string }
26+
27+// String returns the name.
28+func (n RepoName) String() string { return n.s }
29+
30+// IsZero reports whether n is the invalid zero value.
31+func (n RepoName) IsZero() bool { return n.s == "" }
32+
33+// ParseRepoName validates s as a repository name. It does not strip any
34+// ".git" suffix: a name ending in ".git" is invalid.
35+//
36+// The error matches ErrInvalidName and is an *InvalidNameError.
37+func ParseRepoName(s string) (RepoName, error) {
38+ reason := nameProblem(s)
39+ if reason == "" {
40+ return RepoName{s: s}, nil
41+ }
42+ e := &InvalidNameError{Name: s, Reason: reason}
43+ if lower := strings.ToLower(s); lower != s && nameProblem(lower) == "" {
44+ e.Suggestion = lower
45+ }
46+ return RepoName{}, e
47+}
48+
49+// ParseRepoPath parses the repository segment of a URL path. It strips one
50+// trailing ".git" before validating, so "tools.git" names "tools".
51+func ParseRepoPath(segment string) (RepoName, error) {
52+ return ParseRepoName(strings.TrimSuffix(segment, gitSuffix))
53+}
54+
55+// InvalidNameError describes why a repository name is invalid. It matches
56+// ErrInvalidName.
57+type InvalidNameError struct {
58+ Name string
59+ Reason string
60+ Suggestion string // a valid alternative, empty when there is none
61+}
62+
63+// Error implements error.
64+func (e *InvalidNameError) Error() string {
65+ msg := fmt.Sprintf("invalid repository name %q: %s", e.Name, e.Reason)
66+ if e.Suggestion != "" {
67+ msg += fmt.Sprintf(" (try %q)", e.Suggestion)
68+ }
69+ return msg
70+}
71+
72+// Unwrap makes errors.Is(err, ErrInvalidName) true.
73+func (e *InvalidNameError) Unwrap() error { return ErrInvalidName }
74+
75+// nameProblem returns why s is not a valid name, or "" when it is valid.
76+func nameProblem(s string) string {
77+ if s == "" {
78+ return "name is empty"
79+ }
80+ if len(s) > MaxRepoNameLen {
81+ return fmt.Sprintf("name is longer than %d characters", MaxRepoNameLen)
82+ }
83+ for _, r := range s {
84+ switch {
85+ case r >= 'A' && r <= 'Z':
86+ return "uppercase letters are not allowed"
87+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
88+ default:
89+ return fmt.Sprintf("character %q is not allowed (use a-z, 0-9, '.', '_', '-')", r)
90+ }
91+ }
92+ switch {
93+ case strings.HasPrefix(s, ".") || strings.HasSuffix(s, "."):
94+ return "name must not start or end with '.'"
95+ case strings.Contains(s, ".."):
96+ return "name must not contain '..'"
97+ case strings.HasSuffix(s, gitSuffix):
98+ return "name must not end in '.git'"
99+ case reservedNames[s]:
100+ return "name is reserved"
101+ }
102+ return ""
103+}
A internal/domain/name_test.go +117 -0
@@ -0,0 +1,117 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "errors"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+func TestParseRepoName(t *testing.T) {
17+ tests := []struct {
18+ in string
19+ valid bool
20+ suggestion string
21+ }{
22+ {in: "ermite", valid: true},
23+ {in: "a", valid: true},
24+ {in: "my-repo_1.0", valid: true},
25+ {in: "x.y", valid: true},
26+ {in: "0", valid: true},
27+ {in: strings.Repeat("a", 100), valid: true},
28+ {in: strings.Repeat("a", 101)},
29+ {in: ""},
30+ {in: "Ermite", suggestion: "ermite"},
31+ {in: "My-Repo", suggestion: "my-repo"},
32+ {in: "UPPER.git"}, // lowercase would still end in .git: no suggestion
33+ {in: ".hidden"},
34+ {in: "trailing."},
35+ {in: "a..b"},
36+ {in: "tools.git"},
37+ {in: "-"},
38+ {in: "robots.txt"},
39+ {in: "favicon.ico"},
40+ {in: "-dash-ok", valid: true},
41+ {in: "has space"},
42+ {in: "a/b"},
43+ {in: "caf\u00e9"},
44+ {in: "a\x00b"},
45+ }
46+ for _, tt := range tests {
47+ t.Run(tt.in, func(t *testing.T) {
48+ n, err := domain.ParseRepoName(tt.in)
49+ if tt.valid {
50+ require.NoError(t, err)
51+ assert.Equal(t, tt.in, n.String())
52+ assert.False(t, n.IsZero())
53+ return
54+ }
55+ require.Error(t, err)
56+ assert.ErrorIs(t, err, domain.ErrInvalidName)
57+ assert.True(t, n.IsZero())
58+ var ine *domain.InvalidNameError
59+ require.True(t, errors.As(err, &ine))
60+ assert.Equal(t, tt.suggestion, ine.Suggestion)
61+ if tt.suggestion != "" {
62+ assert.Contains(t, err.Error(), tt.suggestion)
63+ }
64+ })
65+ }
66+}
67+
68+func TestParseRepoPath(t *testing.T) {
69+ tests := []struct {
70+ in string
71+ want string
72+ valid bool
73+ }{
74+ {"tools", "tools", true},
75+ {"tools.git", "tools", true},
76+ {"tools.git.git", "", false}, // only one suffix is stripped
77+ {".git", "", false},
78+ {"Tools.git", "", false},
79+ {"", "", false},
80+ }
81+ for _, tt := range tests {
82+ t.Run(tt.in, func(t *testing.T) {
83+ n, err := domain.ParseRepoPath(tt.in)
84+ if !tt.valid {
85+ assert.ErrorIs(t, err, domain.ErrInvalidName)
86+ return
87+ }
88+ require.NoError(t, err)
89+ assert.Equal(t, tt.want, n.String())
90+ })
91+ }
92+}
93+
94+func TestParseRepoNameDoesNotStripGit(t *testing.T) {
95+ _, err := domain.ParseRepoName("tools.git")
96+ assert.ErrorIs(t, err, domain.ErrInvalidName)
97+}
98+
99+func TestParseVisibility(t *testing.T) {
100+ for in, want := range map[string]domain.Visibility{"public": domain.VisibilityPublic, "private": domain.VisibilityPrivate} {
101+ got, err := domain.ParseVisibility(in)
102+ require.NoError(t, err)
103+ assert.Equal(t, want, got)
104+ }
105+ for _, in := range []string{"", "Public", "secret"} {
106+ _, err := domain.ParseVisibility(in)
107+ assert.ErrorIs(t, err, domain.ErrInvalidVisibility, in)
108+ }
109+}
110+
111+func TestPrincipal(t *testing.T) {
112+ var zero domain.Principal
113+ assert.True(t, zero.IsAnonymous())
114+ assert.False(t, zero.IsRoot())
115+ assert.True(t, domain.Root().IsRoot())
116+ assert.Equal(t, domain.PrincipalAnonymous, domain.Anonymous().Kind())
117+}
@@ -0,0 +1,117 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "errors"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+func TestParseRepoName(t *testing.T) {
17+ tests := []struct {
18+ in string
19+ valid bool
20+ suggestion string
21+ }{
22+ {in: "ermite", valid: true},
23+ {in: "a", valid: true},
24+ {in: "my-repo_1.0", valid: true},
25+ {in: "x.y", valid: true},
26+ {in: "0", valid: true},
27+ {in: strings.Repeat("a", 100), valid: true},
28+ {in: strings.Repeat("a", 101)},
29+ {in: ""},
30+ {in: "Ermite", suggestion: "ermite"},
31+ {in: "My-Repo", suggestion: "my-repo"},
32+ {in: "UPPER.git"}, // lowercase would still end in .git: no suggestion
33+ {in: ".hidden"},
34+ {in: "trailing."},
35+ {in: "a..b"},
36+ {in: "tools.git"},
37+ {in: "-"},
38+ {in: "robots.txt"},
39+ {in: "favicon.ico"},
40+ {in: "-dash-ok", valid: true},
41+ {in: "has space"},
42+ {in: "a/b"},
43+ {in: "caf\u00e9"},
44+ {in: "a\x00b"},
45+ }
46+ for _, tt := range tests {
47+ t.Run(tt.in, func(t *testing.T) {
48+ n, err := domain.ParseRepoName(tt.in)
49+ if tt.valid {
50+ require.NoError(t, err)
51+ assert.Equal(t, tt.in, n.String())
52+ assert.False(t, n.IsZero())
53+ return
54+ }
55+ require.Error(t, err)
56+ assert.ErrorIs(t, err, domain.ErrInvalidName)
57+ assert.True(t, n.IsZero())
58+ var ine *domain.InvalidNameError
59+ require.True(t, errors.As(err, &ine))
60+ assert.Equal(t, tt.suggestion, ine.Suggestion)
61+ if tt.suggestion != "" {
62+ assert.Contains(t, err.Error(), tt.suggestion)
63+ }
64+ })
65+ }
66+}
67+
68+func TestParseRepoPath(t *testing.T) {
69+ tests := []struct {
70+ in string
71+ want string
72+ valid bool
73+ }{
74+ {"tools", "tools", true},
75+ {"tools.git", "tools", true},
76+ {"tools.git.git", "", false}, // only one suffix is stripped
77+ {".git", "", false},
78+ {"Tools.git", "", false},
79+ {"", "", false},
80+ }
81+ for _, tt := range tests {
82+ t.Run(tt.in, func(t *testing.T) {
83+ n, err := domain.ParseRepoPath(tt.in)
84+ if !tt.valid {
85+ assert.ErrorIs(t, err, domain.ErrInvalidName)
86+ return
87+ }
88+ require.NoError(t, err)
89+ assert.Equal(t, tt.want, n.String())
90+ })
91+ }
92+}
93+
94+func TestParseRepoNameDoesNotStripGit(t *testing.T) {
95+ _, err := domain.ParseRepoName("tools.git")
96+ assert.ErrorIs(t, err, domain.ErrInvalidName)
97+}
98+
99+func TestParseVisibility(t *testing.T) {
100+ for in, want := range map[string]domain.Visibility{"public": domain.VisibilityPublic, "private": domain.VisibilityPrivate} {
101+ got, err := domain.ParseVisibility(in)
102+ require.NoError(t, err)
103+ assert.Equal(t, want, got)
104+ }
105+ for _, in := range []string{"", "Public", "secret"} {
106+ _, err := domain.ParseVisibility(in)
107+ assert.ErrorIs(t, err, domain.ErrInvalidVisibility, in)
108+ }
109+}
110+
111+func TestPrincipal(t *testing.T) {
112+ var zero domain.Principal
113+ assert.True(t, zero.IsAnonymous())
114+ assert.False(t, zero.IsRoot())
115+ assert.True(t, domain.Root().IsRoot())
116+ assert.Equal(t, domain.PrincipalAnonymous, domain.Anonymous().Kind())
117+}
A internal/domain/principal.go +33 -0
@@ -0,0 +1,33 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+// PrincipalKind tells who a caller is.
6+type PrincipalKind uint8
7+
8+// Principal kinds. The zero value is anonymous, the least privileged.
9+const (
10+ // PrincipalAnonymous is a caller without credentials.
11+ PrincipalAnonymous PrincipalKind = iota
12+ // PrincipalRoot is the holder of the admin token.
13+ PrincipalRoot
14+)
15+
16+// Principal is the authenticated identity of a caller. Stored tokens add
17+// scoped principals later.
18+type Principal struct{ kind PrincipalKind }
19+
20+// Anonymous returns the principal of a caller without credentials.
21+func Anonymous() Principal { return Principal{kind: PrincipalAnonymous} }
22+
23+// Root returns the principal of the admin token holder.
24+func Root() Principal { return Principal{kind: PrincipalRoot} }
25+
26+// Kind returns the kind of principal.
27+func (p Principal) Kind() PrincipalKind { return p.kind }
28+
29+// IsRoot reports whether p may do everything.
30+func (p Principal) IsRoot() bool { return p.kind == PrincipalRoot }
31+
32+// IsAnonymous reports whether p has no credentials.
33+func (p Principal) IsAnonymous() bool { return p.kind == PrincipalAnonymous }
@@ -0,0 +1,33 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+// PrincipalKind tells who a caller is.
6+type PrincipalKind uint8
7+
8+// Principal kinds. The zero value is anonymous, the least privileged.
9+const (
10+ // PrincipalAnonymous is a caller without credentials.
11+ PrincipalAnonymous PrincipalKind = iota
12+ // PrincipalRoot is the holder of the admin token.
13+ PrincipalRoot
14+)
15+
16+// Principal is the authenticated identity of a caller. Stored tokens add
17+// scoped principals later.
18+type Principal struct{ kind PrincipalKind }
19+
20+// Anonymous returns the principal of a caller without credentials.
21+func Anonymous() Principal { return Principal{kind: PrincipalAnonymous} }
22+
23+// Root returns the principal of the admin token holder.
24+func Root() Principal { return Principal{kind: PrincipalRoot} }
25+
26+// Kind returns the kind of principal.
27+func (p Principal) Kind() PrincipalKind { return p.kind }
28+
29+// IsRoot reports whether p may do everything.
30+func (p Principal) IsRoot() bool { return p.kind == PrincipalRoot }
31+
32+// IsAnonymous reports whether p has no credentials.
33+func (p Principal) IsAnonymous() bool { return p.kind == PrincipalAnonymous }
A internal/domain/repo.go +41 -0
@@ -0,0 +1,41 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "time"
8+)
9+
10+// Visibility is the access level of a repository.
11+type Visibility string
12+
13+// Visibility values.
14+const (
15+ // VisibilityPrivate hides the repository from anonymous callers.
16+ VisibilityPrivate Visibility = "private"
17+ // VisibilityPublic lets anonymous callers read the repository.
18+ VisibilityPublic Visibility = "public"
19+)
20+
21+// ParseVisibility parses "public" or "private".
22+func ParseVisibility(s string) (Visibility, error) {
23+ switch v := Visibility(s); v {
24+ case VisibilityPublic, VisibilityPrivate:
25+ return v, nil
26+ }
27+ return "", fmt.Errorf("%w: %q (want public or private)", ErrInvalidVisibility, s)
28+}
29+
30+// String returns the visibility as text.
31+func (v Visibility) String() string { return string(v) }
32+
33+// Repo is the metadata ermite owns about a repository. Git-owned state (refs,
34+// HEAD) stays in Git.
35+type Repo struct {
36+ Name RepoName
37+ Description string
38+ Visibility Visibility
39+ CreatedAt time.Time
40+ PushedAt time.Time // zero until the first push
41+}
@@ -0,0 +1,41 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "time"
8+)
9+
10+// Visibility is the access level of a repository.
11+type Visibility string
12+
13+// Visibility values.
14+const (
15+ // VisibilityPrivate hides the repository from anonymous callers.
16+ VisibilityPrivate Visibility = "private"
17+ // VisibilityPublic lets anonymous callers read the repository.
18+ VisibilityPublic Visibility = "public"
19+)
20+
21+// ParseVisibility parses "public" or "private".
22+func ParseVisibility(s string) (Visibility, error) {
23+ switch v := Visibility(s); v {
24+ case VisibilityPublic, VisibilityPrivate:
25+ return v, nil
26+ }
27+ return "", fmt.Errorf("%w: %q (want public or private)", ErrInvalidVisibility, s)
28+}
29+
30+// String returns the visibility as text.
31+func (v Visibility) String() string { return string(v) }
32+
33+// Repo is the metadata ermite owns about a repository. Git-owned state (refs,
34+// HEAD) stays in Git.
35+type Repo struct {
36+ Name RepoName
37+ Description string
38+ Visibility Visibility
39+ CreatedAt time.Time
40+ PushedAt time.Time // zero until the first push
41+}

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 42

↑↓ moveenter openesc close