detached 10f6c18

Merge pull request #77 from bnema/feat/images-prune-spinner-idempotent

feat(images): prune improvements
commit
10f6c1870cc24da70ad21a2f804873bd8c0d143e
author
bnema · · 2026-02-13 18:36 UTC
committer
GitHub · · 2026-02-13 18:36 UTC
merge of
f7b9ef9 + bd06446 · diff against the first parent
signature
unsigned
2 files · +159 -37
M internal/adapters/in/cli/images.go +123 -37
@@ -8,6 +8,7 @@ import (
88 "strings"
99 "time"
1010
11+ "github.com/charmbracelet/bubbles/spinner"
1112 "github.com/charmbracelet/lipgloss"
1213 "github.com/spf13/cobra"
1314
@@ -133,6 +134,63 @@ func resolvePruneScopes(opts imagesPruneOptions) (pruneDangling, pruneRegistry b
133134 return opts.Dangling, opts.Registry
134135 }
135136
137+func pruneWithSpinner(ctx context.Context, client imagesClient, req dto.ImagePruneRequest, pruneDangling, pruneRegistry bool) (*dto.ImagePruneResponse, error) {
138+ if !isInteractiveTerminal() {
139+ return client.PruneImages(ctx, req)
140+ }
141+
142+ type pruneResult struct {
143+ resp *dto.ImagePruneResponse
144+ err error
145+ }
146+
147+ done := make(chan pruneResult, 1)
148+ go func() {
149+ resp, err := client.PruneImages(ctx, req)
150+ done <- pruneResult{resp: resp, err: err}
151+ }()
152+
153+ // Build spinner message based on scopes
154+ msg := "Pruning images"
155+ if pruneDangling && pruneRegistry {
156+ msg = "Pruning runtime and registry images"
157+ } else if pruneDangling {
158+ msg = "Pruning runtime images"
159+ } else if pruneRegistry {
160+ msg = "Pruning registry images"
161+ }
162+
163+ model := components.NewSpinner(
164+ components.WithMessage(msg),
165+ components.WithSpinnerType(components.SpinnerMiniDot),
166+ )
167+
168+ for {
169+ select {
170+ case result := <-done:
171+ if result.err != nil {
172+ return nil, result.err
173+ }
174+ if result.resp == nil {
175+ return nil, fmt.Errorf("prune operation completed but no response received")
176+ }
177+ // Success - set final message and render
178+ model.SetMessage(cliRenderSuccess("Pruning complete"))
179+ updatedModel, _ := model.Update(spinner.TickMsg{})
180+ if m, ok := updatedModel.(components.SpinnerModel); ok {
181+ fmt.Print("\r" + m.View() + "\n")
182+ }
183+ return result.resp, nil
184+ case <-time.After(100 * time.Millisecond):
185+ updatedModel, _ := model.Update(spinner.TickMsg{})
186+ if m, ok := updatedModel.(components.SpinnerModel); ok {
187+ model = m
188+ fmt.Print("\r" + m.View())
189+ }
190+ }
191+ }
192+}
193+
136194 func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error {
137195 images, err := client.ListImages(ctx)
138196 if err != nil {
@@ -214,7 +272,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
214272
215273 req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry)
216274
217- resp, err := client.PruneImages(ctx, req)
275+ resp, err := pruneWithSpinner(ctx, client, req, pruneDangling, pruneRegistry)
218276 if err != nil {
219277 return fmt.Errorf("failed to prune images: %w", err)
220278 }
@@ -325,11 +383,21 @@ type previewRegistryTag struct {
325383 }
326384
327385 func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int {
328- if keepReleases <= 0 {
329- return 0
386+ tagsByRepo := collectTagsByRepository(images)
387+ removed := 0
388+
389+ for _, repoTags := range tagsByRepo {
390+ removed += countTagsToRemove(repoTags, keepReleases)
330391 }
331392
393+ return removed
394+}
395+
396+// collectTagsByRepository groups images by repository and returns
397+// map of repo -> tag name -> creation time
398+func collectTagsByRepository(images []dto.Image) map[string]map[string]time.Time {
332399 tagsByRepo := make(map[string]map[string]time.Time)
400+
333401 for _, img := range images {
334402 repo := strings.TrimSpace(img.Repository)
335403 tag := strings.TrimSpace(img.Tag)
@@ -337,55 +405,73 @@ func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int {
337405 continue
338406 }
339407
340- repoTags, ok := tagsByRepo[repo]
341- if !ok {
342- repoTags = make(map[string]time.Time)
343- tagsByRepo[repo] = repoTags
408+ if _, ok := tagsByRepo[repo]; !ok {
409+ tagsByRepo[repo] = make(map[string]time.Time)
344410 }
345411
412+ repoTags := tagsByRepo[repo]
346413 if current, exists := repoTags[tag]; !exists || img.Created.After(current) {
347414 repoTags[tag] = img.Created
348415 }
349416 }
350417
351- removed := 0
352- for _, repoTags := range tagsByRepo {
353- tagInfos := make([]previewRegistryTag, 0, len(repoTags))
354- for name, created := range repoTags {
355- tagInfos = append(tagInfos, previewRegistryTag{name: name, created: created})
356- }
418+ return tagsByRepo
419+}
357420
358- sort.Slice(tagInfos, func(i, j int) bool {
359- if tagInfos[i].created.Equal(tagInfos[j].created) {
360- return tagInfos[i].name > tagInfos[j].name
361- }
362- return tagInfos[i].created.After(tagInfos[j].created)
363- })
421+// countTagsToRemove counts how many tags in a repository would be removed
422+// based on keepReleases retention policy
423+func countTagsToRemove(repoTags map[string]time.Time, keepReleases int) int {
424+ tagInfos := toSortedTagInfos(repoTags)
364425
365- kept := make(map[string]struct{})
366- for _, info := range tagInfos {
367- if info.name == "latest" {
368- kept["latest"] = struct{}{}
369- break
370- }
426+ kept := selectKeptTags(tagInfos, keepReleases)
427+
428+ return len(tagInfos) - len(kept)
429+}
430+
431+// toSortedTagInfos converts repo tags map to sorted slice
432+func toSortedTagInfos(repoTags map[string]time.Time) []previewRegistryTag {
433+ tagInfos := make([]previewRegistryTag, 0, len(repoTags))
434+ for name, created := range repoTags {
435+ tagInfos = append(tagInfos, previewRegistryTag{name: name, created: created})
436+ }
437+
438+ sort.Slice(tagInfos, func(i, j int) bool {
439+ if tagInfos[i].created.Equal(tagInfos[j].created) {
440+ return tagInfos[i].name > tagInfos[j].name
371441 }
442+ return tagInfos[i].created.After(tagInfos[j].created)
443+ })
372444
373- keptNonLatest := 0
374- for _, info := range tagInfos {
375- if info.name == "latest" {
376- continue
377- }
378- if keptNonLatest >= keepReleases {
379- break
380- }
381- kept[info.name] = struct{}{}
382- keptNonLatest++
445+ return tagInfos
446+}
447+
448+// selectKeptTags returns which tags would be kept based on
449+// retention policy (latest + keepReleases non-latest)
450+func selectKeptTags(tagInfos []previewRegistryTag, keepReleases int) map[string]struct{} {
451+ kept := make(map[string]struct{})
452+
453+ // Always keep latest if present
454+ for _, info := range tagInfos {
455+ if info.name == "latest" {
456+ kept["latest"] = struct{}{}
457+ break
383458 }
459+ }
384460
385- removed += len(tagInfos) - len(kept)
461+ // Keep most recent non-latest tags
462+ keptCount := 0
463+ for _, info := range tagInfos {
464+ if info.name == "latest" {
465+ continue
466+ }
467+ if keptCount >= keepReleases {
468+ break
469+ }
470+ kept[info.name] = struct{}{}
471+ keptCount++
386472 }
387473
388- return removed
474+ return kept
389475 }
390476
391477 func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest {
@@ -8,6 +8,7 @@ import (
8 "strings"8 "strings"
9 "time"9 "time"
10 10
11+ "github.com/charmbracelet/bubbles/spinner"
11 "github.com/charmbracelet/lipgloss"12 "github.com/charmbracelet/lipgloss"
12 "github.com/spf13/cobra"13 "github.com/spf13/cobra"
13 14
@@ -133,6 +134,63 @@ func resolvePruneScopes(opts imagesPruneOptions) (pruneDangling, pruneRegistry b
133 return opts.Dangling, opts.Registry134 return opts.Dangling, opts.Registry
134 }135 }
135 136
137+func pruneWithSpinner(ctx context.Context, client imagesClient, req dto.ImagePruneRequest, pruneDangling, pruneRegistry bool) (*dto.ImagePruneResponse, error) {
138+ if !isInteractiveTerminal() {
139+ return client.PruneImages(ctx, req)
140+ }
141+
142+ type pruneResult struct {
143+ resp *dto.ImagePruneResponse
144+ err error
145+ }
146+
147+ done := make(chan pruneResult, 1)
148+ go func() {
149+ resp, err := client.PruneImages(ctx, req)
150+ done <- pruneResult{resp: resp, err: err}
151+ }()
152+
153+ // Build spinner message based on scopes
154+ msg := "Pruning images"
155+ if pruneDangling && pruneRegistry {
156+ msg = "Pruning runtime and registry images"
157+ } else if pruneDangling {
158+ msg = "Pruning runtime images"
159+ } else if pruneRegistry {
160+ msg = "Pruning registry images"
161+ }
162+
163+ model := components.NewSpinner(
164+ components.WithMessage(msg),
165+ components.WithSpinnerType(components.SpinnerMiniDot),
166+ )
167+
168+ for {
169+ select {
170+ case result := <-done:
171+ if result.err != nil {
172+ return nil, result.err
173+ }
174+ if result.resp == nil {
175+ return nil, fmt.Errorf("prune operation completed but no response received")
176+ }
177+ // Success - set final message and render
178+ model.SetMessage(cliRenderSuccess("Pruning complete"))
179+ updatedModel, _ := model.Update(spinner.TickMsg{})
180+ if m, ok := updatedModel.(components.SpinnerModel); ok {
181+ fmt.Print("\r" + m.View() + "\n")
182+ }
183+ return result.resp, nil
184+ case <-time.After(100 * time.Millisecond):
185+ updatedModel, _ := model.Update(spinner.TickMsg{})
186+ if m, ok := updatedModel.(components.SpinnerModel); ok {
187+ model = m
188+ fmt.Print("\r" + m.View())
189+ }
190+ }
191+ }
192+}
193+
136 func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error {194 func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error {
137 images, err := client.ListImages(ctx)195 images, err := client.ListImages(ctx)
138 if err != nil {196 if err != nil {
@@ -214,7 +272,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
214 272
215 req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry)273 req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry)
216 274
217- resp, err := client.PruneImages(ctx, req)275+ resp, err := pruneWithSpinner(ctx, client, req, pruneDangling, pruneRegistry)
218 if err != nil {276 if err != nil {
219 return fmt.Errorf("failed to prune images: %w", err)277 return fmt.Errorf("failed to prune images: %w", err)
220 }278 }
@@ -325,11 +383,21 @@ type previewRegistryTag struct {
325 }383 }
326 384
327 func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int {385 func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int {
328- if keepReleases <= 0 {386+ tagsByRepo := collectTagsByRepository(images)
329- return 0387+ removed := 0
388+
389+ for _, repoTags := range tagsByRepo {
390+ removed += countTagsToRemove(repoTags, keepReleases)
330 }391 }
331 392
393+ return removed
394+}
395+
396+// collectTagsByRepository groups images by repository and returns
397+// map of repo -> tag name -> creation time
398+func collectTagsByRepository(images []dto.Image) map[string]map[string]time.Time {
332 tagsByRepo := make(map[string]map[string]time.Time)399 tagsByRepo := make(map[string]map[string]time.Time)
400+
333 for _, img := range images {401 for _, img := range images {
334 repo := strings.TrimSpace(img.Repository)402 repo := strings.TrimSpace(img.Repository)
335 tag := strings.TrimSpace(img.Tag)403 tag := strings.TrimSpace(img.Tag)
@@ -337,55 +405,73 @@ func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int {
337 continue405 continue
338 }406 }
339 407
340- repoTags, ok := tagsByRepo[repo]408+ if _, ok := tagsByRepo[repo]; !ok {
341- if !ok {409+ tagsByRepo[repo] = make(map[string]time.Time)
342- repoTags = make(map[string]time.Time)
343- tagsByRepo[repo] = repoTags
344 }410 }
345 411
412+ repoTags := tagsByRepo[repo]
346 if current, exists := repoTags[tag]; !exists || img.Created.After(current) {413 if current, exists := repoTags[tag]; !exists || img.Created.After(current) {
347 repoTags[tag] = img.Created414 repoTags[tag] = img.Created
348 }415 }
349 }416 }
350 417
351- removed := 0418+ return tagsByRepo
352- for _, repoTags := range tagsByRepo {419+}
353- tagInfos := make([]previewRegistryTag, 0, len(repoTags))
354- for name, created := range repoTags {
355- tagInfos = append(tagInfos, previewRegistryTag{name: name, created: created})
356- }
357 420
358- sort.Slice(tagInfos, func(i, j int) bool {421+// countTagsToRemove counts how many tags in a repository would be removed
359- if tagInfos[i].created.Equal(tagInfos[j].created) {422+// based on keepReleases retention policy
360- return tagInfos[i].name > tagInfos[j].name423+func countTagsToRemove(repoTags map[string]time.Time, keepReleases int) int {
361- }424+ tagInfos := toSortedTagInfos(repoTags)
362- return tagInfos[i].created.After(tagInfos[j].created)
363- })
364 425
365- kept := make(map[string]struct{})426+ kept := selectKeptTags(tagInfos, keepReleases)
366- for _, info := range tagInfos {427+
367- if info.name == "latest" {428+ return len(tagInfos) - len(kept)
368- kept["latest"] = struct{}{}429+}
369- break430+
370- }431+// toSortedTagInfos converts repo tags map to sorted slice
432+func toSortedTagInfos(repoTags map[string]time.Time) []previewRegistryTag {
433+ tagInfos := make([]previewRegistryTag, 0, len(repoTags))
434+ for name, created := range repoTags {
435+ tagInfos = append(tagInfos, previewRegistryTag{name: name, created: created})
436+ }
437+
438+ sort.Slice(tagInfos, func(i, j int) bool {
439+ if tagInfos[i].created.Equal(tagInfos[j].created) {
440+ return tagInfos[i].name > tagInfos[j].name
371 }441 }
442+ return tagInfos[i].created.After(tagInfos[j].created)
443+ })
372 444
373- keptNonLatest := 0445+ return tagInfos
374- for _, info := range tagInfos {446+}
375- if info.name == "latest" {447+
376- continue448+// selectKeptTags returns which tags would be kept based on
377- }449+// retention policy (latest + keepReleases non-latest)
378- if keptNonLatest >= keepReleases {450+func selectKeptTags(tagInfos []previewRegistryTag, keepReleases int) map[string]struct{} {
379- break451+ kept := make(map[string]struct{})
380- }452+
381- kept[info.name] = struct{}{}453+ // Always keep latest if present
382- keptNonLatest++454+ for _, info := range tagInfos {
455+ if info.name == "latest" {
456+ kept["latest"] = struct{}{}
457+ break
383 }458 }
459+ }
384 460
385- removed += len(tagInfos) - len(kept)461+ // Keep most recent non-latest tags
462+ keptCount := 0
463+ for _, info := range tagInfos {
464+ if info.name == "latest" {
465+ continue
466+ }
467+ if keptCount >= keepReleases {
468+ break
469+ }
470+ kept[info.name] = struct{}{}
471+ keptCount++
386 }472 }
387 473
388- return removed474+ return kept
389 }475 }
390 476
391 func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest {477 func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest {
M internal/usecase/images/service_test.go +36 -0
@@ -458,6 +458,42 @@ func TestService_PruneRegistry_EmptyRepository(t *testing.T) {
458458 assert.Empty(t, blobStorage.deletedBlobs)
459459 }
460460
461+func TestService_Prune_IdempotentMultiplePrunesDoNotAccumulate(t *testing.T) {
462+ manifestStorage := newFakeManifestStorage()
463+ manifestStorage.repositories = []string{"gordon/api"}
464+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
465+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
466+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
467+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
468+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
469+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
470+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
471+ rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}
472+
473+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
474+
475+ // First prune - should remove v1 and orphan
476+ report1, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
477+ KeepLast: 1, PruneDangling: true, PruneRegistry: true,
478+ })
479+ require.NoError(t, err)
480+ assert.Equal(t, 1, report1.Registry.TagsRemoved, "first prune should remove v1")
481+ assert.Equal(t, 1, report1.Registry.BlobsRemoved, "first prune should remove orphan")
482+
483+ // Reset runtime prune report to test idempotency properly (runtime would have nothing to prune second time)
484+ rt.pruneReport = pkgruntime.PruneReport{}
485+
486+ // Second prune - should be idempotent (nothing else to remove)
487+ report2, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
488+ KeepLast: 1, PruneDangling: true, PruneRegistry: true,
489+ })
490+ require.NoError(t, err)
491+ assert.Equal(t, 0, report2.Registry.TagsRemoved, "second prune should remove nothing (idempotent)")
492+ assert.Equal(t, 0, report2.Registry.BlobsRemoved, "second prune should remove nothing (idempotent)")
493+ assert.Equal(t, 0, report2.Runtime.DeletedCount, "second prune should remove no runtime images")
494+ assert.Equal(t, int64(0), report2.Runtime.SpaceReclaimed, "second prune should reclaim no runtime space")
495+}
496+
461497 func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
462498 manifestStorage := newFakeManifestStorage()
463499 manifestStorage.repositories = []string{"gordon/api"}
@@ -458,6 +458,42 @@ func TestService_PruneRegistry_EmptyRepository(t *testing.T) {
458 assert.Empty(t, blobStorage.deletedBlobs)458 assert.Empty(t, blobStorage.deletedBlobs)
459 }459 }
460 460
461+func TestService_Prune_IdempotentMultiplePrunesDoNotAccumulate(t *testing.T) {
462+ manifestStorage := newFakeManifestStorage()
463+ manifestStorage.repositories = []string{"gordon/api"}
464+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
465+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
466+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
467+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
468+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
469+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
470+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
471+ rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}
472+
473+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
474+
475+ // First prune - should remove v1 and orphan
476+ report1, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
477+ KeepLast: 1, PruneDangling: true, PruneRegistry: true,
478+ })
479+ require.NoError(t, err)
480+ assert.Equal(t, 1, report1.Registry.TagsRemoved, "first prune should remove v1")
481+ assert.Equal(t, 1, report1.Registry.BlobsRemoved, "first prune should remove orphan")
482+
483+ // Reset runtime prune report to test idempotency properly (runtime would have nothing to prune second time)
484+ rt.pruneReport = pkgruntime.PruneReport{}
485+
486+ // Second prune - should be idempotent (nothing else to remove)
487+ report2, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
488+ KeepLast: 1, PruneDangling: true, PruneRegistry: true,
489+ })
490+ require.NoError(t, err)
491+ assert.Equal(t, 0, report2.Registry.TagsRemoved, "second prune should remove nothing (idempotent)")
492+ assert.Equal(t, 0, report2.Registry.BlobsRemoved, "second prune should remove nothing (idempotent)")
493+ assert.Equal(t, 0, report2.Runtime.DeletedCount, "second prune should remove no runtime images")
494+ assert.Equal(t, int64(0), report2.Runtime.SpaceReclaimed, "second prune should reclaim no runtime space")
495+}
496+
461 func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {497 func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
462 manifestStorage := newFakeManifestStorage()498 manifestStorage := newFakeManifestStorage()
463 manifestStorage.repositories = []string{"gordon/api"}499 manifestStorage.repositories = []string{"gordon/api"}

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