main bac21c6

prune_test.go RawHistoryFolder

1package images
2
3import (
4	"context"
5	"crypto/sha256"
6	"encoding/hex"
7	"errors"
8	"sync"
9	"testing"
10	"time"
11
12	"github.com/bnema/zerowrap"
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/require"
15
16	"github.com/bnema/gordon/internal/boundaries/out"
17	"github.com/bnema/gordon/internal/domain"
18)
19
20// digestOf returns the sha256 digest of a manifest body, matching the
21// identity the service derives during inventory.
22func manifestBodyDigest(data []byte) string {
23	sum := sha256.Sum256(data)
24	return "sha256:" + hex.EncodeToString(sum[:])
25}
26
27func testDigest(seed string) string {
28	sum := sha256.Sum256([]byte(seed))
29	return "sha256:" + hex.EncodeToString(sum[:])
30}
31
32// fakeProtectionStore returns one fixed snapshot.
33type fakeProtectionStore struct {
34	snapshot *domain.PruneProtectionSnapshot
35	err      error
36	calls    int
37}
38
39func (f *fakeProtectionStore) ProtectionSnapshot(context.Context) (*domain.PruneProtectionSnapshot, error) {
40	f.calls++
41	if f.err != nil {
42		return nil, f.err
43	}
44	if f.snapshot == nil {
45		return &domain.PruneProtectionSnapshot{}, nil
46	}
47	return f.snapshot, nil
48}
49
50// fakePruneRuntime records every exact deletion request.
51type fakePruneRuntime struct {
52	inventory    *domain.RuntimeInventory
53	inventoryErr error
54
55	removedImages  []string
56	removeImageErr error
57
58	removedVolumes  []string
59	removeVolumeErr error
60
61	// onRemoveImage, when set, runs before a runtime image deletion so a
62	// test can observe the state it executes under.
63	onRemoveImage func()
64}
65
66func (f *fakePruneRuntime) InventoryRuntime(context.Context) (*domain.RuntimeInventory, error) {
67	if f.inventoryErr != nil {
68		return nil, f.inventoryErr
69	}
70	if f.inventory == nil {
71		return &domain.RuntimeInventory{}, nil
72	}
73	return f.inventory, nil
74}
75
76func (f *fakePruneRuntime) RemoveImageExact(_ context.Context, ref domain.RuntimeImageRef) error {
77	if f.onRemoveImage != nil {
78		f.onRemoveImage()
79	}
80	if f.removeImageErr != nil {
81		return f.removeImageErr
82	}
83	f.removedImages = append(f.removedImages, ref.ID)
84	// A real adapter no longer reports a removed image, so the double
85	// must not either: otherwise a second run would look idempotent
86	// only because the double forgets nothing.
87	if f.inventory != nil {
88		kept := f.inventory.Images[:0]
89		for _, image := range f.inventory.Images {
90			if image.ID != ref.ID {
91				kept = append(kept, image)
92			}
93		}
94		f.inventory.Images = kept
95	}
96	return nil
97}
98
99func (f *fakePruneRuntime) RemoveVolumeExact(_ context.Context, ref domain.RuntimeVolumeRef) error {
100	if f.removeVolumeErr != nil {
101		return f.removeVolumeErr
102	}
103	f.removedVolumes = append(f.removedVolumes, ref.Name)
104	return nil
105}
106
107// fakeBarrier records the exclusive lease lifecycle.
108type fakeBarrier struct {
109	mu       sync.Mutex
110	acquires int
111	releases int
112	err      error
113}
114
115func (b *fakeBarrier) AcquireShared(context.Context) (out.GCLease, error) {
116	return noopLease{}, nil
117}
118
119func (b *fakeBarrier) AcquireExclusive(context.Context) (out.GCLease, error) {
120	if b.err != nil {
121		return nil, b.err
122	}
123	b.mu.Lock()
124	b.acquires++
125	b.mu.Unlock()
126	return &recordingLease{barrier: b}, nil
127}
128
129type recordingLease struct {
130	barrier *fakeBarrier
131	once    sync.Once
132}
133
134func (l *recordingLease) Release() {
135	l.once.Do(func() {
136		l.barrier.mu.Lock()
137		l.barrier.releases++
138		l.barrier.mu.Unlock()
139	})
140}
141
142// noopLease is a lease used by the barrier under test.
143type noopLease struct{}
144
145func (noopLease) Release() {}
146
147func newPruneService(t *testing.T, manifests *fakeManifestStorage, blobs *fakeBlobStorage, protection *fakeProtectionStore, runtime *fakePruneRuntime) (*Service, *fakeBarrier) {
148	t.Helper()
149	barrier := &fakeBarrier{}
150	svc := NewService(&fakeRuntime{}, manifests, blobs, zerowrap.Default()).
151		WithPrunePorts(protection, runtime, barrier)
152	return svc, barrier
153}
154
155// TestPrune_SelectiveMixedBatch is the core acceptance case: app state
156// exists, and a mixed batch of safe, protected, and unknown candidates
157// produces exactly the safe deletions with no operation-wide refusal.
158func TestPrune_SelectiveMixedBatch(t *testing.T) {
159	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
160	oldTag := mustManifestJSON(t, testDigest("a"), testDigest("b"))
161	latestTag := mustManifestJSON(t, testDigest("c"), testDigest("d"))
162
163	manifests := newFakeManifestStorage()
164	manifests.repositories = []string{"app"}
165	manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2", "v3", "v4", "v5"}
166	for index, tag := range []string{"latest", "v1", "v2", "v3", "v4", "v5"} {
167		manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index-6) * time.Hour)
168	}
169	manifests.manifests[manifestRefKey("app", "latest")] = latestTag
170	manifests.manifests[manifestRefKey("app", "v1")] = oldTag
171	for _, tag := range []string{"v2", "v3", "v4", "v5"} {
172		manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag), testDigest(tag+"-layer"))
173	}
174
175	blobs := &fakeBlobStorage{
176		blobs:        []string{testDigest("b"), testDigest("d"), testDigest("orphan")},
177		blobSizes:    map[string]int64{testDigest("orphan"): 4096},
178		blobModTimes: map[string]time.Time{testDigest("orphan"): now.Add(-48 * time.Hour)},
179	}
180
181	activeImage := "sha256:active"
182	danglingImage := "sha256:dangling"
183	foreignImage := "sha256:foreign"
184	protection := &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{
185		Roots: []domain.ProtectionRoot{
186			{Kind: domain.ProtectionActiveService, Ref: mustTagKey(t, "app", "v1"), Owner: "app@active.web"},
187		},
188		ImageClaims: []domain.ImageClaim{releasedImageClaim(danglingImage)},
189	}}
190	runtime := &fakePruneRuntime{inventory: &domain.RuntimeInventory{
191		Images: []domain.RuntimeImage{
192			{ID: activeImage, RepoTags: []string{"app:latest"}, Labels: map[string]string{domain.LabelApp: "app"}},
193			{ID: danglingImage, Labels: map[string]string{domain.LabelApp: "app"}},
194			{ID: foreignImage},
195		},
196		Containers: []domain.RuntimeContainerUse{
197			{ContainerID: "c1", ImageID: activeImage, Running: true},
198		},
199	}}
200
201	svc, barrier := newPruneService(t, manifests, blobs, protection, runtime)
202	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
203		KeepLast: 3, PruneDangling: true, PruneRegistry: true,
204	})
205	require.NoError(t, err)
206
207	// latest + 3 = latest, v5, v4, v3 retained; v2 and v1 pruned
208	// only if unprotected. v1 is protected by the active app state.
209	assert.Equal(t, []manifestRef{{name: "app", reference: "v2"}}, manifests.deletedManifests)
210
211	// The dangling app image is deleted; the foreign and in-use ones
212	// are not.
213	assert.Equal(t, []string{danglingImage}, runtime.removedImages)
214
215	// The orphan blob is deleted; the blobs reachable from the retained
216	// and protected manifests are not.
217	assert.Equal(t, []string{testDigest("orphan")}, blobs.deletedBlobs)
218	assert.Equal(t, int64(4096), report.Plan.ReclaimedBytes)
219	assert.True(t, report.Plan.ReclaimedKnown)
220	assert.True(t, report.Plan.Applied)
221	assert.Empty(t, report.Plan.Failures)
222
223	assert.GreaterOrEqual(t, report.Plan.CountByVerdict(domain.PruneVerdictProtected), 1)
224	assert.GreaterOrEqual(t, report.Plan.CountByVerdict(domain.PruneVerdictEligible), 1)
225	assert.Equal(t, 1, barrier.acquires)
226	assert.Equal(t, 1, barrier.releases)
227	assert.Equal(t, 1, protection.calls)
228}
229
230func TestPrune_DryRunPlansWithoutDeleting(t *testing.T) {
231	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
232	manifests := newFakeManifestStorage()
233	manifests.repositories = []string{"app"}
234	manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2"}
235	for index, tag := range []string{"latest", "v1", "v2"} {
236		manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index-3) * time.Hour)
237		manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag), testDigest(tag+"-l"))
238	}
239	blobs := &fakeBlobStorage{blobs: []string{testDigest("x")}, blobModTimes: map[string]time.Time{testDigest("x"): now.Add(-72 * time.Hour)}}
240
241	svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{}, &fakePruneRuntime{})
242	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
243		KeepLast: 1, PruneDangling: true, PruneRegistry: true, DryRun: true,
244	})
245	require.NoError(t, err)
246
247	assert.False(t, report.Plan.Applied)
248	assert.Empty(t, manifests.deletedManifests)
249	assert.Empty(t, blobs.deletedBlobs)
250	assert.NotEmpty(t, report.Plan.Candidates)
251
252	// The dry-run plan must equal the executed plan.
253	executed, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
254		KeepLast: 1, PruneDangling: true, PruneRegistry: true,
255	})
256	require.NoError(t, err)
257	assert.Equal(t, report.Plan.Candidates, executed.Plan.Candidates)
258	assert.True(t, executed.Plan.Applied)
259}
260
261func TestPrune_ZeroEligibleSucceeds(t *testing.T) {
262	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
263	manifests := newFakeManifestStorage()
264	manifests.repositories = []string{"app"}
265	manifests.tagsByRepo["app"] = []string{"latest", "v1"}
266	for index, tag := range []string{"latest", "v1"} {
267		manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index) * time.Hour)
268		manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag))
269	}
270	blobs := &fakeBlobStorage{blobs: []string{}}
271	runtime := &fakePruneRuntime{inventory: &domain.RuntimeInventory{}}
272
273	svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{}, runtime)
274	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
275		KeepLast: 3, PruneDangling: true, PruneRegistry: true,
276	})
277	require.NoError(t, err, "a zero-deletion prune must succeed, not fail the operation")
278	assert.Zero(t, report.Registry.TagsRemoved)
279	assert.Zero(t, report.Registry.BlobsRemoved)
280	assert.Zero(t, report.Runtime.DeletedCount)
281	assert.Equal(t, 0, report.Plan.CountByVerdict(domain.PruneVerdictEligible))
282}
283
284func TestPrune_MissingChildManifestMakesUnreferencedBlobsUnknown(t *testing.T) {
285	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
286	childDigest := testDigest("missing-child")
287	indexBody := mustManifestIndexJSON(t, childDigest)
288
289	manifests := newFakeManifestStorage()
290	manifests.repositories = []string{"app"}
291	manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2"}
292	manifests.modTimes[manifestRefKey("app", "latest")] = now
293	manifests.modTimes[manifestRefKey("app", "v1")] = now.Add(-time.Hour)
294	manifests.modTimes[manifestRefKey("app", "v2")] = now.Add(-2 * time.Hour)
295	// latest is a multi-arch index whose child manifest is absent.
296	manifests.manifests[manifestRefKey("app", "latest")] = indexBody
297	manifests.manifests[manifestRefKey("app", "v1")] = mustManifestJSON(t, testDigest("c1"))
298	manifests.manifests[manifestRefKey("app", "v2")] = mustManifestJSON(t, testDigest("c2"))
299
300	orphan := testDigest("orphan")
301	blobs := &fakeBlobStorage{
302		blobs:        []string{orphan},
303		blobModTimes: map[string]time.Time{orphan: now.Add(-72 * time.Hour)},
304	}
305
306	svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{}, &fakePruneRuntime{})
307	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
308		KeepLast: 1, PruneRegistry: true,
309	})
310	require.NoError(t, err)
311
312	// The unreadable child makes the retained closure incomplete, so an
313	// otherwise-unreferenced blob is unknown, not eligible.
314	assert.Empty(t, blobs.deletedBlobs, "an incomplete closure must never authorize blob deletion")
315	assert.Contains(t, manifestBodyDigest(indexBody), "sha256:")
316	var unknown bool
317	for _, candidate := range report.Plan.Candidates {
318		if candidate.Kind == domain.PruneResourceOCIBlob && candidate.Verdict == domain.PruneVerdictUnknown {
319			unknown = true
320			assert.Equal(t, []domain.PruneReason{domain.PruneReasonUnknownManifest}, candidate.Reasons)
321		}
322	}
323	assert.True(t, unknown, "the orphan blob must be reported unknown")
324	assert.NotEmpty(t, report.Plan.Gaps)
325
326	// Tags outside the window are still deleted: the failure is scoped
327	// to the content whose safety depended on the missing manifest.
328	assert.Len(t, manifests.deletedManifests, 1)
329	assert.Equal(t, "v2", manifests.deletedManifests[0].reference)
330}
331
332func TestPrune_SharedBlobStaysProtectedThroughClosure(t *testing.T) {
333	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
334	sharedLayer := testDigest("shared-layer")
335
336	manifests := newFakeManifestStorage()
337	manifests.repositories = []string{"app"}
338	manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2"}
339	manifests.modTimes[manifestRefKey("app", "latest")] = now
340	manifests.modTimes[manifestRefKey("app", "v1")] = now.Add(-time.Hour)
341	manifests.modTimes[manifestRefKey("app", "v2")] = now.Add(-2 * time.Hour)
342	manifests.manifests[manifestRefKey("app", "latest")] = mustManifestJSON(t, testDigest("cfg-latest"), sharedLayer)
343	manifests.manifests[manifestRefKey("app", "v1")] = mustManifestJSON(t, testDigest("cfg-v1"), sharedLayer)
344	manifests.manifests[manifestRefKey("app", "v2")] = mustManifestJSON(t, testDigest("cfg-v2"))
345
346	blobs := &fakeBlobStorage{blobs: []string{sharedLayer}}
347	svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{}, &fakePruneRuntime{})
348	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
349	require.NoError(t, err)
350
351	assert.Empty(t, blobs.deletedBlobs, "a layer shared with a retained tag must survive")
352	protected := report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictProtected)
353	require.Len(t, protected, 1)
354	assert.Equal(t, []domain.PruneReason{domain.PruneReasonProtectedSharedContent}, protected[0].Reasons)
355}
356
357func TestPrune_RegistryDeletionFailureIsIsolated(t *testing.T) {
358	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
359	manifests := newFakeManifestStorage()
360	manifests.repositories = []string{"app"}
361	manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2"}
362	for index, tag := range []string{"latest", "v1", "v2"} {
363		manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index-3) * time.Hour)
364		manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag))
365	}
366	manifests.deleteErr = errors.New("storage unavailable")
367
368	blobs := &fakeBlobStorage{}
369	svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{}, &fakePruneRuntime{})
370	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
371	require.NoError(t, err, "one deletion failure must not fail the run")
372
373	require.Len(t, report.Plan.Failures, 1)
374	assert.Equal(t, domain.PruneResourceRegistryTag, report.Plan.Failures[0].Kind)
375	assert.Zero(t, report.Registry.TagsRemoved)
376	assert.Empty(t, report.Plan.Deleted)
377}
378
379func TestPrune_RuntimeDeletionFailureIsIsolated(t *testing.T) {
380	image := "sha256:dangling"
381	runtime := &fakePruneRuntime{
382		inventory: &domain.RuntimeInventory{
383			Images: []domain.RuntimeImage{{ID: image, Labels: map[string]string{domain.LabelApp: "app"}}},
384		},
385		removeImageErr: errors.New("image is in use"),
386	}
387	svc, _ := newPruneService(t, newFakeManifestStorage(), &fakeBlobStorage{}, releasedImageSnapshot(image), runtime)
388
389	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{PruneDangling: true})
390	require.NoError(t, err)
391	require.Len(t, report.Plan.Failures, 1)
392	assert.Equal(t, domain.PruneResourceRuntimeImage, report.Plan.Failures[0].Kind)
393	assert.Zero(t, report.Runtime.DeletedCount)
394}
395
396func TestPrune_KeepLastZeroSkipsRegistry(t *testing.T) {
397	manifests := newFakeManifestStorage()
398	manifests.repositories = []string{"app"}
399	manifests.tagsByRepo["app"] = []string{"v1"}
400	svc, _ := newPruneService(t, manifests, &fakeBlobStorage{blobs: []string{testDigest("x")}}, &fakeProtectionStore{}, &fakePruneRuntime{})
401
402	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 0, PruneRegistry: true})
403	require.NoError(t, err)
404	assert.Empty(t, manifests.deletedManifests)
405	assert.Empty(t, report.Plan.Candidates)
406}
407
408func TestPrune_RegistryOnlySkipsRuntime(t *testing.T) {
409	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
410	manifests := newFakeManifestStorage()
411	manifests.repositories = []string{"app"}
412	manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2"}
413	for index, tag := range []string{"latest", "v1", "v2"} {
414		manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index-3) * time.Hour)
415		manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag))
416	}
417	runtime := &fakePruneRuntime{
418		inventory: &domain.RuntimeInventory{
419			Images: []domain.RuntimeImage{{ID: "sha256:dangling", Labels: map[string]string{domain.LabelApp: "app"}}},
420		},
421	}
422	svc, _ := newPruneService(t, manifests, &fakeBlobStorage{}, &fakeProtectionStore{}, runtime)
423	_, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
424	require.NoError(t, err)
425	assert.Empty(t, runtime.removedImages, "registry-only prune must not touch runtime images")
426}
427
428func TestPrune_RequiresProtectionStore(t *testing.T) {
429	svc := NewService(&fakeRuntime{}, newFakeManifestStorage(), &fakeBlobStorage{}, zerowrap.Default())
430	_, err := svc.Prune(context.Background(), domain.DefaultImagePruneOptions())
431	require.Error(t, err)
432	assert.ErrorIs(t, err, domain.ErrPruneDisabled)
433}
434
435func TestPrune_ProtectionSnapshotFailureFailsClosed(t *testing.T) {
436	protection := &fakeProtectionStore{err: errors.New("unreadable state")}
437	svc, _ := newPruneService(t, newFakeManifestStorage(), &fakeBlobStorage{}, protection, &fakePruneRuntime{})
438	_, err := svc.Prune(context.Background(), domain.DefaultImagePruneOptions())
439	require.Error(t, err)
440	assert.Contains(t, err.Error(), "protection snapshot")
441}
442
443func TestPrune_RuntimeInventoryGapMakesCandidatesUnknown(t *testing.T) {
444	runtime := &fakePruneRuntime{inventory: &domain.RuntimeInventory{
445		Images: []domain.RuntimeImage{{ID: "sha256:dangling", Labels: map[string]string{domain.LabelApp: "app"}}},
446		Gaps: []domain.InventoryGap{{
447			Source: domain.InventorySourceRuntimeContainers,
448			Reason: domain.PruneReasonUnknownContainerUse,
449			Detail: "list containers failed",
450		}},
451	}}
452	svc, _ := newPruneService(t, newFakeManifestStorage(), &fakeBlobStorage{}, &fakeProtectionStore{}, runtime)
453	report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{PruneDangling: true})
454	require.NoError(t, err)
455	assert.Empty(t, runtime.removedImages)
456	assert.Equal(t, 1, report.Plan.CountByVerdict(domain.PruneVerdictUnknown))
457	require.Len(t, report.Plan.Gaps, 1)
458}
459
460// releasedImageClaim marks identity as durably released by an app. Runtime
461// image deletion requires this positive ownership; labels alone never
462// authorize it.
463func releasedImageClaim(identity string) domain.ImageClaim {
464	return domain.ImageClaim{
465		Reference: identity, App: "app", AppID: "app-uuid",
466		State: domain.VolumeClaimReleased,
467	}
468}
469
470// releasedImageSnapshot is a complete snapshot containing one released
471// runtime image claim.
472func releasedImageSnapshot(identity string) *fakeProtectionStore {
473	return &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{
474		ImageClaims: []domain.ImageClaim{releasedImageClaim(identity)},
475	}}
476}
477
478func mustTagKey(t *testing.T, repository, tag string) string {
479	t.Helper()
480	ref, err := domain.NewRegistryTagRef(repository, tag)
481	require.NoError(t, err)
482	return ref.Key()
483}

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