detached 03ad744

feat(images): add use case service for image listing and runtime prune

commit
03ad7447eac568b5b552a52852f8a5a2600e40ae
author
brice · · 2026-02-08 06:19 UTC
committer
brice · · 2026-02-08 09:40 UTC
parent
3fa0d2d
signature
unsigned
2 files · +344 -0
A internal/usecase/images/service.go +131 -0
@@ -0,0 +1,131 @@
1+// Package images implements the image management use case.
2+package images
3+
4+import (
5+ "context"
6+ "strings"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
12+ "github.com/bnema/gordon/pkg/runtime"
13+)
14+
15+// Service implements image list and prune operations.
16+type Service struct {
17+ runtime runtime.Runtime
18+ manifestStorage out.ManifestStorage
19+ blobStorage out.BlobStorage
20+ log zerowrap.Logger
21+}
22+
23+// NewService creates a new images service.
24+func NewService(
25+ rt runtime.Runtime,
26+ manifestStorage out.ManifestStorage,
27+ blobStorage out.BlobStorage,
28+ log zerowrap.Logger,
29+) *Service {
30+ return &Service{
31+ runtime: rt,
32+ manifestStorage: manifestStorage,
33+ blobStorage: blobStorage,
34+ log: log,
35+ }
36+}
37+
38+// ListImages returns images known by the runtime.
39+func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
40+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
41+ zerowrap.FieldLayer: "usecase",
42+ zerowrap.FieldUseCase: "ListImages",
43+ })
44+ log := zerowrap.FromCtx(ctx)
45+
46+ details, err := s.runtime.ListImagesDetailed(ctx)
47+ if err != nil {
48+ return nil, log.WrapErr(err, "failed to list images")
49+ }
50+
51+ images := make([]domain.ImageInfo, 0, len(details))
52+ for _, detail := range details {
53+ if isDanglingImage(detail.RepoTags) {
54+ images = append(images, domain.ImageInfo{
55+ Repository: "",
56+ Tag: "",
57+ Size: detail.Size,
58+ Created: detail.Created,
59+ ID: detail.ID,
60+ Dangling: true,
61+ })
62+ continue
63+ }
64+
65+ for _, repoTag := range detail.RepoTags {
66+ if repoTag == "" || repoTag == "<none>:<none>" {
67+ continue
68+ }
69+ repository, tag := splitRepoTag(repoTag)
70+ images = append(images, domain.ImageInfo{
71+ Repository: repository,
72+ Tag: tag,
73+ Size: detail.Size,
74+ Created: detail.Created,
75+ ID: detail.ID,
76+ Dangling: false,
77+ })
78+ }
79+ }
80+
81+ return images, nil
82+}
83+
84+// PruneRuntime prunes dangling images from the runtime.
85+func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, error) {
86+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
87+ zerowrap.FieldLayer: "usecase",
88+ zerowrap.FieldUseCase: "PruneRuntime",
89+ })
90+ log := zerowrap.FromCtx(ctx)
91+
92+ pruneReport, err := s.runtime.PruneImages(ctx, true)
93+ if err != nil {
94+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to prune runtime images")
95+ }
96+
97+ return domain.ImagePruneReport{
98+ Runtime: domain.RuntimePruneResult{
99+ DeletedCount: len(pruneReport.DeletedIDs),
100+ SpaceReclaimed: pruneReport.SpaceReclaimed,
101+ },
102+ }, nil
103+}
104+
105+func isDanglingImage(repoTags []string) bool {
106+ if len(repoTags) == 0 {
107+ return true
108+ }
109+
110+ for _, tag := range repoTags {
111+ if tag == "<none>:<none>" || tag == "" {
112+ continue
113+ }
114+ return false
115+ }
116+
117+ return true
118+}
119+
120+func splitRepoTag(repoTag string) (string, string) {
121+ if repoTag == "" {
122+ return "", ""
123+ }
124+
125+ idx := strings.LastIndex(repoTag, ":")
126+ if idx <= 0 || idx >= len(repoTag)-1 {
127+ return repoTag, ""
128+ }
129+
130+ return repoTag[:idx], repoTag[idx+1:]
131+}
@@ -0,0 +1,131 @@
1+// Package images implements the image management use case.
2+package images
3+
4+import (
5+ "context"
6+ "strings"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
12+ "github.com/bnema/gordon/pkg/runtime"
13+)
14+
15+// Service implements image list and prune operations.
16+type Service struct {
17+ runtime runtime.Runtime
18+ manifestStorage out.ManifestStorage
19+ blobStorage out.BlobStorage
20+ log zerowrap.Logger
21+}
22+
23+// NewService creates a new images service.
24+func NewService(
25+ rt runtime.Runtime,
26+ manifestStorage out.ManifestStorage,
27+ blobStorage out.BlobStorage,
28+ log zerowrap.Logger,
29+) *Service {
30+ return &Service{
31+ runtime: rt,
32+ manifestStorage: manifestStorage,
33+ blobStorage: blobStorage,
34+ log: log,
35+ }
36+}
37+
38+// ListImages returns images known by the runtime.
39+func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
40+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
41+ zerowrap.FieldLayer: "usecase",
42+ zerowrap.FieldUseCase: "ListImages",
43+ })
44+ log := zerowrap.FromCtx(ctx)
45+
46+ details, err := s.runtime.ListImagesDetailed(ctx)
47+ if err != nil {
48+ return nil, log.WrapErr(err, "failed to list images")
49+ }
50+
51+ images := make([]domain.ImageInfo, 0, len(details))
52+ for _, detail := range details {
53+ if isDanglingImage(detail.RepoTags) {
54+ images = append(images, domain.ImageInfo{
55+ Repository: "",
56+ Tag: "",
57+ Size: detail.Size,
58+ Created: detail.Created,
59+ ID: detail.ID,
60+ Dangling: true,
61+ })
62+ continue
63+ }
64+
65+ for _, repoTag := range detail.RepoTags {
66+ if repoTag == "" || repoTag == "<none>:<none>" {
67+ continue
68+ }
69+ repository, tag := splitRepoTag(repoTag)
70+ images = append(images, domain.ImageInfo{
71+ Repository: repository,
72+ Tag: tag,
73+ Size: detail.Size,
74+ Created: detail.Created,
75+ ID: detail.ID,
76+ Dangling: false,
77+ })
78+ }
79+ }
80+
81+ return images, nil
82+}
83+
84+// PruneRuntime prunes dangling images from the runtime.
85+func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, error) {
86+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
87+ zerowrap.FieldLayer: "usecase",
88+ zerowrap.FieldUseCase: "PruneRuntime",
89+ })
90+ log := zerowrap.FromCtx(ctx)
91+
92+ pruneReport, err := s.runtime.PruneImages(ctx, true)
93+ if err != nil {
94+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to prune runtime images")
95+ }
96+
97+ return domain.ImagePruneReport{
98+ Runtime: domain.RuntimePruneResult{
99+ DeletedCount: len(pruneReport.DeletedIDs),
100+ SpaceReclaimed: pruneReport.SpaceReclaimed,
101+ },
102+ }, nil
103+}
104+
105+func isDanglingImage(repoTags []string) bool {
106+ if len(repoTags) == 0 {
107+ return true
108+ }
109+
110+ for _, tag := range repoTags {
111+ if tag == "<none>:<none>" || tag == "" {
112+ continue
113+ }
114+ return false
115+ }
116+
117+ return true
118+}
119+
120+func splitRepoTag(repoTag string) (string, string) {
121+ if repoTag == "" {
122+ return "", ""
123+ }
124+
125+ idx := strings.LastIndex(repoTag, ":")
126+ if idx <= 0 || idx >= len(repoTag)-1 {
127+ return repoTag, ""
128+ }
129+
130+ return repoTag[:idx], repoTag[idx+1:]
131+}
A internal/usecase/images/service_test.go +213 -0
@@ -0,0 +1,213 @@
1+package images
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/boundaries/out"
14+ "github.com/bnema/gordon/internal/domain"
15+ pkgruntime "github.com/bnema/gordon/pkg/runtime"
16+)
17+
18+func TestService_ListImages_ReturnsAllImages(t *testing.T) {
19+ manifestStorage := noopManifestStorage{}
20+ blobStorage := noopBlobStorage{}
21+
22+ createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
23+ rt := &fakeRuntime{
24+ listDetails: []pkgruntime.ImageDetail{
25+ {
26+ ID: "sha256:111",
27+ RepoTags: []string{"gordon/api:latest", "gordon/api:v1.2.3"},
28+ Size: 1234,
29+ Created: createdAt,
30+ },
31+ {
32+ ID: "sha256:222",
33+ RepoTags: []string{"<none>:<none>"},
34+ Size: 4321,
35+ Created: createdAt.Add(-time.Hour),
36+ },
37+ },
38+ }
39+
40+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
41+
42+ images, err := svc.ListImages(context.Background())
43+
44+ require.NoError(t, err)
45+ require.Len(t, images, 3)
46+
47+ assert.Equal(t, domain.ImageInfo{
48+ Repository: "gordon/api",
49+ Tag: "latest",
50+ Size: 1234,
51+ Created: createdAt,
52+ ID: "sha256:111",
53+ Dangling: false,
54+ }, images[0])
55+
56+ assert.Equal(t, domain.ImageInfo{
57+ Repository: "gordon/api",
58+ Tag: "v1.2.3",
59+ Size: 1234,
60+ Created: createdAt,
61+ ID: "sha256:111",
62+ Dangling: false,
63+ }, images[1])
64+
65+ assert.Equal(t, domain.ImageInfo{
66+ Repository: "",
67+ Tag: "",
68+ Size: 4321,
69+ Created: createdAt.Add(-time.Hour),
70+ ID: "sha256:222",
71+ Dangling: true,
72+ }, images[2])
73+}
74+
75+func TestService_ListImages_EmptyWhenNoImages(t *testing.T) {
76+ manifestStorage := noopManifestStorage{}
77+ blobStorage := noopBlobStorage{}
78+ rt := &fakeRuntime{listDetails: []pkgruntime.ImageDetail{}}
79+
80+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
81+
82+ images, err := svc.ListImages(context.Background())
83+
84+ require.NoError(t, err)
85+ assert.Empty(t, images)
86+}
87+
88+func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) {
89+ manifestStorage := noopManifestStorage{}
90+ blobStorage := noopBlobStorage{}
91+ createdAt := time.Date(2026, 2, 8, 13, 0, 0, 0, time.UTC)
92+
93+ rt := &fakeRuntime{
94+ listDetails: []pkgruntime.ImageDetail{{
95+ ID: "sha256:mixed",
96+ RepoTags: []string{"<none>:<none>", "gordon/web:latest"},
97+ Size: 100,
98+ Created: createdAt,
99+ }},
100+ }
101+
102+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
103+
104+ images, err := svc.ListImages(context.Background())
105+
106+ require.NoError(t, err)
107+ require.Len(t, images, 1)
108+ assert.Equal(t, "gordon/web", images[0].Repository)
109+ assert.Equal(t, "latest", images[0].Tag)
110+ assert.False(t, images[0].Dangling)
111+}
112+
113+func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) {
114+ manifestStorage := noopManifestStorage{}
115+ blobStorage := noopBlobStorage{}
116+ rt := &fakeRuntime{listErr: errors.New("runtime list failed")}
117+
118+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
119+
120+ _, err := svc.ListImages(context.Background())
121+
122+ require.Error(t, err)
123+ assert.Contains(t, err.Error(), "failed to list images")
124+}
125+
126+func TestService_PruneRuntime_RemovesDanglingImages(t *testing.T) {
127+ manifestStorage := noopManifestStorage{}
128+ blobStorage := noopBlobStorage{}
129+ rt := &fakeRuntime{
130+ pruneReport: pkgruntime.PruneReport{
131+ DeletedIDs: []string{"sha256:a", "sha256:b"},
132+ SpaceReclaimed: 2048,
133+ },
134+ }
135+
136+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
137+
138+ report, err := svc.PruneRuntime(context.Background())
139+
140+ require.NoError(t, err)
141+ assert.Equal(t, 2, report.Runtime.DeletedCount)
142+ assert.Equal(t, int64(2048), report.Runtime.SpaceReclaimed)
143+ assert.True(t, rt.pruneCalled)
144+ assert.True(t, rt.pruneDanglingOnly)
145+}
146+
147+func TestService_PruneRuntime_NoDanglingImages(t *testing.T) {
148+ manifestStorage := noopManifestStorage{}
149+ blobStorage := noopBlobStorage{}
150+ rt := &fakeRuntime{
151+ pruneReport: pkgruntime.PruneReport{
152+ DeletedIDs: nil,
153+ SpaceReclaimed: 0,
154+ },
155+ }
156+
157+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
158+
159+ report, err := svc.PruneRuntime(context.Background())
160+
161+ require.NoError(t, err)
162+ assert.Equal(t, 0, report.Runtime.DeletedCount)
163+ assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed)
164+ assert.True(t, rt.pruneCalled)
165+ assert.True(t, rt.pruneDanglingOnly)
166+}
167+
168+func TestService_PruneRuntime_ReturnsErrorWhenRuntimeFails(t *testing.T) {
169+ manifestStorage := noopManifestStorage{}
170+ blobStorage := noopBlobStorage{}
171+ rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
172+
173+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
174+
175+ _, err := svc.PruneRuntime(context.Background())
176+
177+ require.Error(t, err)
178+ assert.Contains(t, err.Error(), "failed to prune runtime images")
179+ assert.True(t, rt.pruneCalled)
180+ assert.True(t, rt.pruneDanglingOnly)
181+}
182+
183+type fakeRuntime struct {
184+ pkgruntime.Runtime
185+
186+ listDetails []pkgruntime.ImageDetail
187+ listErr error
188+
189+ pruneReport pkgruntime.PruneReport
190+ pruneErr error
191+ pruneCalled bool
192+ pruneDanglingOnly bool
193+}
194+
195+type noopManifestStorage struct {
196+ out.ManifestStorage
197+}
198+
199+type noopBlobStorage struct {
200+ out.BlobStorage
201+}
202+
203+func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) {
204+ return f.listDetails, f.listErr
205+}
206+
207+func (f *fakeRuntime) PruneImages(_ context.Context, danglingOnly bool) (pkgruntime.PruneReport, error) {
208+ f.pruneCalled = true
209+ f.pruneDanglingOnly = danglingOnly
210+ return f.pruneReport, f.pruneErr
211+}
212+
213+var _ pkgruntime.Runtime = (*fakeRuntime)(nil)
@@ -0,0 +1,213 @@
1+package images
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/boundaries/out"
14+ "github.com/bnema/gordon/internal/domain"
15+ pkgruntime "github.com/bnema/gordon/pkg/runtime"
16+)
17+
18+func TestService_ListImages_ReturnsAllImages(t *testing.T) {
19+ manifestStorage := noopManifestStorage{}
20+ blobStorage := noopBlobStorage{}
21+
22+ createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
23+ rt := &fakeRuntime{
24+ listDetails: []pkgruntime.ImageDetail{
25+ {
26+ ID: "sha256:111",
27+ RepoTags: []string{"gordon/api:latest", "gordon/api:v1.2.3"},
28+ Size: 1234,
29+ Created: createdAt,
30+ },
31+ {
32+ ID: "sha256:222",
33+ RepoTags: []string{"<none>:<none>"},
34+ Size: 4321,
35+ Created: createdAt.Add(-time.Hour),
36+ },
37+ },
38+ }
39+
40+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
41+
42+ images, err := svc.ListImages(context.Background())
43+
44+ require.NoError(t, err)
45+ require.Len(t, images, 3)
46+
47+ assert.Equal(t, domain.ImageInfo{
48+ Repository: "gordon/api",
49+ Tag: "latest",
50+ Size: 1234,
51+ Created: createdAt,
52+ ID: "sha256:111",
53+ Dangling: false,
54+ }, images[0])
55+
56+ assert.Equal(t, domain.ImageInfo{
57+ Repository: "gordon/api",
58+ Tag: "v1.2.3",
59+ Size: 1234,
60+ Created: createdAt,
61+ ID: "sha256:111",
62+ Dangling: false,
63+ }, images[1])
64+
65+ assert.Equal(t, domain.ImageInfo{
66+ Repository: "",
67+ Tag: "",
68+ Size: 4321,
69+ Created: createdAt.Add(-time.Hour),
70+ ID: "sha256:222",
71+ Dangling: true,
72+ }, images[2])
73+}
74+
75+func TestService_ListImages_EmptyWhenNoImages(t *testing.T) {
76+ manifestStorage := noopManifestStorage{}
77+ blobStorage := noopBlobStorage{}
78+ rt := &fakeRuntime{listDetails: []pkgruntime.ImageDetail{}}
79+
80+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
81+
82+ images, err := svc.ListImages(context.Background())
83+
84+ require.NoError(t, err)
85+ assert.Empty(t, images)
86+}
87+
88+func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) {
89+ manifestStorage := noopManifestStorage{}
90+ blobStorage := noopBlobStorage{}
91+ createdAt := time.Date(2026, 2, 8, 13, 0, 0, 0, time.UTC)
92+
93+ rt := &fakeRuntime{
94+ listDetails: []pkgruntime.ImageDetail{{
95+ ID: "sha256:mixed",
96+ RepoTags: []string{"<none>:<none>", "gordon/web:latest"},
97+ Size: 100,
98+ Created: createdAt,
99+ }},
100+ }
101+
102+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
103+
104+ images, err := svc.ListImages(context.Background())
105+
106+ require.NoError(t, err)
107+ require.Len(t, images, 1)
108+ assert.Equal(t, "gordon/web", images[0].Repository)
109+ assert.Equal(t, "latest", images[0].Tag)
110+ assert.False(t, images[0].Dangling)
111+}
112+
113+func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) {
114+ manifestStorage := noopManifestStorage{}
115+ blobStorage := noopBlobStorage{}
116+ rt := &fakeRuntime{listErr: errors.New("runtime list failed")}
117+
118+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
119+
120+ _, err := svc.ListImages(context.Background())
121+
122+ require.Error(t, err)
123+ assert.Contains(t, err.Error(), "failed to list images")
124+}
125+
126+func TestService_PruneRuntime_RemovesDanglingImages(t *testing.T) {
127+ manifestStorage := noopManifestStorage{}
128+ blobStorage := noopBlobStorage{}
129+ rt := &fakeRuntime{
130+ pruneReport: pkgruntime.PruneReport{
131+ DeletedIDs: []string{"sha256:a", "sha256:b"},
132+ SpaceReclaimed: 2048,
133+ },
134+ }
135+
136+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
137+
138+ report, err := svc.PruneRuntime(context.Background())
139+
140+ require.NoError(t, err)
141+ assert.Equal(t, 2, report.Runtime.DeletedCount)
142+ assert.Equal(t, int64(2048), report.Runtime.SpaceReclaimed)
143+ assert.True(t, rt.pruneCalled)
144+ assert.True(t, rt.pruneDanglingOnly)
145+}
146+
147+func TestService_PruneRuntime_NoDanglingImages(t *testing.T) {
148+ manifestStorage := noopManifestStorage{}
149+ blobStorage := noopBlobStorage{}
150+ rt := &fakeRuntime{
151+ pruneReport: pkgruntime.PruneReport{
152+ DeletedIDs: nil,
153+ SpaceReclaimed: 0,
154+ },
155+ }
156+
157+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
158+
159+ report, err := svc.PruneRuntime(context.Background())
160+
161+ require.NoError(t, err)
162+ assert.Equal(t, 0, report.Runtime.DeletedCount)
163+ assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed)
164+ assert.True(t, rt.pruneCalled)
165+ assert.True(t, rt.pruneDanglingOnly)
166+}
167+
168+func TestService_PruneRuntime_ReturnsErrorWhenRuntimeFails(t *testing.T) {
169+ manifestStorage := noopManifestStorage{}
170+ blobStorage := noopBlobStorage{}
171+ rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
172+
173+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
174+
175+ _, err := svc.PruneRuntime(context.Background())
176+
177+ require.Error(t, err)
178+ assert.Contains(t, err.Error(), "failed to prune runtime images")
179+ assert.True(t, rt.pruneCalled)
180+ assert.True(t, rt.pruneDanglingOnly)
181+}
182+
183+type fakeRuntime struct {
184+ pkgruntime.Runtime
185+
186+ listDetails []pkgruntime.ImageDetail
187+ listErr error
188+
189+ pruneReport pkgruntime.PruneReport
190+ pruneErr error
191+ pruneCalled bool
192+ pruneDanglingOnly bool
193+}
194+
195+type noopManifestStorage struct {
196+ out.ManifestStorage
197+}
198+
199+type noopBlobStorage struct {
200+ out.BlobStorage
201+}
202+
203+func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) {
204+ return f.listDetails, f.listErr
205+}
206+
207+func (f *fakeRuntime) PruneImages(_ context.Context, danglingOnly bool) (pkgruntime.PruneReport, error) {
208+ f.pruneCalled = true
209+ f.pruneDanglingOnly = danglingOnly
210+ return f.pruneReport, f.pruneErr
211+}
212+
213+var _ pkgruntime.Runtime = (*fakeRuntime)(nil)

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 8

Tags 106

↑↓ moveenter openesc close