Skip to content

feat(images): prune improvements#77

Merged
bnema merged 4 commits intomainfrom
feat/images-prune-spinner-idempotent
Feb 13, 2026
Merged

feat(images): prune improvements#77
bnema merged 4 commits intomainfrom
feat/images-prune-spinner-idempotent

Conversation

@bnema
Copy link
Owner

@bnema bnema commented Feb 13, 2026

Summary

  • Spinner during prune: Shows progress ("Pruning images...", "Pruning runtime and registry", etc.) during cleanup operation
  • Idempotent prune test: Verifies running prune multiple times is safe - second prune removes nothing, no errors
  • Already includes from main: Missing child manifest graceful handling (log warning, continue instead of 500 error), prune preview with counts

Changes

  • pruneWithSpinner: Runs PruneImages in goroutine with Bubble Tea spinner
  • Spinner message adapts to selected scopes (dangling, registry, or both)
  • TestService_Prune_IdempotentMultiplePrunesDoNotAccumulate: Runs prune twice, verifies second is no-op
  • Existing fixes from main:
    • Orphaned child manifest handling
    • Preview with dangling/registry counts before confirmation

Test Coverage

All existing Prune tests pass, plus new idempotent test.

Summary by CodeRabbit

  • New Features

    • Image pruning runs in the background with an interactive spinner showing dynamic messages for runtime, registry, or combined scopes, ensures a final success frame, and falls back to non-interactive behavior in silent terminals.
  • Tests

    • Added tests verifying consecutive image prunes are idempotent and do not accumulate removals.

- Add pruneWithSpinner that shows progress during image cleanup
- Test for idempotent prune: running prune twice should not
  delete additional items or error
- Spinner message adapts based on which scopes are enabled
  (runtime, registry, or both)
- Handles missing child manifests gracefully (log warning, continue)

Fixes #74
@coderabbitai
Copy link

coderabbitai bot commented Feb 13, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Adds an interactive spinner-backed CLI prune flow that runs image pruning in a background goroutine, extracts registry tag-selection/counting into helper functions for prune estimation, and adds a test asserting prune idempotency (the test was inserted twice).

Changes

Cohort / File(s) Summary
CLI Spinner & Prune Integration
internal/adapters/in/cli/images.go
Adds pruneWithSpinner to run PruneImages in a background goroutine with an interactive spinner for interactive terminals; falls back to direct PruneImages for non-interactive terminals and replaces the previous direct prune call.
Prune Helpers & Tag Selection
internal/adapters/in/cli/images.go (helpers: collectTagsByRepository, countTagsToRemove, toSortedTagInfos, selectKeptTags, estimateRegistryTagsToPrune)
Extracts and centralizes logic to group tags by repository, sort tags, decide which tags to keep, and count tags to remove; rewrites registry prune estimation to use these helpers and replaces previous inline computations.
Tests (idempotency)
internal/usecase/images/service_test.go
Adds TestService_Prune_IdempotentMultiplePrunesDoNotAccumulate to verify consecutive prunes are idempotent; the test appears duplicated within the same file.

Sequence Diagram

sequenceDiagram
    participant User as "User / CLI"
    participant CLI as "pruneWithSpinner()"
    participant Spinner as "Spinner UI (interactive)"
    participant Worker as "Background Goroutine"
    participant Client as "Client.PruneImages()"

    User->>CLI: invoke pruneWithSpinner(ctx, client, req, ...)
    alt interactive terminal
        CLI->>Spinner: start spinner with scope-aware message
        CLI->>Worker: spawn goroutine to run PruneImages(req)
        Worker->>Client: call PruneImages(ctx, req)
        Client-->>Worker: return ImagePruneResponse / error
        Worker-->>CLI: send result on channel
        loop spinner render loop
            Spinner->>Spinner: animate frames
        end
        CLI->>Spinner: render final success frame and stop
    else non-interactive terminal
        CLI->>Client: call PruneImages(ctx, req) directly
        Client-->>CLI: return ImagePruneResponse / error
    end
    CLI-->>User: return prune result / error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰
