detached 0ad73b1

fix: address code review feedback on image pruning

- Fix estimateRegistryTagsToPrune: remove incorrect early return for keepReleases=0
  When keepReleases is 0, user wants to keep only 'latest' tag, but early
  return caused preview to report 0 tags to prune incorrectly.

- Fix pruneWithSpinner: properly drive spinner animation
  Use model.Update(spinner.TickMsg{}) and View() methods instead of
  calling Init() repeatedly.

- Fix test idempotency: reset runtime pruneReport between prunes
  Ensures second prune truly tests idempotency by asserting zero
  runtime results after clearing fake runtime's state.
commit
0ad73b17203389b26d7dcf248d3ceb0c1e649cca
author
brice · · 2026-02-13 18:30 UTC
parent
7c11f18
signature
unsigned
2 files · +15 -10
M internal/adapters/in/cli/images.go +10 -10
@@ -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
@@ -173,15 +174,18 @@ func pruneWithSpinner(ctx context.Context, client imagesClient, req dto.ImagePru
173174 if result.resp == nil {
174175 return nil, fmt.Errorf("prune operation completed but no response received")
175176 }
176- // Success - wait for final frame to render
177+ // Success - set final message and render
177178 model.SetMessage(cliRenderSuccess("Pruning complete"))
178- time.Sleep(100 * time.Millisecond)
179- cmd := model.Init()
180- fmt.Print(cmd)
179+ updatedModel, _ := model.Update(spinner.TickMsg{})
180+ if m, ok := updatedModel.(components.SpinnerModel); ok {
181+ fmt.Print(m.View())
182+ }
181183 return result.resp, nil
182184 case <-time.After(100 * time.Millisecond):
183- cmd := model.Init()
184- fmt.Print(cmd)
185+ updatedModel, _ := model.Update(spinner.TickMsg{})
186+ if m, ok := updatedModel.(components.SpinnerModel); ok {
187+ fmt.Print(m.View())
188+ }
185189 }
186190 }
187191 }
@@ -378,10 +382,6 @@ type previewRegistryTag struct {
378382 }
379383
380384 func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int {
381- if keepReleases <= 0 {
382- return 0
383- }
384-
385385 tagsByRepo := collectTagsByRepository(images)
386386 removed := 0
387387
@@ -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
@@ -173,15 +174,18 @@ func pruneWithSpinner(ctx context.Context, client imagesClient, req dto.ImagePru
173 if result.resp == nil {174 if result.resp == nil {
174 return nil, fmt.Errorf("prune operation completed but no response received")175 return nil, fmt.Errorf("prune operation completed but no response received")
175 }176 }
176- // Success - wait for final frame to render177+ // Success - set final message and render
177 model.SetMessage(cliRenderSuccess("Pruning complete"))178 model.SetMessage(cliRenderSuccess("Pruning complete"))
178- time.Sleep(100 * time.Millisecond)179+ updatedModel, _ := model.Update(spinner.TickMsg{})
179- cmd := model.Init()180+ if m, ok := updatedModel.(components.SpinnerModel); ok {
180- fmt.Print(cmd)181+ fmt.Print(m.View())
182+ }
181 return result.resp, nil183 return result.resp, nil
182 case <-time.After(100 * time.Millisecond):184 case <-time.After(100 * time.Millisecond):
183- cmd := model.Init()185+ updatedModel, _ := model.Update(spinner.TickMsg{})
184- fmt.Print(cmd)186+ if m, ok := updatedModel.(components.SpinnerModel); ok {
187+ fmt.Print(m.View())
188+ }
185 }189 }
186 }190 }
187 }191 }
@@ -378,10 +382,6 @@ type previewRegistryTag struct {
378 }382 }
379 383
380 func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int {384 func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int {
381- if keepReleases <= 0 {
382- return 0
383- }
384-
385 tagsByRepo := collectTagsByRepository(images)385 tagsByRepo := collectTagsByRepository(images)
386 removed := 0386 removed := 0
387 387
M internal/usecase/images/service_test.go +5 -0
@@ -480,6 +480,9 @@ func TestService_Prune_IdempotentMultiplePrunesDoNotAccumulate(t *testing.T) {
480480 assert.Equal(t, 1, report1.Registry.TagsRemoved, "first prune should remove v1")
481481 assert.Equal(t, 1, report1.Registry.BlobsRemoved, "first prune should remove orphan")
482482
483+ // Reset runtime prune report to test idempotency properly (runtime would have nothing to prune second time)
484+ rt.pruneReport = pkgruntime.PruneReport{}
485+
483486 // Second prune - should be idempotent (nothing else to remove)
484487 report2, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
485488 KeepLast: 1, PruneDangling: true, PruneRegistry: true,
@@ -487,6 +490,8 @@ func TestService_Prune_IdempotentMultiplePrunesDoNotAccumulate(t *testing.T) {
487490 require.NoError(t, err)
488491 assert.Equal(t, 0, report2.Registry.TagsRemoved, "second prune should remove nothing (idempotent)")
489492 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")
490495 }
491496
492497 func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
@@ -480,6 +480,9 @@ func TestService_Prune_IdempotentMultiplePrunesDoNotAccumulate(t *testing.T) {
480 assert.Equal(t, 1, report1.Registry.TagsRemoved, "first prune should remove v1")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")481 assert.Equal(t, 1, report1.Registry.BlobsRemoved, "first prune should remove orphan")
482 482
483+ // Reset runtime prune report to test idempotency properly (runtime would have nothing to prune second time)
484+ rt.pruneReport = pkgruntime.PruneReport{}
485+
483 // Second prune - should be idempotent (nothing else to remove)486 // Second prune - should be idempotent (nothing else to remove)
484 report2, err := svc.Prune(context.Background(), domain.ImagePruneOptions{487 report2, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
485 KeepLast: 1, PruneDangling: true, PruneRegistry: true,488 KeepLast: 1, PruneDangling: true, PruneRegistry: true,
@@ -487,6 +490,8 @@ func TestService_Prune_IdempotentMultiplePrunesDoNotAccumulate(t *testing.T) {
487 require.NoError(t, err)490 require.NoError(t, err)
488 assert.Equal(t, 0, report2.Registry.TagsRemoved, "second prune should remove nothing (idempotent)")491 assert.Equal(t, 0, report2.Registry.TagsRemoved, "second prune should remove nothing (idempotent)")
489 assert.Equal(t, 0, report2.Registry.BlobsRemoved, "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")
490 }495 }
491 496
492 func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {497 func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {

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