v3.0.0-alpha.1 6fdddb8

prune_failclosed_test.go RawHistoryFolder

1package images
2
3import (
4	"context"
5	"errors"
6	"testing"
7	"time"
8
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/require"
11
12	"github.com/bnema/gordon/internal/domain"
13)
14
15// TestPrune_SnapshotGapFailsClosed proves an incomplete protection
16// snapshot never authorizes deletion: an unreadable app record could be
17// hiding a claim on any candidate, so every candidate stays unknown.
18func TestPrune_SnapshotGapFailsClosed(t *testing.T) {
19	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
20	manifests := newFakeManifestStorage()
21	manifests.repositories = []string{"app"}
22	manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2"}
23	for index, tag := range []string{"latest", "v1", "v2"} {
24		manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index-3) * time.Hour)
25		manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag), testDigest(tag+"-layer"))
26	}
27
28	orphan := testDigest("orphan")
29	blobs := &fakeBlobStorage{
30		blobs:        []string{orphan},
31		blobModTimes: map[string]time.Time{orphan: now.Add(-72 * time.Hour)},
32	}
33	protection := &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{
34		Gaps: []domain.InventoryGap{{
35			Source: domain.InventorySourceAppState,
36			Reason: domain.PruneReasonUnknownInventory,
37			Detail: "app record unreadable",
38		}},
39	}}
40	runtime := &fakePruneRuntime{inventory: &domain.RuntimeInventory{
41		Images: []domain.RuntimeImage{{ID: "sha256:dangling", Labels: map[string]string{domain.LabelApp: "app"}}},
42	}}
43
44	svc, _ := newPruneService(t, manifests, blobs, protection, runtime)
45	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
46		KeepLast: 1, PruneDangling: true, PruneRegistry: true,
47	})
48	require.NoError(t, err)
49
50	assert.Empty(t, manifests.deletedManifests, "an incomplete snapshot must not authorize tag deletion")
51	assert.Empty(t, blobs.deletedBlobs, "an incomplete snapshot must not authorize blob deletion")
52	assert.Empty(t, runtime.removedImages, "an incomplete snapshot must not authorize image deletion")
53	assert.Zero(t, report.Plan.CountByVerdict(domain.PruneVerdictEligible))
54	// Only the explicitly retained window tags stay protected; every
55	// other candidate is unknown rather than eligible.
56	assert.Equal(t, 2, report.Plan.CountByVerdict(domain.PruneVerdictProtected))
57	assert.Equal(t, len(report.Plan.Candidates)-2, report.Plan.CountByVerdict(domain.PruneVerdictUnknown))
58}
59
60// TestPrune_MovedTagKeepsActiveDigestClosure proves an ACTIVE digest root
61// keeps the full OCI closure of its manifest: moving the mutable tag away
62// must not let prune delete the config or layers of the pinned image.
63func TestPrune_MovedTagKeepsActiveDigestClosure(t *testing.T) {
64	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
65	pinned := testDigest("active-manifest")
66	configDigest := testDigest("active-config")
67	layerDigest := testDigest("active-layer")
68
69	manifests := newFakeManifestStorage()
70	manifests.repositories = []string{"app"}
71	// The tag now points elsewhere; the pinned manifest survives untagged.
72	manifests.tagsByRepo["app"] = []string{"latest"}
73	manifests.modTimes[manifestRefKey("app", "latest")] = now
74	manifests.manifests[manifestRefKey("app", "latest")] = mustManifestJSON(t, testDigest("latest-config"))
75	manifests.manifests[manifestRefKey("app", pinned)] = mustManifestJSON(t, configDigest, layerDigest)
76
77	blobs := &fakeBlobStorage{
78		blobs: []string{configDigest, layerDigest, testDigest("orphan")},
79		blobModTimes: map[string]time.Time{
80			configDigest:         now.Add(-72 * time.Hour),
81			layerDigest:          now.Add(-72 * time.Hour),
82			testDigest("orphan"): now.Add(-72 * time.Hour),
83		},
84	}
85	protection := &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{
86		Roots: []domain.ProtectionRoot{{
87			Kind: domain.ProtectionActiveService, Ref: pinned, Repository: "app", Owner: "app@active.web",
88		}},
89	}}
90
91	svc, _ := newPruneService(t, manifests, blobs, protection, &fakePruneRuntime{})
92	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
93	require.NoError(t, err)
94
95	for _, protected := range []string{configDigest, layerDigest} {
96		protectedVerdicts := report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictProtected)
97		found := false
98		for _, verdict := range protectedVerdicts {
99			if verdict.Ref == protected {
100				found = true
101			}
102		}
103		assert.True(t, found, "blob %s of the pinned manifest must stay protected", protected)
104	}
105	assert.NotContains(t, blobs.deletedBlobs, configDigest)
106	assert.NotContains(t, blobs.deletedBlobs, layerDigest)
107}
108
109// TestPrune_SubjectClosureProtectsManifestConfigAndLayers proves a retained
110// artifact manifest keeps its subject manifest and that subject's blobs.
111func TestPrune_SubjectClosureProtectsManifestConfigAndLayers(t *testing.T) {
112	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
113	subject := testDigest("subject-manifest")
114	configDigest := testDigest("subject-config")
115	layerDigest := testDigest("subject-layer")
116
117	manifests := newFakeManifestStorage()
118	manifests.repositories = []string{"app"}
119	manifests.tagsByRepo["app"] = []string{"attestation"}
120	manifests.modTimes[manifestRefKey("app", "attestation")] = now
121	manifests.manifests[manifestRefKey("app", "attestation")] = mustSubjectManifestJSON(t, subject)
122	manifests.manifests[manifestRefKey("app", subject)] = mustManifestJSON(t, configDigest, layerDigest)
123
124	blobs := &fakeBlobStorage{
125		blobs: []string{subject, configDigest, layerDigest},
126		blobModTimes: map[string]time.Time{
127			subject: now.Add(-72 * time.Hour), configDigest: now.Add(-72 * time.Hour), layerDigest: now.Add(-72 * time.Hour),
128		},
129	}
130	svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{}}, &fakePruneRuntime{})
131	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
132	require.NoError(t, err)
133
134	assert.Empty(t, blobs.deletedBlobs)
135	for _, digest := range []string{subject, configDigest, layerDigest} {
136		assert.Contains(t, candidateRefs(report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictProtected)), digest)
137	}
138}
139
140func TestPrune_MissingSubjectFailsClosed(t *testing.T) {
141	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
142	orphan := testDigest("orphan")
143	manifests := newFakeManifestStorage()
144	manifests.repositories = []string{"app"}
145	manifests.tagsByRepo["app"] = []string{"attestation"}
146	manifests.modTimes[manifestRefKey("app", "attestation")] = now
147	manifests.manifests[manifestRefKey("app", "attestation")] = mustSubjectManifestJSON(t, testDigest("missing-subject"))
148	blobs := &fakeBlobStorage{blobs: []string{orphan}, blobModTimes: map[string]time.Time{orphan: now.Add(-72 * time.Hour)}}
149
150	svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{}}, &fakePruneRuntime{})
151	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
152	require.NoError(t, err)
153
154	assert.Empty(t, blobs.deletedBlobs)
155	assert.Contains(t, candidateRefs(report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictUnknown)), orphan)
156}
157
158func TestPrune_SubjectCycleTerminatesAndProtectsClosure(t *testing.T) {
159	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
160	a := testDigest("subject-a")
161	b := testDigest("subject-b")
162	manifests := newFakeManifestStorage()
163	manifests.repositories = []string{"app"}
164	manifests.tagsByRepo["app"] = []string{"attestation"}
165	manifests.modTimes[manifestRefKey("app", "attestation")] = now
166	manifests.manifests[manifestRefKey("app", "attestation")] = mustSubjectManifestJSON(t, a)
167	manifests.manifests[manifestRefKey("app", a)] = mustSubjectManifestJSON(t, b)
168	manifests.manifests[manifestRefKey("app", b)] = mustSubjectManifestJSON(t, a)
169	blobs := &fakeBlobStorage{blobs: []string{a, b}, blobModTimes: map[string]time.Time{a: now.Add(-72 * time.Hour), b: now.Add(-72 * time.Hour)}}
170
171	svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{}}, &fakePruneRuntime{})
172	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
173	require.NoError(t, err)
174
175	assert.Empty(t, blobs.deletedBlobs)
176	protected := candidateRefs(report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictProtected))
177	assert.Contains(t, protected, a)
178	assert.Contains(t, protected, b)
179}
180
181func mustSubjectManifestJSON(t *testing.T, subject string) []byte {
182	t.Helper()
183	return []byte(`{"mediaType":"application/vnd.oci.image.manifest.v1+json","subject":{"digest":"` + subject + `"}}`)
184}
185
186func candidateRefs(candidates []domain.PruneCandidateReport) []string {
187	refs := make([]string, 0, len(candidates))
188	for _, candidate := range candidates {
189		refs = append(refs, candidate.Ref)
190	}
191	return refs
192}
193
194// TestPrune_ExternalRepositoryDigestRootDoesNotDisablePruning proves a
195// durable digest root whose repository has no local content does not make
196// the registry closure incomplete: external images never introduce local
197// content, so unrelated blobs stay eligible.
198func TestPrune_ExternalRepositoryDigestRootDoesNotDisablePruning(t *testing.T) {
199	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
200	orphan := testDigest("orphan")
201
202	manifests := newFakeManifestStorage()
203	manifests.repositories = []string{"app"}
204	manifests.tagsByRepo["app"] = []string{"latest"}
205	manifests.modTimes[manifestRefKey("app", "latest")] = now
206	manifests.manifests[manifestRefKey("app", "latest")] = mustManifestJSON(t, testDigest("latest-config"))
207
208	blobs := &fakeBlobStorage{
209		blobs:        []string{orphan},
210		blobModTimes: map[string]time.Time{orphan: now.Add(-72 * time.Hour)},
211	}
212	protection := &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{
213		Roots: []domain.ProtectionRoot{{
214			Kind: domain.ProtectionActiveService, Ref: testDigest("external-manifest"),
215			Repository: "docker.io/library/nginx", Owner: "app@active.web",
216		}},
217	}}
218
219	svc, _ := newPruneService(t, manifests, blobs, protection, &fakePruneRuntime{})
220	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
221	require.NoError(t, err)
222
223	assert.Equal(t, []string{orphan}, blobs.deletedBlobs, "an external root must not block local prune")
224	assert.Zero(t, report.Plan.CountByVerdict(domain.PruneVerdictUnknown))
225}
226
227// TestPrune_UnresolvedDigestRootFailsClosed proves a durable digest root
228// whose manifest cannot be read makes the registry closure incomplete, so
229// unrelated blobs stay unknown instead of being deleted.
230func TestPrune_UnresolvedDigestRootFailsClosed(t *testing.T) {
231	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
232	missing := testDigest("missing-manifest")
233	orphan := testDigest("orphan")
234
235	manifests := newFakeManifestStorage()
236	manifests.repositories = []string{"app"}
237	manifests.tagsByRepo["app"] = []string{"latest"}
238	manifests.modTimes[manifestRefKey("app", "latest")] = now
239	manifests.manifests[manifestRefKey("app", "latest")] = mustManifestJSON(t, testDigest("latest-config"))
240
241	blobs := &fakeBlobStorage{
242		blobs:        []string{orphan},
243		blobModTimes: map[string]time.Time{orphan: now.Add(-72 * time.Hour)},
244	}
245	protection := &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{
246		Roots: []domain.ProtectionRoot{{
247			Kind: domain.ProtectionActiveService, Ref: missing, Repository: "app", Owner: "app@active.web",
248		}},
249	}}
250
251	svc, _ := newPruneService(t, manifests, blobs, protection, &fakePruneRuntime{})
252	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
253	require.NoError(t, err)
254
255	assert.Empty(t, blobs.deletedBlobs, "an unresolved durable root must not authorize deletion")
256	unknown := report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictUnknown)
257	require.Len(t, unknown, 1)
258	assert.Equal(t, orphan, unknown[0].Ref)
259}
260
261// TestPrune_BlobClaimedByDigestRootSurvives proves a digest pinned
262// directly by durable state (not reachable through any retained tag)
263// is protected.
264func TestPrune_BlobClaimedByDigestRootSurvives(t *testing.T) {
265	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
266	pinned := testDigest("pinned-by-operation")
267
268	manifests := newFakeManifestStorage()
269	manifests.repositories = []string{"app"}
270	manifests.tagsByRepo["app"] = []string{"latest", "v1"}
271	for index, tag := range []string{"latest", "v1"} {
272		manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index) * time.Hour)
273		manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag))
274	}
275
276	blobs := &fakeBlobStorage{
277		blobs:        []string{pinned},
278		blobModTimes: map[string]time.Time{pinned: now.Add(-72 * time.Hour)},
279	}
280	protection := &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{
281		Roots: []domain.ProtectionRoot{
282			{Kind: domain.ProtectionOperation, Ref: pinned, Owner: "app@op-1"},
283		},
284	}}
285
286	svc, _ := newPruneService(t, manifests, blobs, protection, &fakePruneRuntime{})
287	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
288	require.NoError(t, err)
289
290	assert.Empty(t, blobs.deletedBlobs, "a digest pinned by durable state must survive")
291	protected := report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictProtected)
292	require.Len(t, protected, 1)
293	assert.Equal(t, []domain.PruneReason{domain.PruneReasonProtectedOperation}, protected[0].Reasons)
294}
295
296// TestPrune_BlobWithoutModTimeStaysUnknown proves an unreadable blob age
297// fails closed instead of falling through to eligibility: a blob that
298// cannot be aged cannot be proven past the upload TTL.
299func TestPrune_BlobWithoutModTimeStaysUnknown(t *testing.T) {
300	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
301	unknownAge := testDigest("unknown-age")
302
303	manifests := newFakeManifestStorage()
304	manifests.repositories = []string{"app"}
305	manifests.tagsByRepo["app"] = []string{"latest"}
306	manifests.modTimes[manifestRefKey("app", "latest")] = now
307	manifests.manifests[manifestRefKey("app", "latest")] = mustManifestJSON(t, testDigest("cfg"))
308
309	blobs := &fakeBlobStorage{blobs: []string{unknownAge}, blobModTimeErr: errors.New("stat failed")}
310	svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{}, &fakePruneRuntime{})
311
312	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
313	require.NoError(t, err)
314	assert.Empty(t, blobs.deletedBlobs)
315	unknown := report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictUnknown)
316	require.Len(t, unknown, 1)
317	assert.Equal(t, []domain.PruneReason{domain.PruneReasonUnknownInventory}, unknown[0].Reasons)
318}
319
320// TestPrune_MalformedTagFailsClosed proves a tag whose identity cannot
321// be established makes the registry plan incomplete rather than being
322// skipped: skipping it would hide its content from the closure.
323func TestPrune_MalformedTagFailsClosed(t *testing.T) {
324	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
325	orphan := testDigest("orphan")
326
327	manifests := newFakeManifestStorage()
328	manifests.repositories = []string{"app"}
329	manifests.tagsByRepo["app"] = []string{"latest", " "}
330	manifests.modTimes[manifestRefKey("app", "latest")] = now
331	manifests.manifests[manifestRefKey("app", "latest")] = mustManifestJSON(t, testDigest("cfg"))
332
333	blobs := &fakeBlobStorage{
334		blobs:        []string{orphan},
335		blobModTimes: map[string]time.Time{orphan: now.Add(-72 * time.Hour)},
336	}
337	svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{}, &fakePruneRuntime{})
338
339	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
340	require.NoError(t, err)
341	assert.Empty(t, blobs.deletedBlobs)
342	assert.NotEmpty(t, report.Plan.Gaps)
343	assert.Zero(t, report.Plan.CountByVerdict(domain.PruneVerdictEligible))
344}
345
346// TestPrune_SecondExecutionIsIdempotent proves an executed plan is
347// complete: re-running it finds nothing left to delete, and the second
348// report carries no eligible candidate.
349func TestPrune_SecondExecutionIsIdempotent(t *testing.T) {
350	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
351	manifests := newFakeManifestStorage()
352	manifests.repositories = []string{"app"}
353	manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2", "v3"}
354	for index, tag := range []string{"latest", "v1", "v2", "v3"} {
355		manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index-4) * time.Hour)
356		manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag), testDigest(tag+"-layer"))
357	}
358
359	orphan := testDigest("orphan")
360	blobs := &fakeBlobStorage{
361		blobs:        []string{orphan},
362		blobModTimes: map[string]time.Time{orphan: now.Add(-72 * time.Hour)},
363	}
364	runtime := &fakePruneRuntime{inventory: &domain.RuntimeInventory{
365		Images: []domain.RuntimeImage{{ID: "sha256:dangling", Labels: map[string]string{domain.LabelApp: "app"}}},
366	}}
367
368	svc, _ := newPruneService(t, manifests, blobs, releasedImageSnapshot("sha256:dangling"), runtime)
369	opts := domain.ImagePruneOptions{KeepLast: 1, PruneDangling: true, PruneRegistry: true}
370
371	first, err := svc.Prune(context.Background(), opts)
372	require.NoError(t, err)
373	require.NotEmpty(t, first.Plan.Deleted, "the first run must delete something to be worth repeating")
374	firstDeleted := len(first.Plan.Deleted)
375	firstTags := len(manifests.deletedManifests)
376	firstImages := len(runtime.removedImages)
377	firstBlobs := len(blobs.deletedBlobs)
378	require.NotZero(t, firstTags+firstImages+firstBlobs)
379
380	second, err := svc.Prune(context.Background(), opts)
381	require.NoError(t, err)
382	assert.Empty(t, second.Plan.Deleted, "a second run must delete nothing")
383	assert.Zero(t, second.Plan.CountByVerdict(domain.PruneVerdictEligible), "nothing may remain eligible")
384	assert.Zero(t, second.Plan.CountByVerdict(domain.PruneVerdictUnknown), "a completed plan leaves no unknowns behind")
385	assert.Empty(t, second.Plan.Failures)
386	assert.Equal(t, firstTags, len(manifests.deletedManifests))
387	assert.Equal(t, firstImages, len(runtime.removedImages))
388	assert.Equal(t, firstBlobs, len(blobs.deletedBlobs))
389	assert.Len(t, first.Plan.Deleted, firstDeleted)
390}
391
392// TestPrune_RuntimeDeletionRunsOutsideRegistryLock proves the registry
393// mutation lock is not held while runtime images are deleted, so a slow
394// runtime prune cannot stall registry pushes.
395func TestPrune_RuntimeDeletionRunsOutsideRegistryLock(t *testing.T) {
396	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
397	manifests := newFakeManifestStorage()
398	manifests.repositories = []string{"app"}
399	manifests.tagsByRepo["app"] = []string{"latest", "v1"}
400	manifests.modTimes[manifestRefKey("app", "latest")] = now
401	manifests.modTimes[manifestRefKey("app", "v1")] = now.Add(-time.Hour)
402	manifests.manifests[manifestRefKey("app", "latest")] = mustManifestJSON(t, testDigest("l"))
403	manifests.manifests[manifestRefKey("app", "v1")] = mustManifestJSON(t, testDigest("one"))
404
405	// The runtime port reports whether the registry lock was held when
406	// the deletion ran.
407	runtime := &fakePruneRuntime{
408		inventory: &domain.RuntimeInventory{
409			Images: []domain.RuntimeImage{{ID: "sha256:dangling", Labels: map[string]string{domain.LabelApp: "app"}}},
410		},
411	}
412	svc, _ := newPruneService(t, manifests, &fakeBlobStorage{}, releasedImageSnapshot("sha256:dangling"), runtime)
413
414	unlocked := false
415	runtime.onRemoveImage = func() {
416		unlocked = svc.mutationMu.TryLock()
417		if unlocked {
418			svc.mutationMu.Unlock()
419		}
420	}
421
422	_, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
423		KeepLast: 1, PruneDangling: true, PruneRegistry: true,
424	})
425	require.NoError(t, err)
426	assert.Equal(t, []string{"sha256:dangling"}, runtime.removedImages)
427	assert.True(t, unlocked, "runtime deletion must not run under the registry mutation lock")
428}

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 7

Tags 106

↑↓ moveenter openesc close