detached df1db36

feat: signature badges on merge request commits (#73)

commit
df1db3633d13a90a4e152cfc1cd566ae4e863c9a
author
brice · · 2026-10-18 18:36 UTC
committer
bnema · · 2026-10-18 18:36 UTC
parent
94ae608
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.26.0
10 files · +275 -167
M docs/commit-signatures.md +1 -1
@@ -1,6 +1,6 @@
11 # Commit signatures
22
3-ermite checks commit signatures on the commit list and the commit page and shows a badge with the protocol and the result. The API returns the same result in `Commit.verification` from `ListCommits` and `GetCommit`.
3+ermite checks commit signatures on the commit list, the commit page and the commits of a merge request, and shows a badge with the protocol and the result. The API returns the same result in `Commit.verification` from `ListCommits`, `GetCommit` and `GetMergeRequest`.
44
55 ## Formats
66
@@ -1,6 +1,6 @@
1 # Commit signatures1 # Commit signatures
2 2
3-ermite checks commit signatures on the commit list and the commit page and shows a badge with the protocol and the result. The API returns the same result in `Commit.verification` from `ListCommits` and `GetCommit`.3+ermite checks commit signatures on the commit list, the commit page and the commits of a merge request, and shows a badge with the protocol and the result. The API returns the same result in `Commit.verification` from `ListCommits`, `GetCommit` and `GetMergeRequest`.
4 4
5 ## Formats5 ## Formats
6 6
M internal/adapters/web/mr.go +3 -0
@@ -148,6 +148,8 @@ type MRCommit struct {
148148 Short, Href, Subject, Author, When, WhenAt string
149149 // New is true for a commit pushed after the head the reviewer last read.
150150 New bool
151+ // Sig is the signature badge, nil for an unsigned or unchecked commit.
152+ Sig *SignatureBadge
151153 }
152154
153155 // MergeBox is what the owner does with an open request.
@@ -534,6 +536,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
534536 row := commitRow(d.name(), c, now)
535537 p.Commits = append(p.Commits, MRCommit{
536538 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
539+ Sig: row.Sig,
537540 })
538541 }
539542 lastRead := res.LastReadID.String()
@@ -148,6 +148,8 @@ type MRCommit struct {
148 Short, Href, Subject, Author, When, WhenAt string148 Short, Href, Subject, Author, When, WhenAt string
149 // New is true for a commit pushed after the head the reviewer last read.149 // New is true for a commit pushed after the head the reviewer last read.
150 New bool150 New bool
151+ // Sig is the signature badge, nil for an unsigned or unchecked commit.
152+ Sig *SignatureBadge
151 }153 }
152 154
153 // MergeBox is what the owner does with an open request.155 // MergeBox is what the owner does with an open request.
@@ -534,6 +536,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
534 row := commitRow(d.name(), c, now)536 row := commitRow(d.name(), c, now)
535 p.Commits = append(p.Commits, MRCommit{537 p.Commits = append(p.Commits, MRCommit{
536 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,538 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
539+ Sig: row.Sig,
537 })540 })
538 }541 }
539 lastRead := res.LastReadID.String()542 lastRead := res.LastReadID.String()
M internal/adapters/web/mr.templ +3 -0
@@ -318,6 +318,9 @@ templ mrRail(p MRPage) {
318318 <li data-new?={ c.New }>
319319 <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a>
320320 <span class="s">{ c.Subject }</span>
321+ if c.Sig != nil {
322+ @signaturePill(c.Sig)
323+ }
321324 <span class="mute">
322325 { c.Author }
323326 if c.WhenAt != "" {
@@ -318,6 +318,9 @@ templ mrRail(p MRPage) {
318 <li data-new?={ c.New }>318 <li data-new?={ c.New }>
319 <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a>319 <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a>
320 <span class="s">{ c.Subject }</span>320 <span class="s">{ c.Subject }</span>
321+ if c.Sig != nil {
322+ @signaturePill(c.Sig)
323+ }
321 <span class="mute">324 <span class="mute">
322 { c.Author }325 { c.Author }
323 if c.WhenAt != "" {326 if c.WhenAt != "" {
M internal/adapters/web/mr_templ.go +174 -164 generated file: collapsed
M internal/adapters/web/signature_test.go +12 -0
@@ -3,6 +3,7 @@
33 package web
44
55 import (
6+ "strings"
67 "testing"
78 "time"
89
@@ -139,3 +140,14 @@ func TestCommitPageShowsTheSignature(t *testing.T) {
139140 got = page(domain.CommitVerification{})
140141 assert.NotContains(t, got, "signature")
141142 }
143+
144+func TestMergeRequestCommitsShowTheSignatureBadge(t *testing.T) {
145+ t.Parallel()
146+ signed := signatureBadge(verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""), false)
147+ got := renderToString(t, mrRail(MRPage{Commits: []MRCommit{
148+ {Short: "aaaaaaa", Href: "/r/commit/a", Subject: "signed", Sig: signed},
149+ {Short: "bbbbbbb", Href: "/r/commit/b", Subject: "unsigned"},
150+ }}))
151+ assert.Equal(t, 1, strings.Count(got, `class="pill sig sig-ok"`), "only the signed commit has a pill")
152+ assert.Contains(t, got, `<span class="sig-proto">GPG</span> Verified`)
153+}
@@ -3,6 +3,7 @@
3 package web3 package web
4 4
5 import (5 import (
6+ "strings"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
@@ -139,3 +140,14 @@ func TestCommitPageShowsTheSignature(t *testing.T) {
139 got = page(domain.CommitVerification{})140 got = page(domain.CommitVerification{})
140 assert.NotContains(t, got, "signature")141 assert.NotContains(t, got, "signature")
141 }142 }
143+
144+func TestMergeRequestCommitsShowTheSignatureBadge(t *testing.T) {
145+ t.Parallel()
146+ signed := signatureBadge(verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""), false)
147+ got := renderToString(t, mrRail(MRPage{Commits: []MRCommit{
148+ {Short: "aaaaaaa", Href: "/r/commit/a", Subject: "signed", Sig: signed},
149+ {Short: "bbbbbbb", Href: "/r/commit/b", Subject: "unsigned"},
150+ }}))
151+ assert.Equal(t, 1, strings.Count(got, `class="pill sig sig-ok"`), "only the signed commit has a pill")
152+ assert.Contains(t, got, `<span class="sig-proto">GPG</span> Verified`)
153+}
M internal/app/forge.go +1 -0
@@ -314,6 +314,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
314314 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
315315 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),
316316 })
317+ f.MergeRequests.WithVerifier(f.Signatures)
317318 f.CI.SetPullGate(f.MergeRequests)
318319 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
319320
@@ -314,6 +314,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
314 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,314 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
315 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),315 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),
316 })316 })
317+ f.MergeRequests.WithVerifier(f.Signatures)
317 f.CI.SetPullGate(f.MergeRequests)318 f.CI.SetPullGate(f.MergeRequests)
318 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")319 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
319 320
M internal/app/signatures_e2e_test.go +18 -0
@@ -135,6 +135,24 @@ func TestCommitSignaturesEndToEnd(t *testing.T) {
135135 assert.Contains(t, body, "GPG")
136136 assert.Contains(t, body, "sig-ok")
137137
138+ // A merge request lists its commits with their signature state, over the
139+ // API and on its page.
140+ h.mustGit(wt, "checkout", "-q", "-b", "feat")
141+ h.writeFile(wt, "d.txt", []byte("d\n"))
142+ h.mustGit(wt, "-c", "gpg.format=ssh", "-c", "user.signingkey="+sshKey+".pub", "commit", "-q", "--amend", "-S", "--no-edit")
143+ feat := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
144+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/sig.git"), "feat")
145+ _, err = root.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "sig", Title: "d", SourceBranch: "feat"}))
146+ require.NoError(t, err)
147+ mr, err := root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "sig", Number: 1}))
148+ require.NoError(t, err)
149+ require.Len(t, mr.Msg.GetCommits(), 1)
150+ assert.Equal(t, feat, mr.Msg.GetCommits()[0].GetId())
151+ assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED, mr.Msg.GetCommits()[0].GetVerification().GetStatus())
152+ code, _, body = h.get("/sig/merge-requests/1")
153+ require.Equal(t, http.StatusOK, code)
154+ assert.Contains(t, body, `<span class="pill sig sig-ok"`)
155+
138156 // Removing the key turns the commit unknown again.
139157 _, err = trust.RemoveTrustedKey(ctx, connect.NewRequest(&pb.RemoveTrustedKeyRequest{Id: list.Msg.GetKeys()[1].GetId()}))
140158 require.NoError(t, err)
@@ -135,6 +135,24 @@ func TestCommitSignaturesEndToEnd(t *testing.T) {
135 assert.Contains(t, body, "GPG")135 assert.Contains(t, body, "GPG")
136 assert.Contains(t, body, "sig-ok")136 assert.Contains(t, body, "sig-ok")
137 137
138+ // A merge request lists its commits with their signature state, over the
139+ // API and on its page.
140+ h.mustGit(wt, "checkout", "-q", "-b", "feat")
141+ h.writeFile(wt, "d.txt", []byte("d\n"))
142+ h.mustGit(wt, "-c", "gpg.format=ssh", "-c", "user.signingkey="+sshKey+".pub", "commit", "-q", "--amend", "-S", "--no-edit")
143+ feat := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
144+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/sig.git"), "feat")
145+ _, err = root.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "sig", Title: "d", SourceBranch: "feat"}))
146+ require.NoError(t, err)
147+ mr, err := root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "sig", Number: 1}))
148+ require.NoError(t, err)
149+ require.Len(t, mr.Msg.GetCommits(), 1)
150+ assert.Equal(t, feat, mr.Msg.GetCommits()[0].GetId())
151+ assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED, mr.Msg.GetCommits()[0].GetVerification().GetStatus())
152+ code, _, body = h.get("/sig/merge-requests/1")
153+ require.Equal(t, http.StatusOK, code)
154+ assert.Contains(t, body, `<span class="pill sig sig-ok"`)
155+
138 // Removing the key turns the commit unknown again.156 // Removing the key turns the commit unknown again.
139 _, err = trust.RemoveTrustedKey(ctx, connect.NewRequest(&pb.RemoveTrustedKeyRequest{Id: list.Msg.GetKeys()[1].GetId()}))157 _, err = trust.RemoveTrustedKey(ctx, connect.NewRequest(&pb.RemoveTrustedKeyRequest{Id: list.Msg.GetKeys()[1].GetId()}))
140 require.NoError(t, err)158 require.NoError(t, err)
M internal/usecase/browse.go +8 -2
@@ -67,10 +67,16 @@ func (b *Browse) WithVerifier(v ports.CommitVerifier) *Browse {
6767 // verify fills the Verification of the commits, in place, with one call. The
6868 // slice must not be shared with a cache.
6969 func (b *Browse) verify(ctx context.Context, repo domain.RepoName, commits []domain.Commit) {
70- if b.verifier == nil || len(commits) == 0 {
70+ verifyCommits(ctx, b.verifier, repo, commits)
71+}
72+
73+// verifyCommits fills the Verification of the commits in place, with one call
74+// to v; a nil v leaves them unchecked.
75+func verifyCommits(ctx context.Context, v ports.CommitVerifier, repo domain.RepoName, commits []domain.Commit) {
76+ if v == nil || len(commits) == 0 {
7177 return
7278 }
73- res := b.verifier.VerifyCommits(ctx, repo, commits)
79+ res := v.VerifyCommits(ctx, repo, commits)
7480 for i := range commits {
7581 if v, ok := res[commits[i].ID]; ok {
7682 commits[i].Verification = v
@@ -67,10 +67,16 @@ func (b *Browse) WithVerifier(v ports.CommitVerifier) *Browse {
67 // verify fills the Verification of the commits, in place, with one call. The67 // verify fills the Verification of the commits, in place, with one call. The
68 // slice must not be shared with a cache.68 // slice must not be shared with a cache.
69 func (b *Browse) verify(ctx context.Context, repo domain.RepoName, commits []domain.Commit) {69 func (b *Browse) verify(ctx context.Context, repo domain.RepoName, commits []domain.Commit) {
70- if b.verifier == nil || len(commits) == 0 {70+ verifyCommits(ctx, b.verifier, repo, commits)
71+}
72+
73+// verifyCommits fills the Verification of the commits in place, with one call
74+// to v; a nil v leaves them unchecked.
75+func verifyCommits(ctx context.Context, v ports.CommitVerifier, repo domain.RepoName, commits []domain.Commit) {
76+ if v == nil || len(commits) == 0 {
71 return77 return
72 }78 }
73- res := b.verifier.VerifyCommits(ctx, repo, commits)79+ res := v.VerifyCommits(ctx, repo, commits)
74 for i := range commits {80 for i := range commits {
75 if v, ok := res[commits[i].ID]; ok {81 if v, ok := res[commits[i].ID]; ok {
76 commits[i].Verification = v82 commits[i].Verification = v
M internal/usecase/mergerequests.go +9 -0
@@ -55,6 +55,7 @@ type MergeRequests struct {
5555 checks ports.CIChecksReader // nil: no CI, so no check blocks a merge
5656 pulls ports.PullRequestTrigger // nil: no pull_request workflows
5757 tips ports.CIRefTipStore // nil: who merges is not recorded
58+ verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
5859
5960 mu sync.Mutex
6061 locks map[domain.RepoName]chan struct{}
@@ -105,6 +106,13 @@ func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
105106 }
106107 }
107108
109+// WithVerifier makes GetMergeRequest fill the signature state of the commits
110+// it lists (ADR 0030).
111+func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests {
112+ m.verifier = v
113+ return m
114+}
115+
108116 // lockRepo takes the merge lock of a repository, waiting until ctx ends. The
109117 // merges of one repository, and the creation of its merge requests, run one at
110118 // a time. The returned function releases the lock.
@@ -490,6 +498,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
490498 if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil {
491499 return ports.MergeRequestDetail{}, err
492500 }
501+ verifyCommits(ctx, m.verifier, name, d.Commits)
493502 }
494503 if opts.SinceLastRead && !d.LastReadID.IsZero() {
495504 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {
@@ -55,6 +55,7 @@ type MergeRequests struct {
55 checks ports.CIChecksReader // nil: no CI, so no check blocks a merge55 checks ports.CIChecksReader // nil: no CI, so no check blocks a merge
56 pulls ports.PullRequestTrigger // nil: no pull_request workflows56 pulls ports.PullRequestTrigger // nil: no pull_request workflows
57 tips ports.CIRefTipStore // nil: who merges is not recorded57 tips ports.CIRefTipStore // nil: who merges is not recorded
58+ verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
58 59
59 mu sync.Mutex60 mu sync.Mutex
60 locks map[domain.RepoName]chan struct{}61 locks map[domain.RepoName]chan struct{}
@@ -105,6 +106,13 @@ func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
105 }106 }
106 }107 }
107 108
109+// WithVerifier makes GetMergeRequest fill the signature state of the commits
110+// it lists (ADR 0030).
111+func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests {
112+ m.verifier = v
113+ return m
114+}
115+
108 // lockRepo takes the merge lock of a repository, waiting until ctx ends. The116 // lockRepo takes the merge lock of a repository, waiting until ctx ends. The
109 // merges of one repository, and the creation of its merge requests, run one at117 // merges of one repository, and the creation of its merge requests, run one at
110 // a time. The returned function releases the lock.118 // a time. The returned function releases the lock.
@@ -490,6 +498,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
490 if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil {498 if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil {
491 return ports.MergeRequestDetail{}, err499 return ports.MergeRequestDetail{}, err
492 }500 }
501+ verifyCommits(ctx, m.verifier, name, d.Commits)
493 }502 }
494 if opts.SinceLastRead && !d.LastReadID.IsZero() {503 if opts.SinceLastRead && !d.LastReadID.IsZero() {
495 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {504 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {
A internal/usecase/mergerequests_signatures_test.go +46 -0
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "bnema.dev/ermite/internal/ports/mocks"
15+)
16+
17+// ADR 0030: the commits of a merge request carry their signature state when a
18+// verifier is set, in one call; without one they stay unchecked.
19+func TestGetMergeRequestVerifiesItsCommits(t *testing.T) {
20+ t.Parallel()
21+ r := newMRRig(t)
22+ r.seed()
23+ head := r.feature("feat")
24+ mr := r.open("feat")
25+
26+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
27+ require.NoError(t, err)
28+ require.NotEmpty(t, d.Commits)
29+ assert.Equal(t, domain.StatusUnchecked, d.Commits[0].Verification.Status, "no verifier: unchecked")
30+
31+ v := mocks.NewCommitVerifier(t)
32+ want := domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusVerified, KeyTitle: "laptop"}
33+ v.EXPECT().VerifyCommits(mock.Anything, r.repo, mock.MatchedBy(func(cs []domain.Commit) bool {
34+ return len(cs) == len(d.Commits) && cs[0].ID == head
35+ })).Return(map[domain.ObjectID]domain.CommitVerification{head: want}).Once()
36+ r.mrs.WithVerifier(v)
37+
38+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
39+ require.NoError(t, err)
40+ assert.Equal(t, want, d.Commits[0].Verification)
41+
42+ // A diff-only read lists no commits and checks nothing.
43+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{DiffOnly: true})
44+ require.NoError(t, err)
45+ assert.Empty(t, d.Commits)
46+}
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "bnema.dev/ermite/internal/ports/mocks"
15+)
16+
17+// ADR 0030: the commits of a merge request carry their signature state when a
18+// verifier is set, in one call; without one they stay unchecked.
19+func TestGetMergeRequestVerifiesItsCommits(t *testing.T) {
20+ t.Parallel()
21+ r := newMRRig(t)
22+ r.seed()
23+ head := r.feature("feat")
24+ mr := r.open("feat")
25+
26+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
27+ require.NoError(t, err)
28+ require.NotEmpty(t, d.Commits)
29+ assert.Equal(t, domain.StatusUnchecked, d.Commits[0].Verification.Status, "no verifier: unchecked")
30+
31+ v := mocks.NewCommitVerifier(t)
32+ want := domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusVerified, KeyTitle: "laptop"}
33+ v.EXPECT().VerifyCommits(mock.Anything, r.repo, mock.MatchedBy(func(cs []domain.Commit) bool {
34+ return len(cs) == len(d.Commits) && cs[0].ID == head
35+ })).Return(map[domain.ObjectID]domain.CommitVerification{head: want}).Once()
36+ r.mrs.WithVerifier(v)
37+
38+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
39+ require.NoError(t, err)
40+ assert.Equal(t, want, d.Commits[0].Verification)
41+
42+ // A diff-only read lists no commits and checks nothing.
43+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{DiffOnly: true})
44+ require.NoError(t, err)
45+ assert.Empty(t, d.Commits)
46+}

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 41

↑↓ moveenter openesc close