I spun a wheel while pruning tags,
A goroutine hummed, no needless nags,
Keepers held fast, the rest did hop,
Prune ran twice — the count did stop,
I twitched my nose and gave a clap.

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'feat(images): prune improvements' is generic and vague, using the term 'improvements' without specifying the key changes like spinner UI, idempotency fixes, or scope-based enhancements. Consider a more specific title that highlights the main feature, such as 'feat(images): add prune spinner UI and idempotent prune validation' or 'feat(images): interactive prune with spinner and idempotency test'.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/images-prune-spinner-idempotent

Comment @coderabbitai help to get the list of available commands and usage tips.

…tic complexity

Break down complex function into smaller helper functions:
- collectTagsByRepository: Group tags by repo
- countTagsToRemove: Count tags that would be removed
- toSortedTagInfos: Sort tags by creation time
- selectKeptTags: Apply retention policy

Fixes golangci-lint cyclomatic complexity > 15 warning.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@internal/adapters/in/cli/images.go`:
- Around line 136-187: The spinner is being driven incorrectly by calling
model.Init() and printing its returned tea.Cmd; instead call the model's Update
and View methods each tick so the spinner advances and renders properly. In
pruneWithSpinner replace uses of model.Init() and printing that value with a
loop that on each 100ms tick invokes model.Update(spinner.TickMsg{}) (importing
"github.com/charmbracelet/bubbles/spinner") to get the updated model and any
cmd, then call model.View() and print that string; on success set the message
via model.SetMessage(cliRenderSuccess("Pruning complete")), perform a final
Update/View cycle to render the success frame, and only then return the
response. Ensure you still consume the done channel and handle errors as before.

In `@internal/usecase/images/service_test.go`:
- Around line 461-490: The test's idempotency check ignores runtime pruning
because the fake runtime (rt of type fakeRuntime) always returns a non‑zero
pkgruntime.PruneReport; update the test so runtime doesn't mask registry
idempotency: either construct the Service without the runtime (pass nil or a
disabled runtime) when calling NewService, or reset rt.pruneReport to an empty
pkgruntime.PruneReport before the second svc.Prune call and assert
report2.Runtime (or report2.Runtime.DeletedIDs/SpaceReclaimed) is zero to ensure
the second prune truly removed nothing from the runtime.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/adapters/in/cli/images.go (1)

380-393: ⚠️ Potential issue | 🟠 Major

Bug: keepReleases == 0 case returns incorrect estimate.

When keepReleases is 0, the user wants to keep only the "latest" tag and prune everything else. However, this early return causes the preview to report 0 tags to prune, which is incorrect.

The condition should be keepReleases < 0 (unreachable due to CLI validation) or removed entirely. The helper functions already handle keepReleases=0 correctly — selectKeptTags will keep only "latest" when keepReleases=0.

🐛 Proposed fix
 func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int {
-	if keepReleases <= 0 {
-		return 0
-	}
-
 	tagsByRepo := collectTagsByRepository(images)
 	removed := 0

- 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.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@internal/adapters/in/cli/images.go`:
- Around line 168-191: The spinner never advances because the returned model
from model.Update(spinner.TickMsg{}) isn't reassigned and each frame prints a
new line; fix by reassigning the updated model (e.g., model = updatedModel)
after calling model.Update(spinner.TickMsg{}) everywhere in the loop (both
timeout case and success branch where spinner.TickMsg{} is used), and change
fmt.Print(m.View()) to print with a carriage return (e.g., prepend or append
"\r") so frames overwrite the same line; ensure the same reassignment/CR
behavior is applied in the success path where cliRenderSuccess("Pruning
complete") is set before calling Update.

Comment on lines 168 to 191
for {
select {
case result := <-done:
if result.err != nil {
return nil, result.err
}
if result.resp == nil {
return nil, fmt.Errorf("prune operation completed but no response received")
}
// Success - set final message and render
model.SetMessage(cliRenderSuccess("Pruning complete"))
updatedModel, _ := model.Update(spinner.TickMsg{})
if m, ok := updatedModel.(components.SpinnerModel); ok {
fmt.Print(m.View())
}
return result.resp, nil
case <-time.After(100 * time.Millisecond):
updatedModel, _ := model.Update(spinner.TickMsg{})
if m, ok := updatedModel.(components.SpinnerModel); ok {
fmt.Print(m.View())
}
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Spinner model state is not preserved between iterations, breaking animation.

The Update method returns a new model with updated spinner state, but it's never reassigned to model. Each iteration calls Update on the same initial state, so the spinner frame never advances. Additionally, without a carriage return (\r), each frame prints on a new line instead of overwriting.

🔧 Proposed fix
 	for {
 		select {
 		case result := <-done:
 			if result.err != nil {
 				return nil, result.err
 			}
 			if result.resp == nil {
 				return nil, fmt.Errorf("prune operation completed but no response received")
 			}
 			// Success - set final message and render
 			model.SetMessage(cliRenderSuccess("Pruning complete"))
 			updatedModel, _ := model.Update(spinner.TickMsg{})
 			if m, ok := updatedModel.(components.SpinnerModel); ok {
-				fmt.Print(m.View())
+				fmt.Print("\r" + m.View() + "\n")
 			}
 			return result.resp, nil
 		case <-time.After(100 * time.Millisecond):
 			updatedModel, _ := model.Update(spinner.TickMsg{})
 			if m, ok := updatedModel.(components.SpinnerModel); ok {
-				fmt.Print(m.View())
+				model = m
+				fmt.Print("\r" + m.View())
 			}
 		}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for {
select {
case result := <-done:
if result.err != nil {
return nil, result.err
}
if result.resp == nil {
return nil, fmt.Errorf("prune operation completed but no response received")
}
// Success - set final message and render
model.SetMessage(cliRenderSuccess("Pruning complete"))
updatedModel, _ := model.Update(spinner.TickMsg{})
if m, ok := updatedModel.(components.SpinnerModel); ok {
fmt.Print(m.View())
}
return result.resp, nil
case <-time.After(100 * time.Millisecond):
updatedModel, _ := model.Update(spinner.TickMsg{})
if m, ok := updatedModel.(components.SpinnerModel); ok {
fmt.Print(m.View())
}
}
}
}
for {
select {
case result := <-done:
if result.err != nil {
return nil, result.err
}
if result.resp == nil {
return nil, fmt.Errorf("prune operation completed but no response received")
}
// Success - set final message and render
model.SetMessage(cliRenderSuccess("Pruning complete"))
updatedModel, _ := model.Update(spinner.TickMsg{})
if m, ok := updatedModel.(components.SpinnerModel); ok {
fmt.Print("\r" + m.View() + "\n")
}
return result.resp, nil
case <-time.After(100 * time.Millisecond):
updatedModel, _ := model.Update(spinner.TickMsg{})
if m, ok := updatedModel.(components.SpinnerModel); ok {
model = m
fmt.Print("\r" + m.View())
}
}
}
🤖 Prompt for AI Agents
In `@internal/adapters/in/cli/images.go` around lines 168 - 191, The spinner never
advances because the returned model from model.Update(spinner.TickMsg{}) isn't
reassigned and each frame prints a new line; fix by reassigning the updated
model (e.g., model = updatedModel) after calling model.Update(spinner.TickMsg{})
everywhere in the loop (both timeout case and success branch where
spinner.TickMsg{} is used), and change fmt.Print(m.View()) to print with a
carriage return (e.g., prepend or append "\r") so frames overwrite the same
line; ensure the same reassignment/CR behavior is applied in the success path
where cliRenderSuccess("Pruning complete") is set before calling Update.

- Reassign model after Update() to preserve spinner state between iterations
- Add \r carriage return so frames overwrite instead of printing new lines
- Add \n after final success message for clean line ending
@bnema bnema merged commit 322654e into main Feb 13, 2026
3 of 4 checks passed
@bnema bnema deleted the feat/images-prune-spinner-idempotent branch February 13, 2026 18:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant