detached 561bcaf

Merge pull request #76 from bnema/refactor/cli-ui-style-unification

refactor(cli): unify CLI presentation and redesign images prune
commit
561bcaf1d61002c49b659082643e3affd4cf5d65
author
bnema · · 2026-02-13 08:06 UTC
committer
GitHub · · 2026-02-13 08:06 UTC
merge of
83ea58e + 12bb525 · diff against the first parent
signature
unsigned
22 files · +1418 -153

this diff holds invisible characters (direction controls, zero-width characters). They are shown as <U+XXXX> markers, never applied.

M docs/cli/images.md +28 -14
@@ -11,7 +11,7 @@ gordon images <subcommand>
1111 Subcommands:
1212
1313 - `list` - List runtime images and registry tags.
14-- `prune` - Prune dangling runtime images and optionally registry data.
14+- `prune` - Prune dangling runtime images and old registry tags.
1515
1616 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).
1717
@@ -27,25 +27,33 @@ Rows that exist only in the registry (not currently present in the runtime cache
2727 ## gordon images prune
2828
2929 ```bash
30-gordon images prune [--dry-run] [--keep <n>] [--runtime-only]
30+gordon images prune [--dry-run] [--keep-releases <n>] [--dangling] [--registry] [--no-confirm]
3131 ```
3232
33+By default, prune removes dangling runtime images **and** applies registry tag retention (keeping `latest` + 3 previous non-`latest` tags per repository). A confirmation prompt is shown before destructive operations.
34+
3335 Flags:
3436
3537 | Flag | Default | Description |
3638 |------|---------|-------------|
3739 | `--dry-run` | `false` | Show prune behavior without applying changes |
38-| `--keep` | `0` | Number of previous tags to keep per repository (`latest` is always preserved when present; `0` disables registry cleanup) |
39-| `--runtime-only` | `false` | Prune dangling runtime images only; forces registry cleanup off |
40+| `--keep-releases` | `3` | Number of previous non-`latest` tags to keep per repository (`latest` is always preserved) |
41+| `--dangling` | `false` | Restrict scope to dangling runtime images only |
42+| `--registry` | `false` | Restrict scope to registry tag retention only |
43+| `--no-confirm` | `false` | Skip the confirmation prompt |
44+
45+### Scope resolution
4046
41-`--keep` defaults to `0` for ad-hoc CLI safety (registry cleanup off by default), while scheduled pruning uses `images.prune.keep_last` from config (default `3`). Use config for recurring behavior; when a CLI `--keep` value is provided, that request value is used.
47+- **No scope flags** (default): both runtime and registry cleanup run.
48+- **`--dangling`**: only runtime dangling images are pruned; registry is skipped.
49+- **`--registry`**: only registry tag retention runs; runtime is skipped.
50+- **`--dangling --registry`**: both scopes run (same as default, but explicit).
4251
43-Behavior notes:
52+### Retention semantics
4453
45-- `--runtime-only` forces `keep_last = 0`.
46-- `--keep` must be `>= 0`.
47-- `--dry-run` reports what would be pruned.
48-- When `--keep` is greater than `0`, registry cleanup keeps `latest` (if present) plus the requested number of most-recent non-`latest` tags.
54+- `latest` is always preserved when present.
55+- `--keep-releases` counts non-`latest` tags, ordered by most recent first.
56+- `--keep-releases=0` with registry scope enabled still runs registry cleanup but keeps no non-`latest` tags.
4957
5058 ## Examples
5159
@@ -53,14 +61,20 @@ Behavior notes:
5361 # List images
5462 gordon images list
5563
64+# Prune everything with defaults (dangling + registry, keep latest + 3)
65+gordon images prune
66+
5667 # Prune dangling runtime images only
57-gordon images prune --runtime-only
68+gordon images prune --dangling
5869
59-# Prune runtime images and keep latest + 3 previous tags per repository
60-gordon images prune --keep 3
70+# Prune registry tags only, keeping latest + 5 previous
71+gordon images prune --registry --keep-releases 5
6172
6273 # Preview cleanup without applying
63-gordon images prune --dry-run --keep 5
74+gordon images prune --dry-run
75+
76+# Skip confirmation prompt
77+gordon images prune --no-confirm
6478 ```
6579
6680 ## Required Permissions
@@ -11,7 +11,7 @@ gordon images <subcommand>
11 Subcommands:11 Subcommands:
12 12
13 - `list` - List runtime images and registry tags.13 - `list` - List runtime images and registry tags.
14-- `prune` - Prune dangling runtime images and optionally registry data.14+- `prune` - Prune dangling runtime images and old registry tags.
15 15
16 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).16 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).
17 17
@@ -27,25 +27,33 @@ Rows that exist only in the registry (not currently present in the runtime cache
27 ## gordon images prune27 ## gordon images prune
28 28
29 ```bash29 ```bash
30-gordon images prune [--dry-run] [--keep <n>] [--runtime-only]30+gordon images prune [--dry-run] [--keep-releases <n>] [--dangling] [--registry] [--no-confirm]
31 ```31 ```
32 32
33+By default, prune removes dangling runtime images **and** applies registry tag retention (keeping `latest` + 3 previous non-`latest` tags per repository). A confirmation prompt is shown before destructive operations.
34+
33 Flags:35 Flags:
34 36
35 | Flag | Default | Description |37 | Flag | Default | Description |
36 |------|---------|-------------|38 |------|---------|-------------|
37 | `--dry-run` | `false` | Show prune behavior without applying changes |39 | `--dry-run` | `false` | Show prune behavior without applying changes |
38-| `--keep` | `0` | Number of previous tags to keep per repository (`latest` is always preserved when present; `0` disables registry cleanup) |40+| `--keep-releases` | `3` | Number of previous non-`latest` tags to keep per repository (`latest` is always preserved) |
39-| `--runtime-only` | `false` | Prune dangling runtime images only; forces registry cleanup off |41+| `--dangling` | `false` | Restrict scope to dangling runtime images only |
42+| `--registry` | `false` | Restrict scope to registry tag retention only |
43+| `--no-confirm` | `false` | Skip the confirmation prompt |
44+
45+### Scope resolution
40 46
41-`--keep` defaults to `0` for ad-hoc CLI safety (registry cleanup off by default), while scheduled pruning uses `images.prune.keep_last` from config (default `3`). Use config for recurring behavior; when a CLI `--keep` value is provided, that request value is used.47+- **No scope flags** (default): both runtime and registry cleanup run.
48+- **`--dangling`**: only runtime dangling images are pruned; registry is skipped.
49+- **`--registry`**: only registry tag retention runs; runtime is skipped.
50+- **`--dangling --registry`**: both scopes run (same as default, but explicit).
42 51
43-Behavior notes:52+### Retention semantics
44 53
45-- `--runtime-only` forces `keep_last = 0`.54+- `latest` is always preserved when present.
46-- `--keep` must be `>= 0`.55+- `--keep-releases` counts non-`latest` tags, ordered by most recent first.
47-- `--dry-run` reports what would be pruned.56+- `--keep-releases=0` with registry scope enabled still runs registry cleanup but keeps no non-`latest` tags.
48-- When `--keep` is greater than `0`, registry cleanup keeps `latest` (if present) plus the requested number of most-recent non-`latest` tags.
49 57
50 ## Examples58 ## Examples
51 59
@@ -53,14 +61,20 @@ Behavior notes:
53 # List images61 # List images
54 gordon images list62 gordon images list
55 63
64+# Prune everything with defaults (dangling + registry, keep latest + 3)
65+gordon images prune
66+
56 # Prune dangling runtime images only67 # Prune dangling runtime images only
57-gordon images prune --runtime-only68+gordon images prune --dangling
58 69
59-# Prune runtime images and keep latest + 3 previous tags per repository70+# Prune registry tags only, keeping latest + 5 previous
60-gordon images prune --keep 371+gordon images prune --registry --keep-releases 5
61 72
62 # Preview cleanup without applying73 # Preview cleanup without applying
63-gordon images prune --dry-run --keep 574+gordon images prune --dry-run
75+
76+# Skip confirmation prompt
77+gordon images prune --no-confirm
64 ```78 ```
65 79
66 ## Required Permissions80 ## Required Permissions
M docs/config/images.md +2 -0
@@ -11,6 +11,8 @@ When enabled, Gordon runs a scheduled image prune job that:
1111 - Preserves the `latest` tag.
1212 - Removes unreferenced blobs after tag cleanup.
1313
14+The CLI `gordon images prune` uses the same defaults as the scheduled job (keep `latest` + 3 previous tags, both scopes enabled: dangling runtime images and registry tag retention).
15+
1416 ## Configuration
1517
1618 ```toml
@@ -11,6 +11,8 @@ When enabled, Gordon runs a scheduled image prune job that:
11 - Preserves the `latest` tag.11 - Preserves the `latest` tag.
12 - Removes unreferenced blobs after tag cleanup.12 - Removes unreferenced blobs after tag cleanup.
13 13
14+The CLI `gordon images prune` uses the same defaults as the scheduled job (keep `latest` + 3 previous tags, both scopes enabled: dangling runtime images and registry tag retention).
15+
14 ## Configuration16 ## Configuration
15 17
16 ```toml18 ```toml
M internal/adapters/dto/admin_images.go +3 -1
@@ -19,7 +19,9 @@ type ImagesResponse struct {
1919
2020 // ImagePruneRequest triggers image pruning.
2121 type ImagePruneRequest struct {
22- KeepLast *int `json:"keep_last,omitempty"`
22+ KeepLast *int `json:"keep_last,omitempty"`
23+ PruneDangling *bool `json:"prune_dangling,omitempty"`
24+ PruneRegistry *bool `json:"prune_registry,omitempty"`
2325 }
2426
2527 // RuntimePruneResult represents runtime prune results.
@@ -19,7 +19,9 @@ type ImagesResponse struct {
19 19
20 // ImagePruneRequest triggers image pruning.20 // ImagePruneRequest triggers image pruning.
21 type ImagePruneRequest struct {21 type ImagePruneRequest struct {
22- KeepLast *int `json:"keep_last,omitempty"`22+ KeepLast *int `json:"keep_last,omitempty"`
23+ PruneDangling *bool `json:"prune_dangling,omitempty"`
24+ PruneRegistry *bool `json:"prune_registry,omitempty"`
23 }25 }
24 26
25 // RuntimePruneResult represents runtime prune results.27 // RuntimePruneResult represents runtime prune results.
M internal/adapters/in/cli/backup.go +21 -3
@@ -52,9 +52,14 @@ func newBackupListCmd() *cobra.Command {
5252 }
5353
5454 if len(jobs) == 0 {
55- fmt.Println("No backups found")
55+ if err := cliWriteLine(os.Stdout, cliRenderMuted("No backups found")); err != nil {
56+ return err
57+ }
5658 return nil
5759 }
60+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Backups")); err != nil {
61+ return err
62+ }
5863 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
5964 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
6065 return err
@@ -96,6 +101,9 @@ func newBackupRunCmd() *cobra.Command {
96101 if result.Backup == nil {
97102 return fmt.Errorf("backup run completed without backup payload")
98103 }
104+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Result")); err != nil {
105+ return err
106+ }
99107
100108 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
101109 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil {
@@ -133,9 +141,14 @@ func newBackupDetectCmd() *cobra.Command {
133141 }
134142
135143 if len(dbs) == 0 {
136- fmt.Println("No supported databases detected")
144+ if err := cliWriteLine(os.Stdout, cliRenderMuted("No supported databases detected")); err != nil {
145+ return err
146+ }
137147 return nil
138148 }
149+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Detected Databases")); err != nil {
150+ return err
151+ }
139152 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
140153 if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil {
141154 return err
@@ -172,9 +185,14 @@ func newBackupStatusCmd() *cobra.Command {
172185 }
173186
174187 if len(jobs) == 0 {
175- fmt.Println("No backup status available")
188+ if err := cliWriteLine(os.Stdout, cliRenderMuted("No backup status available")); err != nil {
189+ return err
190+ }
176191 return nil
177192 }
193+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Status")); err != nil {
194+ return err
195+ }
178196 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
179197 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil {
180198 return err
@@ -52,9 +52,14 @@ func newBackupListCmd() *cobra.Command {
52 }52 }
53 53
54 if len(jobs) == 0 {54 if len(jobs) == 0 {
55- fmt.Println("No backups found")55+ if err := cliWriteLine(os.Stdout, cliRenderMuted("No backups found")); err != nil {
56+ return err
57+ }
56 return nil58 return nil
57 }59 }
60+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Backups")); err != nil {
61+ return err
62+ }
58 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)63 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
59 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {64 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
60 return err65 return err
@@ -96,6 +101,9 @@ func newBackupRunCmd() *cobra.Command {
96 if result.Backup == nil {101 if result.Backup == nil {
97 return fmt.Errorf("backup run completed without backup payload")102 return fmt.Errorf("backup run completed without backup payload")
98 }103 }
104+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Result")); err != nil {
105+ return err
106+ }
99 107
100 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)108 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
101 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil {109 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil {
@@ -133,9 +141,14 @@ func newBackupDetectCmd() *cobra.Command {
133 }141 }
134 142
135 if len(dbs) == 0 {143 if len(dbs) == 0 {
136- fmt.Println("No supported databases detected")144+ if err := cliWriteLine(os.Stdout, cliRenderMuted("No supported databases detected")); err != nil {
145+ return err
146+ }
137 return nil147 return nil
138 }148 }
149+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Detected Databases")); err != nil {
150+ return err
151+ }
139 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)152 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
140 if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil {153 if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil {
141 return err154 return err
@@ -172,9 +185,14 @@ func newBackupStatusCmd() *cobra.Command {
172 }185 }
173 186
174 if len(jobs) == 0 {187 if len(jobs) == 0 {
175- fmt.Println("No backup status available")188+ if err := cliWriteLine(os.Stdout, cliRenderMuted("No backup status available")); err != nil {
189+ return err
190+ }
176 return nil191 return nil
177 }192 }
193+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Status")); err != nil {
194+ return err
195+ }
178 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)196 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
179 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil {197 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil {
180 return err198 return err
M internal/adapters/in/cli/images.go +142 -50
@@ -5,23 +5,57 @@ import (
55 "fmt"
66 "io"
77 "strings"
8- "text/tabwriter"
98 "time"
109
10+ "github.com/charmbracelet/lipgloss"
1111 "github.com/spf13/cobra"
1212
1313 "github.com/bnema/gordon/internal/adapters/dto"
14+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
15+ "github.com/bnema/gordon/internal/domain"
1416 )
1517
1618 type imagesClient interface {
1719 ListImages(ctx context.Context) ([]dto.Image, error)
18- PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error)
20+ PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error)
1921 }
2022
2123 type imagesPruneOptions struct {
22- DryRun bool
23- KeepLast int
24- RuntimeOnly bool
24+ DryRun bool
25+ KeepReleases int
26+ Dangling bool // scope flag: prune dangling runtime images
27+ Registry bool // scope flag: prune registry tags
28+ NoConfirm bool
29+}
30+
31+// pruneConfirmFunc is the confirmation callback used before destructive prune.
32+// It is a package-level variable so tests can replace it without spawning a
33+// real Bubble Tea program.
34+var pruneConfirmFunc = defaultPruneConfirm
35+
36+func defaultPruneConfirm(prompt string) (bool, error) {
37+ return components.RunConfirm(prompt, components.WithDefaultYes())
38+}
39+
40+// Column widths are fixed to keep table output predictable across terminals.
41+// Values are chosen for typical repository names and SHA-prefixed IDs;
42+// the table component truncates with ellipsis when content overflows.
43+const (
44+ imagesListRepositoryColumnWidth = 44
45+ imagesListTagColumnWidth = 14
46+ imagesListSizeColumnWidth = 10
47+ imagesListCreatedColumnWidth = 22
48+ imagesListImageIDColumnWidth = 22
49+ imagesListDanglingColumnWidth = 10
50+)
51+
52+var imagesListTableColumns = []components.TableColumn{
53+ {Title: "REPOSITORY", Width: imagesListRepositoryColumnWidth},
54+ {Title: "TAG", Width: imagesListTagColumnWidth},
55+ {Title: "SIZE", Width: imagesListSizeColumnWidth},
56+ {Title: "CREATED", Width: imagesListCreatedColumnWidth},
57+ {Title: "IMAGE_ID", Width: imagesListImageIDColumnWidth},
58+ {Title: "DANGLING", Width: imagesListDanglingColumnWidth},
2559 }
2660
2761 func newImagesCmd() *cobra.Command {
@@ -60,6 +94,10 @@ func newImagesPruneCmd() *cobra.Command {
6094 cmd := &cobra.Command{
6195 Use: "prune",
6296 Short: "Prune unused images",
97+ Long: `Remove dangling runtime images and/or old registry tags.
98+
99+By default both runtime and registry cleanup run, keeping latest + 3 previous
100+release tags per repository. Use --dangling or --registry to restrict scope.`,
63101 RunE: func(cmd *cobra.Command, _ []string) error {
64102 client, isRemote := GetRemoteClient()
65103 if !isRemote {
@@ -71,12 +109,24 @@ func newImagesPruneCmd() *cobra.Command {
71109 }
72110
73111 cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes")
74- cmd.Flags().IntVar(&opts.KeepLast, "keep", 0, "Number of previous version tags to keep per repository (latest is always kept when present; 0 disables registry cleanup)")
75- cmd.Flags().BoolVar(&opts.RuntimeOnly, "runtime-only", false, "Prune dangling runtime images only (skip registry cleanup)")
112+ cmd.Flags().IntVar(&opts.KeepReleases, "keep-releases", domain.DefaultImagePruneKeepLast,
113+ "Number of previous non-latest tags to keep per repository (latest is always kept)")
114+ cmd.Flags().BoolVar(&opts.Dangling, "dangling", false, "Include dangling runtime images (default: both scopes)")
115+ cmd.Flags().BoolVar(&opts.Registry, "registry", false, "Include old registry tags (default: both scopes)")
116+ cmd.Flags().BoolVar(&opts.NoConfirm, "no-confirm", false, "Skip confirmation prompt")
76117
77118 return cmd
78119 }
79120
121+// resolvePruneScopes determines which prune subsystems are active.
122+// No scope flags → both true. Any scope flag present → only selected scopes.
123+func resolvePruneScopes(opts imagesPruneOptions) (pruneDangling, pruneRegistry bool) {
124+ if !opts.Dangling && !opts.Registry {
125+ return true, true
126+ }
127+ return opts.Dangling, opts.Registry
128+}
129+
80130 func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error {
81131 images, err := client.ListImages(ctx)
82132 if err != nil {
@@ -84,19 +134,19 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro
84134 }
85135
86136 if len(images) == 0 {
87- if _, err := fmt.Fprintln(out, "No images found"); err != nil {
137+ if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil {
88138 return err
89139 }
90- _, err := fmt.Fprintln(out, "Total images: 0 (dangling: 0)")
140+ err := cliWriteLine(out, cliRenderInfo("Total images: 0 (dangling: 0)"))
91141 return err
92142 }
93143
94- w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
95- if _, err := fmt.Fprintln(w, "REPOSITORY\tTAG\tSIZE\tCREATED\tIMAGE_ID\tDANGLING"); err != nil {
144+ if err := cliWriteLine(out, cliRenderTitle("Images")); err != nil {
96145 return err
97146 }
98147
99148 danglingCount := 0
149+ rows := make([][]string, 0, len(images))
100150 for _, img := range images {
101151 dangling := "no"
102152 if img.Dangling {
@@ -104,30 +154,88 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro
104154 danglingCount++
105155 }
106156
107- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", img.Repository, img.Tag, formatImageSize(img.Size), formatImageCreatedAt(img.Created), formatImageID(img.ID), dangling); err != nil {
108- return err
109- }
157+ rows = append(rows, []string{
158+ img.Repository,
159+ img.Tag,
160+ formatImageSize(img.Size),
161+ formatImageCreatedAt(img.Created),
162+ formatImageID(img.ID),
163+ dangling,
164+ })
110165 }
111166
112- if err := w.Flush(); err != nil {
167+ table := components.NewTable(
168+ components.WithColumns(imagesListTableColumns),
169+ components.WithRows(rows),
170+ components.WithHeaderStyle(lipgloss.NewStyle().Bold(true)),
171+ components.WithCellStyle(lipgloss.NewStyle()),
172+ )
173+
174+ if err := cliWriteLine(out, table.View()); err != nil {
113175 return err
114176 }
115177
116- _, err = fmt.Fprintf(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount)
178+ err = cliWritef(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount)
117179 return err
118180 }
119181
120182 func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOptions, out io.Writer) error {
121- if opts.KeepLast < 0 {
122- return fmt.Errorf("--keep must be >= 0")
183+ if opts.KeepReleases < 0 {
184+ return fmt.Errorf("--keep-releases must be >= 0")
123185 }
124186
125- keepLast := opts.KeepLast
126- if opts.RuntimeOnly {
127- keepLast = 0
128- }
187+ pruneDangling, pruneRegistry := resolvePruneScopes(opts)
129188
130189 if opts.DryRun {
190+ return runImagesPruneDryRun(ctx, client, opts, pruneDangling, pruneRegistry, out)
191+ }
192+
193+ // Confirmation prompt for destructive operations.
194+ if !opts.NoConfirm {
195+ confirmed, err := pruneConfirmFunc("Prune images? This cannot be undone.")
196+ if err != nil {
197+ return fmt.Errorf("confirmation failed: %w", err)
198+ }
199+ if !confirmed {
200+ return cliWriteLine(out, cliRenderMuted("Prune cancelled"))
201+ }
202+ }
203+
204+ req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry)
205+
206+ resp, err := client.PruneImages(ctx, req)
207+ if err != nil {
208+ return fmt.Errorf("failed to prune images: %w", err)
209+ }
210+ if resp == nil {
211+ return fmt.Errorf("failed to prune images: empty response")
212+ }
213+
214+ if pruneDangling {
215+ if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil {
216+ return err
217+ }
218+ } else {
219+ if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil {
220+ return err
221+ }
222+ }
223+
224+ if pruneRegistry {
225+ err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed)
226+ return err
227+ }
228+
229+ err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))
230+ return err
231+}
232+
233+func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesPruneOptions, pruneDangling, pruneRegistry bool, out io.Writer) error {
234+ if err := cliWriteLine(out, cliRenderWarning("Dry run: no changes applied")); err != nil {
235+ return err
236+ }
237+
238+ if pruneDangling {
131239 images, err := client.ListImages(ctx)
132240 if err != nil {
133241 return fmt.Errorf("failed to list images: %w", err)
@@ -140,44 +248,28 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
140248 }
141249 }
142250
143- if _, err := fmt.Fprintln(out, "Dry run: no changes applied"); err != nil {
144- return err
145- }
146- if _, err := fmt.Fprintf(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil {
251+ if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil {
147252 return err
148253 }
149- if opts.RuntimeOnly {
150- _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)")
254+ } else {
255+ if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil {
151256 return err
152257 }
153- if keepLast == 0 {
154- _, err = fmt.Fprintln(out, "Registry cleanup skipped (--keep=0)")
155- return err
156- }
157-
158- _, err = fmt.Fprintf(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast)
159- return err
160258 }
161259
162- resp, err := client.PruneImages(ctx, keepLast)
163- if err != nil {
164- return fmt.Errorf("failed to prune images: %w", err)
165- }
166- if resp == nil {
167- return fmt.Errorf("failed to prune images: empty response")
260+ if pruneRegistry {
261+ return cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", opts.KeepReleases)
168262 }
169263
170- if _, err := fmt.Fprintf(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil {
171- return err
172- }
264+ return cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))
265+}
173266
174- if opts.RuntimeOnly {
175- _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)")
176- return err
267+func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest {
268+ return dto.ImagePruneRequest{
269+ KeepLast: &keepReleases,
270+ PruneDangling: &pruneDangling,
271+ PruneRegistry: &pruneRegistry,
177272 }
178-
179- _, err = fmt.Fprintf(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed)
180- return err
181273 }
182274
183275 func formatImageCreatedAt(t time.Time) string {
@@ -5,23 +5,57 @@ import (
5 "fmt"5 "fmt"
6 "io"6 "io"
7 "strings"7 "strings"
8- "text/tabwriter"
9 "time"8 "time"
10 9
10+ "github.com/charmbracelet/lipgloss"
11 "github.com/spf13/cobra"11 "github.com/spf13/cobra"
12 12
13 "github.com/bnema/gordon/internal/adapters/dto"13 "github.com/bnema/gordon/internal/adapters/dto"
14+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
15+ "github.com/bnema/gordon/internal/domain"
14 )16 )
15 17
16 type imagesClient interface {18 type imagesClient interface {
17 ListImages(ctx context.Context) ([]dto.Image, error)19 ListImages(ctx context.Context) ([]dto.Image, error)
18- PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error)20+ PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error)
19 }21 }
20 22
21 type imagesPruneOptions struct {23 type imagesPruneOptions struct {
22- DryRun bool24+ DryRun bool
23- KeepLast int25+ KeepReleases int
24- RuntimeOnly bool26+ Dangling bool // scope flag: prune dangling runtime images
27+ Registry bool // scope flag: prune registry tags
28+ NoConfirm bool
29+}
30+
31+// pruneConfirmFunc is the confirmation callback used before destructive prune.
32+// It is a package-level variable so tests can replace it without spawning a
33+// real Bubble Tea program.
34+var pruneConfirmFunc = defaultPruneConfirm
35+
36+func defaultPruneConfirm(prompt string) (bool, error) {
37+ return components.RunConfirm(prompt, components.WithDefaultYes())
38+}
39+
40+// Column widths are fixed to keep table output predictable across terminals.
41+// Values are chosen for typical repository names and SHA-prefixed IDs;
42+// the table component truncates with ellipsis when content overflows.
43+const (
44+ imagesListRepositoryColumnWidth = 44
45+ imagesListTagColumnWidth = 14
46+ imagesListSizeColumnWidth = 10
47+ imagesListCreatedColumnWidth = 22
48+ imagesListImageIDColumnWidth = 22
49+ imagesListDanglingColumnWidth = 10
50+)
51+
52+var imagesListTableColumns = []components.TableColumn{
53+ {Title: "REPOSITORY", Width: imagesListRepositoryColumnWidth},
54+ {Title: "TAG", Width: imagesListTagColumnWidth},
55+ {Title: "SIZE", Width: imagesListSizeColumnWidth},
56+ {Title: "CREATED", Width: imagesListCreatedColumnWidth},
57+ {Title: "IMAGE_ID", Width: imagesListImageIDColumnWidth},
58+ {Title: "DANGLING", Width: imagesListDanglingColumnWidth},
25 }59 }
26 60
27 func newImagesCmd() *cobra.Command {61 func newImagesCmd() *cobra.Command {
@@ -60,6 +94,10 @@ func newImagesPruneCmd() *cobra.Command {
60 cmd := &cobra.Command{94 cmd := &cobra.Command{
61 Use: "prune",95 Use: "prune",
62 Short: "Prune unused images",96 Short: "Prune unused images",
97+ Long: `Remove dangling runtime images and/or old registry tags.
98+
99+By default both runtime and registry cleanup run, keeping latest + 3 previous
100+release tags per repository. Use --dangling or --registry to restrict scope.`,
63 RunE: func(cmd *cobra.Command, _ []string) error {101 RunE: func(cmd *cobra.Command, _ []string) error {
64 client, isRemote := GetRemoteClient()102 client, isRemote := GetRemoteClient()
65 if !isRemote {103 if !isRemote {
@@ -71,12 +109,24 @@ func newImagesPruneCmd() *cobra.Command {
71 }109 }
72 110
73 cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes")111 cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes")
74- cmd.Flags().IntVar(&opts.KeepLast, "keep", 0, "Number of previous version tags to keep per repository (latest is always kept when present; 0 disables registry cleanup)")112+ cmd.Flags().IntVar(&opts.KeepReleases, "keep-releases", domain.DefaultImagePruneKeepLast,
75- cmd.Flags().BoolVar(&opts.RuntimeOnly, "runtime-only", false, "Prune dangling runtime images only (skip registry cleanup)")113+ "Number of previous non-latest tags to keep per repository (latest is always kept)")
114+ cmd.Flags().BoolVar(&opts.Dangling, "dangling", false, "Include dangling runtime images (default: both scopes)")
115+ cmd.Flags().BoolVar(&opts.Registry, "registry", false, "Include old registry tags (default: both scopes)")
116+ cmd.Flags().BoolVar(&opts.NoConfirm, "no-confirm", false, "Skip confirmation prompt")
76 117
77 return cmd118 return cmd
78 }119 }
79 120
121+// resolvePruneScopes determines which prune subsystems are active.
122+// No scope flags → both true. Any scope flag present → only selected scopes.
123+func resolvePruneScopes(opts imagesPruneOptions) (pruneDangling, pruneRegistry bool) {
124+ if !opts.Dangling && !opts.Registry {
125+ return true, true
126+ }
127+ return opts.Dangling, opts.Registry
128+}
129+
80 func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error {130 func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error {
81 images, err := client.ListImages(ctx)131 images, err := client.ListImages(ctx)
82 if err != nil {132 if err != nil {
@@ -84,19 +134,19 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro
84 }134 }
85 135
86 if len(images) == 0 {136 if len(images) == 0 {
87- if _, err := fmt.Fprintln(out, "No images found"); err != nil {137+ if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil {
88 return err138 return err
89 }139 }
90- _, err := fmt.Fprintln(out, "Total images: 0 (dangling: 0)")140+ err := cliWriteLine(out, cliRenderInfo("Total images: 0 (dangling: 0)"))
91 return err141 return err
92 }142 }
93 143
94- w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)144+ if err := cliWriteLine(out, cliRenderTitle("Images")); err != nil {
95- if _, err := fmt.Fprintln(w, "REPOSITORY\tTAG\tSIZE\tCREATED\tIMAGE_ID\tDANGLING"); err != nil {
96 return err145 return err
97 }146 }
98 147
99 danglingCount := 0148 danglingCount := 0
149+ rows := make([][]string, 0, len(images))
100 for _, img := range images {150 for _, img := range images {
101 dangling := "no"151 dangling := "no"
102 if img.Dangling {152 if img.Dangling {
@@ -104,30 +154,88 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro
104 danglingCount++154 danglingCount++
105 }155 }
106 156
107- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", img.Repository, img.Tag, formatImageSize(img.Size), formatImageCreatedAt(img.Created), formatImageID(img.ID), dangling); err != nil {157+ rows = append(rows, []string{
108- return err158+ img.Repository,
109- }159+ img.Tag,
160+ formatImageSize(img.Size),
161+ formatImageCreatedAt(img.Created),
162+ formatImageID(img.ID),
163+ dangling,
164+ })
110 }165 }
111 166
112- if err := w.Flush(); err != nil {167+ table := components.NewTable(
168+ components.WithColumns(imagesListTableColumns),
169+ components.WithRows(rows),
170+ components.WithHeaderStyle(lipgloss.NewStyle().Bold(true)),
171+ components.WithCellStyle(lipgloss.NewStyle()),
172+ )
173+
174+ if err := cliWriteLine(out, table.View()); err != nil {
113 return err175 return err
114 }176 }
115 177
116- _, err = fmt.Fprintf(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount)178+ err = cliWritef(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount)
117 return err179 return err
118 }180 }
119 181
120 func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOptions, out io.Writer) error {182 func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOptions, out io.Writer) error {
121- if opts.KeepLast < 0 {183+ if opts.KeepReleases < 0 {
122- return fmt.Errorf("--keep must be >= 0")184+ return fmt.Errorf("--keep-releases must be >= 0")
123 }185 }
124 186
125- keepLast := opts.KeepLast187+ pruneDangling, pruneRegistry := resolvePruneScopes(opts)
126- if opts.RuntimeOnly {
127- keepLast = 0
128- }
129 188
130 if opts.DryRun {189 if opts.DryRun {
190+ return runImagesPruneDryRun(ctx, client, opts, pruneDangling, pruneRegistry, out)
191+ }
192+
193+ // Confirmation prompt for destructive operations.
194+ if !opts.NoConfirm {
195+ confirmed, err := pruneConfirmFunc("Prune images? This cannot be undone.")
196+ if err != nil {
197+ return fmt.Errorf("confirmation failed: %w", err)
198+ }
199+ if !confirmed {
200+ return cliWriteLine(out, cliRenderMuted("Prune cancelled"))
201+ }
202+ }
203+
204+ req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry)
205+
206+ resp, err := client.PruneImages(ctx, req)
207+ if err != nil {
208+ return fmt.Errorf("failed to prune images: %w", err)
209+ }
210+ if resp == nil {
211+ return fmt.Errorf("failed to prune images: empty response")
212+ }
213+
214+ if pruneDangling {
215+ if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil {
216+ return err
217+ }
218+ } else {
219+ if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil {
220+ return err
221+ }
222+ }
223+
224+ if pruneRegistry {
225+ err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed)
226+ return err
227+ }
228+
229+ err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))
230+ return err
231+}
232+
233+func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesPruneOptions, pruneDangling, pruneRegistry bool, out io.Writer) error {
234+ if err := cliWriteLine(out, cliRenderWarning("Dry run: no changes applied")); err != nil {
235+ return err
236+ }
237+
238+ if pruneDangling {
131 images, err := client.ListImages(ctx)239 images, err := client.ListImages(ctx)
132 if err != nil {240 if err != nil {
133 return fmt.Errorf("failed to list images: %w", err)241 return fmt.Errorf("failed to list images: %w", err)
@@ -140,44 +248,28 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
140 }248 }
141 }249 }
142 250
143- if _, err := fmt.Fprintln(out, "Dry run: no changes applied"); err != nil {251+ if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil {
144- return err
145- }
146- if _, err := fmt.Fprintf(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil {
147 return err252 return err
148 }253 }
149- if opts.RuntimeOnly {254+ } else {
150- _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)")255+ if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil {
151 return err256 return err
152 }257 }
153- if keepLast == 0 {
154- _, err = fmt.Fprintln(out, "Registry cleanup skipped (--keep=0)")
155- return err
156- }
157-
158- _, err = fmt.Fprintf(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast)
159- return err
160 }258 }
161 259
162- resp, err := client.PruneImages(ctx, keepLast)260+ if pruneRegistry {
163- if err != nil {261+ return cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", opts.KeepReleases)
164- return fmt.Errorf("failed to prune images: %w", err)
165- }
166- if resp == nil {
167- return fmt.Errorf("failed to prune images: empty response")
168 }262 }
169 263
170- if _, err := fmt.Fprintf(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil {264+ return cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))
171- return err265+}
172- }
173 266
174- if opts.RuntimeOnly {267+func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest {
175- _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)")268+ return dto.ImagePruneRequest{
176- return err269+ KeepLast: &keepReleases,
270+ PruneDangling: &pruneDangling,
271+ PruneRegistry: &pruneRegistry,
177 }272 }
178-
179- _, err = fmt.Fprintf(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed)
180- return err
181 }273 }
182 274
183 func formatImageCreatedAt(t time.Time) string {275 func formatImageCreatedAt(t time.Time) string {
M internal/adapters/in/cli/images_test.go +439 -31
@@ -4,6 +4,8 @@ import (
44 "bytes"
55 "context"
66 "errors"
7+ "regexp"
8+ "strings"
79 "testing"
810 "time"
911
@@ -11,6 +13,7 @@ import (
1113 "github.com/stretchr/testify/require"
1214
1315 "github.com/bnema/gordon/internal/adapters/dto"
16+ "github.com/bnema/gordon/internal/domain"
1417 )
1518
1619 type fakeImagesClient struct {
@@ -21,7 +24,7 @@ type fakeImagesClient struct {
2124
2225 listImagesCalls int
2326 pruneCalls int
24- lastPruneKeep int
27+ lastPruneOpts dto.ImagePruneRequest
2528 }
2629
2730 func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) {
@@ -32,15 +35,19 @@ func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) {
3235 return f.listImagesResp, nil
3336 }
3437
35-func (f *fakeImagesClient) PruneImages(_ context.Context, keepLast int) (*dto.ImagePruneResponse, error) {
38+func (f *fakeImagesClient) PruneImages(_ context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
3639 f.pruneCalls++
37- f.lastPruneKeep = keepLast
40+ f.lastPruneOpts = req
3841 if f.pruneErr != nil {
3942 return nil, f.pruneErr
4043 }
4144 return f.pruneResp, nil
4245 }
4346
47+// ---------------------------------------------------------------------------
48+// images list tests (unchanged)
49+// ---------------------------------------------------------------------------
50+
4451 func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
4552 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
4653 client := &fakeImagesClient{
@@ -62,6 +69,110 @@ func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
6269 assert.Equal(t, 1, client.listImagesCalls)
6370 }
6471
72+func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) {
73+ createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
74+ client := &fakeImagesClient{
75+ listImagesResp: []dto.Image{
76+ {Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false},
77+ },
78+ }
79+
80+ var out bytes.Buffer
81+ err := runImagesList(context.Background(), client, &out)
82+ require.NoError(t, err)
83+
84+ text := stripANSI(out.String())
85+ header := findHeaderLine(text)
86+ assert.NotEmpty(t, header)
87+ assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG"))
88+ assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE"))
89+ assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED"))
90+ assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID"))
91+ assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING"))
92+ assert.Contains(t, text, "Total images: 1 (dangling: 0)")
93+}
94+
95+func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T) {
96+ createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
97+ longRepository := "registry.example.com/team/this-is-a-very-long-service-name-with-extra-segments/and/more/segments/than/anyone/should/reasonably/use"
98+ longImageID := "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra-suffix-for-overflow"
99+ client := &fakeImagesClient{
100+ listImagesResp: []dto.Image{
101+ {Repository: longRepository, Tag: "latest", Size: 12_000_000, Created: createdAt, ID: longImageID, Dangling: false},
102+ },
103+ }
104+
105+ var out bytes.Buffer
106+ err := runImagesList(context.Background(), client, &out)
107+ require.NoError(t, err)
108+
109+ text := stripANSI(out.String())
110+ header := findHeaderLine(text)
111+ assert.NotEmpty(t, header)
112+ assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG"))
113+ assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE"))
114+ assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED"))
115+ assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID"))
116+ assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING"))
117+
118+ row := findImageListRowLine(text, "latest")
119+ assert.NotEmpty(t, row)
120+
121+ cols := splitTableColumns(row)
122+ require.Len(t, cols, 6)
123+
124+ repositoryField := cols[0]
125+ imageIDField := cols[4]
126+
127+ assert.NotEqual(t, longRepository, repositoryField)
128+ assert.NotEqual(t, longImageID, imageIDField)
129+ assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis")
130+ assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis")
131+ assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "...")))
132+ assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "...")))
133+
134+ assert.Contains(t, text, "Total images: 1 (dangling: 0)")
135+}
136+
137+func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) {
138+ longRepository := "registry.example.com/very/long/repository/path/with/deep/nesting/and-digest-like-ref@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra"
139+ longTag := "release-2026-02-12-build-metadata-with-a-very-very-long-tag-name"
140+ longImageID := "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
141+ client := &fakeImagesClient{
142+ listImagesResp: []dto.Image{
143+ {Repository: longRepository, Tag: longTag, Size: 0, Created: time.Time{}, ID: longImageID, Dangling: true},
144+ },
145+ }
146+
147+ var out bytes.Buffer
148+ err := runImagesList(context.Background(), client, &out)
149+ require.NoError(t, err)
150+
151+ text := stripANSI(out.String())
152+ rows := findImageListRows(text)
153+ require.Len(t, rows, 1, "expected a single, no-wrap data row")
154+
155+ cols := rows[0]
156+ require.Len(t, cols, 6)
157+
158+ repositoryField := cols[0]
159+ tagField := cols[1]
160+ sizeField := cols[2]
161+ createdField := cols[3]
162+ imageIDField := cols[4]
163+
164+ assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis")
165+ assert.True(t, strings.HasSuffix(tagField, "..."), "tag should be truncated with ellipsis")
166+ assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis")
167+ assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "...")))
168+ assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "...")))
169+ assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "...")))
170+
171+ assert.Equal(t, "-", sizeField)
172+ assert.Equal(t, "-", createdField)
173+ assert.Contains(t, text, "Total images: 1 (dangling: 1)")
174+}
175+
65176 func TestRunImagesList_EmptyOutput(t *testing.T) {
66177 client := &fakeImagesClient{listImagesResp: []dto.Image{}}
67178
@@ -75,13 +186,123 @@ func TestRunImagesList_EmptyOutput(t *testing.T) {
75186 assert.Equal(t, 1, client.listImagesCalls)
76187 }
77188
78-func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) {
189+// ---------------------------------------------------------------------------
190+// images prune: default scope behavior
191+// ---------------------------------------------------------------------------
192+
193+func TestRunImagesPrune_DefaultScopeIsBothDanglingAndRegistry(t *testing.T) {
194+ // No scope flags → both scopes enabled, keep-releases defaults to domain default.
195+ client := &fakeImagesClient{
196+ pruneResp: &dto.ImagePruneResponse{
197+ Runtime: dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 512},
198+ Registry: dto.RegistryPruneResult{TagsRemoved: 2, BlobsRemoved: 1, SpaceReclaimed: 1024},
199+ },
200+ }
201+
202+ var out bytes.Buffer
203+ opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true}
204+ err := runImagesPrune(context.Background(), client, opts, &out)
205+ require.NoError(t, err)
206+
207+ assert.Equal(t, 1, client.pruneCalls)
208+ require.NotNil(t, client.lastPruneOpts.KeepLast)
209+ assert.Equal(t, domain.DefaultImagePruneKeepLast, *client.lastPruneOpts.KeepLast)
210+ require.NotNil(t, client.lastPruneOpts.PruneDangling)
211+ assert.True(t, *client.lastPruneOpts.PruneDangling)
212+ require.NotNil(t, client.lastPruneOpts.PruneRegistry)
213+ assert.True(t, *client.lastPruneOpts.PruneRegistry)
214+
215+ text := out.String()
216+ assert.Contains(t, text, "Runtime: deleted=1")
217+ assert.Contains(t, text, "Registry: tags_removed=2")
218+}
219+
220+func TestRunImagesPrune_DanglingOnlyScopeSkipsRegistry(t *testing.T) {
221+ client := &fakeImagesClient{
222+ pruneResp: &dto.ImagePruneResponse{
223+ Runtime: dto.RuntimePruneResult{DeletedCount: 3, SpaceReclaimed: 2048},
224+ },
225+ }
226+
227+ var out bytes.Buffer
228+ opts := imagesPruneOptions{Dangling: true, NoConfirm: true}
229+ err := runImagesPrune(context.Background(), client, opts, &out)
230+ require.NoError(t, err)
231+
232+ assert.Equal(t, 1, client.pruneCalls)
233+ require.NotNil(t, client.lastPruneOpts.PruneDangling)
234+ assert.True(t, *client.lastPruneOpts.PruneDangling)
235+ require.NotNil(t, client.lastPruneOpts.PruneRegistry)
236+ assert.False(t, *client.lastPruneOpts.PruneRegistry)
237+
238+ text := out.String()
239+ assert.Contains(t, text, "Runtime: deleted=3")
240+ assert.Contains(t, text, "Registry cleanup skipped")
241+}
242+
243+func TestRunImagesPrune_RegistryOnlyScopeSkipsRuntime(t *testing.T) {
244+ client := &fakeImagesClient{
245+ pruneResp: &dto.ImagePruneResponse{
246+ Registry: dto.RegistryPruneResult{TagsRemoved: 4, BlobsRemoved: 2, SpaceReclaimed: 4096},
247+ },
248+ }
249+
250+ var out bytes.Buffer
251+ opts := imagesPruneOptions{Registry: true, KeepReleases: 5, NoConfirm: true}
252+ err := runImagesPrune(context.Background(), client, opts, &out)
253+ require.NoError(t, err)
254+
255+ assert.Equal(t, 1, client.pruneCalls)
256+ require.NotNil(t, client.lastPruneOpts.PruneDangling)
257+ assert.False(t, *client.lastPruneOpts.PruneDangling)
258+ require.NotNil(t, client.lastPruneOpts.PruneRegistry)
259+ assert.True(t, *client.lastPruneOpts.PruneRegistry)
260+ require.NotNil(t, client.lastPruneOpts.KeepLast)
261+ assert.Equal(t, 5, *client.lastPruneOpts.KeepLast)
262+
263+ text := out.String()
264+ assert.Contains(t, text, "Runtime cleanup skipped")
265+ assert.Contains(t, text, "Registry: tags_removed=4")
266+}
267+
268+func TestRunImagesPrune_BothScopeFlagsExplicit(t *testing.T) {
269+ client := &fakeImagesClient{
270+ pruneResp: &dto.ImagePruneResponse{
271+ Runtime: dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 100},
272+ Registry: dto.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 0, SpaceReclaimed: 200},
273+ },
274+ }
275+
276+ var out bytes.Buffer
277+ opts := imagesPruneOptions{Dangling: true, Registry: true, KeepReleases: 2, NoConfirm: true}
278+ err := runImagesPrune(context.Background(), client, opts, &out)
279+ require.NoError(t, err)
280+
281+ assert.Equal(t, 1, client.pruneCalls)
282+ require.NotNil(t, client.lastPruneOpts.PruneDangling)
283+ assert.True(t, *client.lastPruneOpts.PruneDangling)
284+ require.NotNil(t, client.lastPruneOpts.PruneRegistry)
285+ assert.True(t, *client.lastPruneOpts.PruneRegistry)
286+ require.NotNil(t, client.lastPruneOpts.KeepLast)
287+ assert.Equal(t, 2, *client.lastPruneOpts.KeepLast)
288+
289+ text := out.String()
290+ assert.Contains(t, text, "Runtime: deleted=1")
291+ assert.Contains(t, text, "Registry: tags_removed=1")
292+}
293+
294+// ---------------------------------------------------------------------------
295+// images prune: dry-run with scope flags
296+// ---------------------------------------------------------------------------
297+
298+func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {
79299 client := &fakeImagesClient{
80300 listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}},
81301 }
82302
83303 var out bytes.Buffer
84- err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 3}, &out)
304+ opts := imagesPruneOptions{DryRun: true, KeepReleases: 3}
305+ err := runImagesPrune(context.Background(), client, opts, &out)
85306 require.NoError(t, err)
86307
87308 text := out.String()
@@ -92,67 +313,254 @@ func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) {
92313 assert.Equal(t, 0, client.pruneCalls)
93314 }
94315
95-func TestRunImagesPrune_DryRunKeepZeroSkipsRegistry(t *testing.T) {
96- client := &fakeImagesClient{listImagesResp: []dto.Image{{Dangling: true}}}
316+func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {
317+ client := &fakeImagesClient{
318+ listImagesResp: []dto.Image{{Dangling: true}},
319+ }
97320
98321 var out bytes.Buffer
99- err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 0}, &out)
322+ opts := imagesPruneOptions{DryRun: true, Dangling: true}
323+ err := runImagesPrune(context.Background(), client, opts, &out)
100324 require.NoError(t, err)
101325
102326 text := out.String()
103- assert.Contains(t, text, "Registry cleanup skipped (--keep=0)")
104- assert.Equal(t, 1, client.listImagesCalls)
327+ assert.Contains(t, text, "would prune 1 dangling runtime images")
328+ assert.Contains(t, text, "Registry cleanup skipped")
105329 assert.Equal(t, 0, client.pruneCalls)
106330 }
107331
108-func TestRunImagesPrune_RuntimeOnlyForcesKeepZero(t *testing.T) {
109- client := &fakeImagesClient{pruneResp: &dto.ImagePruneResponse{}}
332+func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {
333+ client := &fakeImagesClient{
334+ listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}},
335+ }
336+
337+ var out bytes.Buffer
338+ opts := imagesPruneOptions{DryRun: true, Registry: true, KeepReleases: 5}
339+ err := runImagesPrune(context.Background(), client, opts, &out)
340+ require.NoError(t, err)
341+
342+ text := out.String()
343+ assert.Contains(t, text, "Runtime cleanup skipped")
344+ assert.Contains(t, text, "would keep latest + 5 previous tags")
345+ assert.Equal(t, 0, client.pruneCalls)
346+}
347+
348+// ---------------------------------------------------------------------------
349+// images prune: keep-releases semantics
350+// ---------------------------------------------------------------------------
351+
352+func TestRunImagesPrune_KeepReleasesControlsRetention(t *testing.T) {
353+ client := &fakeImagesClient{
354+ pruneResp: &dto.ImagePruneResponse{
355+ Runtime: dto.RuntimePruneResult{DeletedCount: 0},
356+ Registry: dto.RegistryPruneResult{TagsRemoved: 7, BlobsRemoved: 3, SpaceReclaimed: 9000},
357+ },
358+ }
110359
111360 var out bytes.Buffer
112- err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 9, RuntimeOnly: true}, &out)
361+ opts := imagesPruneOptions{KeepReleases: 10, NoConfirm: true}
362+ err := runImagesPrune(context.Background(), client, opts, &out)
113363 require.NoError(t, err)
114364
365+ require.NotNil(t, client.lastPruneOpts.KeepLast)
366+ assert.Equal(t, 10, *client.lastPruneOpts.KeepLast)
367+}
368+
369+func TestRunImagesPrune_RejectsNegativeKeepReleases(t *testing.T) {
370+ err := runImagesPrune(context.Background(), &fakeImagesClient{}, imagesPruneOptions{KeepReleases: -1}, &bytes.Buffer{})
371+ require.Error(t, err)
372+ assert.Contains(t, err.Error(), "--keep-releases must be >= 0")
373+}
374+
375+// ---------------------------------------------------------------------------
376+// images prune: confirmation behavior
377+// ---------------------------------------------------------------------------
378+
379+func TestRunImagesPrune_NonDryRunCallsConfirm(t *testing.T) {
380+ confirmCalled := false
381+ origConfirm := pruneConfirmFunc
382+ pruneConfirmFunc = func(_ string) (bool, error) {
383+ confirmCalled = true
384+ return true, nil
385+ }
386+ t.Cleanup(func() { pruneConfirmFunc = origConfirm })
387+
388+ client := &fakeImagesClient{
389+ pruneResp: &dto.ImagePruneResponse{
390+ Runtime: dto.RuntimePruneResult{DeletedCount: 1},
391+ Registry: dto.RegistryPruneResult{TagsRemoved: 1},
392+ },
393+ }
394+
395+ var out bytes.Buffer
396+ opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast}
397+ err := runImagesPrune(context.Background(), client, opts, &out)
398+ require.NoError(t, err)
399+
400+ assert.True(t, confirmCalled, "confirmation should have been called")
115401 assert.Equal(t, 1, client.pruneCalls)
116- assert.Equal(t, 0, client.lastPruneKeep)
117- assert.Contains(t, out.String(), "Registry cleanup skipped")
118402 }
119403
120-func TestRunImagesPrune_UsesKeepFlagWhenNotRuntimeOnly(t *testing.T) {
404+func TestRunImagesPrune_ConfirmRejectCancelsOperation(t *testing.T) {
405+ origConfirm := pruneConfirmFunc
406+ pruneConfirmFunc = func(_ string) (bool, error) {
407+ return false, nil
408+ }
409+ t.Cleanup(func() { pruneConfirmFunc = origConfirm })
410+
411+ client := &fakeImagesClient{pruneResp: &dto.ImagePruneResponse{}}
412+
413+ var out bytes.Buffer
414+ opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast}
415+ err := runImagesPrune(context.Background(), client, opts, &out)
416+ require.NoError(t, err)
417+
418+ assert.Equal(t, 0, client.pruneCalls, "prune should not be called after rejection")
419+ assert.Contains(t, out.String(), "cancelled")
420+}
421+
422+func TestRunImagesPrune_NoConfirmSkipsPrompt(t *testing.T) {
423+ confirmCalled := false
424+ origConfirm := pruneConfirmFunc
425+ pruneConfirmFunc = func(_ string) (bool, error) {
426+ confirmCalled = true
427+ return true, nil
428+ }
429+ t.Cleanup(func() { pruneConfirmFunc = origConfirm })
430+
121431 client := &fakeImagesClient{
122432 pruneResp: &dto.ImagePruneResponse{
123- Runtime: dto.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
124- Registry: dto.RegistryPruneResult{TagsRemoved: 3, BlobsRemoved: 1, SpaceReclaimed: 8192},
433+ Runtime: dto.RuntimePruneResult{DeletedCount: 1},
125434 },
126435 }
127436
128437 var out bytes.Buffer
129- err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 5}, &out)
438+ opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true}
439+ err := runImagesPrune(context.Background(), client, opts, &out)
130440 require.NoError(t, err)
131441
132- text := out.String()
442+ assert.False(t, confirmCalled, "confirmation should not be called with --no-confirm")
133443 assert.Equal(t, 1, client.pruneCalls)
134- assert.Equal(t, 5, client.lastPruneKeep)
135- assert.Contains(t, text, "Runtime: deleted=2")
136- assert.Contains(t, text, "Registry: tags_removed=3")
137444 }
138445
446+func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) {
447+ confirmCalled := false
448+ origConfirm := pruneConfirmFunc
449+ pruneConfirmFunc = func(_ string) (bool, error) {
450+ confirmCalled = true
451+ return true, nil
452+ }
453+ t.Cleanup(func() { pruneConfirmFunc = origConfirm })
454+
455+ client := &fakeImagesClient{
456+ listImagesResp: []dto.Image{{Dangling: true}},
457+ }
458+
459+ var out bytes.Buffer
460+ opts := imagesPruneOptions{DryRun: true, KeepReleases: 3}
461+ err := runImagesPrune(context.Background(), client, opts, &out)
462+ require.NoError(t, err)
463+
464+ assert.False(t, confirmCalled, "confirmation should not be called during dry-run")
465+}
466+
467+// ---------------------------------------------------------------------------
468+// images prune: error handling
469+// ---------------------------------------------------------------------------
470+
139471 func TestRunImagesPrune_ReturnsRemoteErrors(t *testing.T) {
472+ origConfirm := pruneConfirmFunc
473+ pruneConfirmFunc = func(_ string) (bool, error) { return true, nil }
474+ t.Cleanup(func() { pruneConfirmFunc = origConfirm })
475+
140476 client := &fakeImagesClient{pruneErr: errors.New("request failed")}
141477
142- err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 2}, &bytes.Buffer{})
478+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 2}, &bytes.Buffer{})
143479 require.Error(t, err)
144480 assert.Contains(t, err.Error(), "failed to prune images")
145481 }
146482
147-func TestRunImagesPrune_RejectsNegativeKeep(t *testing.T) {
148- err := runImagesPrune(context.Background(), &fakeImagesClient{}, imagesPruneOptions{KeepLast: -1}, &bytes.Buffer{})
149- require.Error(t, err)
150- assert.Contains(t, err.Error(), "--keep must be >= 0")
151-}
152-
153483 func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) {
484+ origConfirm := pruneConfirmFunc
485+ pruneConfirmFunc = func(_ string) (bool, error) { return true, nil }
486+ t.Cleanup(func() { pruneConfirmFunc = origConfirm })
487+
154488 client := &fakeImagesClient{pruneResp: nil}
155- err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 1}, &bytes.Buffer{})
489+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 1}, &bytes.Buffer{})
156490 require.Error(t, err)
157491 assert.Contains(t, err.Error(), "empty response")
158492 }
493+
494+// ---------------------------------------------------------------------------
495+// test helpers
496+// ---------------------------------------------------------------------------
497+
498+func stripANSI(value string) string {
499+ ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
500+ return ansiRegex.ReplaceAllString(value, "")
501+}
502+
503+func findHeaderLine(text string) string {
504+ for _, line := range strings.Split(text, "\n") {
505+ if strings.Contains(line, "REPOSITORY") && strings.Contains(line, "DANGLING") {
506+ return line
507+ }
508+ }
509+
510+ return ""
511+}
512+
513+func findImageListRowLine(text string, tag string) string {
514+ for _, line := range strings.Split(text, "\n") {
515+ trimmed := strings.TrimSpace(line)
516+ if trimmed == "" {
517+ continue
518+ }
519+ if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") {
520+ continue
521+ }
522+
523+ cols := splitTableColumns(line)
524+ if len(cols) == 6 && cols[1] == tag {
525+ return line
526+ }
527+ }
528+
529+ return ""
530+}
531+
532+func findImageListRows(text string) [][]string {
533+ rows := make([][]string, 0)
534+ for _, line := range strings.Split(text, "\n") {
535+ trimmed := strings.TrimSpace(line)
536+ if trimmed == "" {
537+ continue
538+ }
539+ if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") {
540+ continue
541+ }
542+
543+ cols := splitTableColumns(line)
544+ if len(cols) == 6 && cols[0] != "REPOSITORY" {
545+ rows = append(rows, cols)
546+ }
547+ }
548+
549+ return rows
550+}
551+
552+func splitTableColumns(line string) []string {
553+ if strings.Contains(line, "│") {
554+ parts := strings.Split(line, "│")
555+ cols := make([]string, 0, len(parts))
556+ for _, part := range parts {
557+ cell := strings.TrimSpace(part)
558+ if cell != "" {
559+ cols = append(cols, cell)
560+ }
561+ }
562+ return cols
563+ }
564+
565+ return regexp.MustCompile(`\s{2,}`).Split(strings.TrimSpace(line), -1)
566+}
@@ -4,6 +4,8 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "errors"6 "errors"
7+ "regexp"
8+ "strings"
7 "testing"9 "testing"
8 "time"10 "time"
9 11
@@ -11,6 +13,7 @@ import (
11 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
12 14
13 "github.com/bnema/gordon/internal/adapters/dto"15 "github.com/bnema/gordon/internal/adapters/dto"
16+ "github.com/bnema/gordon/internal/domain"
14 )17 )
15 18
16 type fakeImagesClient struct {19 type fakeImagesClient struct {
@@ -21,7 +24,7 @@ type fakeImagesClient struct {
21 24
22 listImagesCalls int25 listImagesCalls int
23 pruneCalls int26 pruneCalls int
24- lastPruneKeep int27+ lastPruneOpts dto.ImagePruneRequest
25 }28 }
26 29
27 func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) {30 func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) {
@@ -32,15 +35,19 @@ func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) {
32 return f.listImagesResp, nil35 return f.listImagesResp, nil
33 }36 }
34 37
35-func (f *fakeImagesClient) PruneImages(_ context.Context, keepLast int) (*dto.ImagePruneResponse, error) {38+func (f *fakeImagesClient) PruneImages(_ context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
36 f.pruneCalls++39 f.pruneCalls++
37- f.lastPruneKeep = keepLast40+ f.lastPruneOpts = req
38 if f.pruneErr != nil {41 if f.pruneErr != nil {
39 return nil, f.pruneErr42 return nil, f.pruneErr
40 }43 }
41 return f.pruneResp, nil44 return f.pruneResp, nil
42 }45 }
43 46
47+// ---------------------------------------------------------------------------
48+// images list tests (unchanged)
49+// ---------------------------------------------------------------------------
50+
44 func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {51 func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
45 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)52 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
46 client := &fakeImagesClient{53 client := &fakeImagesClient{
@@ -62,6 +69,110 @@ func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
62 assert.Equal(t, 1, client.listImagesCalls)69 assert.Equal(t, 1, client.listImagesCalls)
63 }70 }
64 71
72+func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) {
73+ createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
74+ client := &fakeImagesClient{
75+ listImagesResp: []dto.Image{
76+ {Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false},
77+ },
78+ }
79+
80+ var out bytes.Buffer
81+ err := runImagesList(context.Background(), client, &out)
82+ require.NoError(t, err)
83+
84+ text := stripANSI(out.String())
85+ header := findHeaderLine(text)
86+ assert.NotEmpty(t, header)
87+ assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG"))
88+ assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE"))
89+ assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED"))
90+ assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID"))
91+ assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING"))
92+ assert.Contains(t, text, "Total images: 1 (dangling: 0)")
93+}
94+
95+func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T) {
96+ createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
97+ longRepository := "registry.example.com/team/this-is-a-very-long-service-name-with-extra-segments/and/more/segments/than/anyone/should/reasonably/use"
98+ longImageID := "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra-suffix-for-overflow"
99+ client := &fakeImagesClient{
100+ listImagesResp: []dto.Image{
101+ {Repository: longRepository, Tag: "latest", Size: 12_000_000, Created: createdAt, ID: longImageID, Dangling: false},
102+ },
103+ }
104+
105+ var out bytes.Buffer
106+ err := runImagesList(context.Background(), client, &out)
107+ require.NoError(t, err)
108+
109+ text := stripANSI(out.String())
110+ header := findHeaderLine(text)
111+ assert.NotEmpty(t, header)
112+ assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG"))
113+ assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE"))
114+ assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED"))
115+ assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID"))
116+ assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING"))
117+
118+ row := findImageListRowLine(text, "latest")
119+ assert.NotEmpty(t, row)
120+
121+ cols := splitTableColumns(row)
122+ require.Len(t, cols, 6)
123+
124+ repositoryField := cols[0]
125+ imageIDField := cols[4]
126+
127+ assert.NotEqual(t, longRepository, repositoryField)
128+ assert.NotEqual(t, longImageID, imageIDField)
129+ assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis")
130+ assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis")
131+ assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "...")))
132+ assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "...")))
133+
134+ assert.Contains(t, text, "Total images: 1 (dangling: 0)")
135+}
136+
137+func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) {
138+ longRepository := "registry.example.com/very/long/repository/path/with/deep/nesting/and-digest-like-ref@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra"
139+ longTag := "release-2026-02-12-build-metadata-with-a-very-very-long-tag-name"
140+ longImageID := "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
141+ client := &fakeImagesClient{
142+ listImagesResp: []dto.Image{
143+ {Repository: longRepository, Tag: longTag, Size: 0, Created: time.Time{}, ID: longImageID, Dangling: true},
144+ },
145+ }
146+
147+ var out bytes.Buffer
148+ err := runImagesList(context.Background(), client, &out)
149+ require.NoError(t, err)
150+
151+ text := stripANSI(out.String())
152+ rows := findImageListRows(text)
153+ require.Len(t, rows, 1, "expected a single, no-wrap data row")
154+
155+ cols := rows[0]
156+ require.Len(t, cols, 6)
157+
158+ repositoryField := cols[0]
159+ tagField := cols[1]
160+ sizeField := cols[2]
161+ createdField := cols[3]
162+ imageIDField := cols[4]
163+
164+ assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis")
165+ assert.True(t, strings.HasSuffix(tagField, "..."), "tag should be truncated with ellipsis")
166+ assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis")
167+ assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "...")))
168+ assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "...")))
169+ assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "...")))
170+
171+ assert.Equal(t, "-", sizeField)
172+ assert.Equal(t, "-", createdField)
173+ assert.Contains(t, text, "Total images: 1 (dangling: 1)")
174+}
175+
65 func TestRunImagesList_EmptyOutput(t *testing.T) {176 func TestRunImagesList_EmptyOutput(t *testing.T) {
66 client := &fakeImagesClient{listImagesResp: []dto.Image{}}177 client := &fakeImagesClient{listImagesResp: []dto.Image{}}
67 178
@@ -75,13 +186,123 @@ func TestRunImagesList_EmptyOutput(t *testing.T) {
75 assert.Equal(t, 1, client.listImagesCalls)186 assert.Equal(t, 1, client.listImagesCalls)
76 }187 }
77 188
78-func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) {189+// ---------------------------------------------------------------------------
190+// images prune: default scope behavior
191+// ---------------------------------------------------------------------------
192+
193+func TestRunImagesPrune_DefaultScopeIsBothDanglingAndRegistry(t *testing.T) {
194+ // No scope flags → both scopes enabled, keep-releases defaults to domain default.
195+ client := &fakeImagesClient{
196+ pruneResp: &dto.ImagePruneResponse{
197+ Runtime: dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 512},
198+ Registry: dto.RegistryPruneResult{TagsRemoved: 2, BlobsRemoved: 1, SpaceReclaimed: 1024},
199+ },
200+ }
201+
202+ var out bytes.Buffer
203+ opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true}
204+ err := runImagesPrune(context.Background(), client, opts, &out)
205+ require.NoError(t, err)
206+
207+ assert.Equal(t, 1, client.pruneCalls)
208+ require.NotNil(t, client.lastPruneOpts.KeepLast)
209+ assert.Equal(t, domain.DefaultImagePruneKeepLast, *client.lastPruneOpts.KeepLast)
210+ require.NotNil(t, client.lastPruneOpts.PruneDangling)
211+ assert.True(t, *client.lastPruneOpts.PruneDangling)
212+ require.NotNil(t, client.lastPruneOpts.PruneRegistry)
213+ assert.True(t, *client.lastPruneOpts.PruneRegistry)
214+
215+ text := out.String()
216+ assert.Contains(t, text, "Runtime: deleted=1")
217+ assert.Contains(t, text, "Registry: tags_removed=2")
218+}
219+
220+func TestRunImagesPrune_DanglingOnlyScopeSkipsRegistry(t *testing.T) {
221+ client := &fakeImagesClient{
222+ pruneResp: &dto.ImagePruneResponse{
223+ Runtime: dto.RuntimePruneResult{DeletedCount: 3, SpaceReclaimed: 2048},
224+ },
225+ }
226+
227+ var out bytes.Buffer
228+ opts := imagesPruneOptions{Dangling: true, NoConfirm: true}
229+ err := runImagesPrune(context.Background(), client, opts, &out)
230+ require.NoError(t, err)
231+
232+ assert.Equal(t, 1, client.pruneCalls)
233+ require.NotNil(t, client.lastPruneOpts.PruneDangling)
234+ assert.True(t, *client.lastPruneOpts.PruneDangling)
235+ require.NotNil(t, client.lastPruneOpts.PruneRegistry)
236+ assert.False(t, *client.lastPruneOpts.PruneRegistry)
237+
238+ text := out.String()
239+ assert.Contains(t, text, "Runtime: deleted=3")
240+ assert.Contains(t, text, "Registry cleanup skipped")
241+}
242+
243+func TestRunImagesPrune_RegistryOnlyScopeSkipsRuntime(t *testing.T) {
244+ client := &fakeImagesClient{
245+ pruneResp: &dto.ImagePruneResponse{
246+ Registry: dto.RegistryPruneResult{TagsRemoved: 4, BlobsRemoved: 2, SpaceReclaimed: 4096},
247+ },
248+ }
249+
250+ var out bytes.Buffer
251+ opts := imagesPruneOptions{Registry: true, KeepReleases: 5, NoConfirm: true}
252+ err := runImagesPrune(context.Background(), client, opts, &out)
253+ require.NoError(t, err)
254+
255+ assert.Equal(t, 1, client.pruneCalls)
256+ require.NotNil(t, client.lastPruneOpts.PruneDangling)
257+ assert.False(t, *client.lastPruneOpts.PruneDangling)
258+ require.NotNil(t, client.lastPruneOpts.PruneRegistry)
259+ assert.True(t, *client.lastPruneOpts.PruneRegistry)
260+ require.NotNil(t, client.lastPruneOpts.KeepLast)
261+ assert.Equal(t, 5, *client.lastPruneOpts.KeepLast)
262+
263+ text := out.String()
264+ assert.Contains(t, text, "Runtime cleanup skipped")
265+ assert.Contains(t, text, "Registry: tags_removed=4")
266+}
267+
268+func TestRunImagesPrune_BothScopeFlagsExplicit(t *testing.T) {
269+ client := &fakeImagesClient{
270+ pruneResp: &dto.ImagePruneResponse{
271+ Runtime: dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 100},
272+ Registry: dto.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 0, SpaceReclaimed: 200},
273+ },
274+ }
275+
276+ var out bytes.Buffer
277+ opts := imagesPruneOptions{Dangling: true, Registry: true, KeepReleases: 2, NoConfirm: true}
278+ err := runImagesPrune(context.Background(), client, opts, &out)
279+ require.NoError(t, err)
280+
281+ assert.Equal(t, 1, client.pruneCalls)
282+ require.NotNil(t, client.lastPruneOpts.PruneDangling)
283+ assert.True(t, *client.lastPruneOpts.PruneDangling)
284+ require.NotNil(t, client.lastPruneOpts.PruneRegistry)
285+ assert.True(t, *client.lastPruneOpts.PruneRegistry)
286+ require.NotNil(t, client.lastPruneOpts.KeepLast)
287+ assert.Equal(t, 2, *client.lastPruneOpts.KeepLast)
288+
289+ text := out.String()
290+ assert.Contains(t, text, "Runtime: deleted=1")
291+ assert.Contains(t, text, "Registry: tags_removed=1")
292+}
293+
294+// ---------------------------------------------------------------------------
295+// images prune: dry-run with scope flags
296+// ---------------------------------------------------------------------------
297+
298+func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {
79 client := &fakeImagesClient{299 client := &fakeImagesClient{
80 listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}},300 listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}},
81 }301 }
82 302
83 var out bytes.Buffer303 var out bytes.Buffer
84- err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 3}, &out)304+ opts := imagesPruneOptions{DryRun: true, KeepReleases: 3}
305+ err := runImagesPrune(context.Background(), client, opts, &out)
85 require.NoError(t, err)306 require.NoError(t, err)
86 307
87 text := out.String()308 text := out.String()
@@ -92,67 +313,254 @@ func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) {
92 assert.Equal(t, 0, client.pruneCalls)313 assert.Equal(t, 0, client.pruneCalls)
93 }314 }
94 315
95-func TestRunImagesPrune_DryRunKeepZeroSkipsRegistry(t *testing.T) {316+func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {
96- client := &fakeImagesClient{listImagesResp: []dto.Image{{Dangling: true}}}317+ client := &fakeImagesClient{
318+ listImagesResp: []dto.Image{{Dangling: true}},
319+ }
97 320
98 var out bytes.Buffer321 var out bytes.Buffer
99- err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 0}, &out)322+ opts := imagesPruneOptions{DryRun: true, Dangling: true}
323+ err := runImagesPrune(context.Background(), client, opts, &out)
100 require.NoError(t, err)324 require.NoError(t, err)
101 325
102 text := out.String()326 text := out.String()
103- assert.Contains(t, text, "Registry cleanup skipped (--keep=0)")327+ assert.Contains(t, text, "would prune 1 dangling runtime images")
104- assert.Equal(t, 1, client.listImagesCalls)328+ assert.Contains(t, text, "Registry cleanup skipped")
105 assert.Equal(t, 0, client.pruneCalls)329 assert.Equal(t, 0, client.pruneCalls)
106 }330 }
107 331
108-func TestRunImagesPrune_RuntimeOnlyForcesKeepZero(t *testing.T) {332+func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {
109- client := &fakeImagesClient{pruneResp: &dto.ImagePruneResponse{}}333+ client := &fakeImagesClient{
334+ listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}},
335+ }
336+
337+ var out bytes.Buffer
338+ opts := imagesPruneOptions{DryRun: true, Registry: true, KeepReleases: 5}
339+ err := runImagesPrune(context.Background(), client, opts, &out)
340+ require.NoError(t, err)
341+
342+ text := out.String()
343+ assert.Contains(t, text, "Runtime cleanup skipped")
344+ assert.Contains(t, text, "would keep latest + 5 previous tags")
345+ assert.Equal(t, 0, client.pruneCalls)
346+}
347+
348+// ---------------------------------------------------------------------------
349+// images prune: keep-releases semantics
350+// ---------------------------------------------------------------------------
351+
352+func TestRunImagesPrune_KeepReleasesControlsRetention(t *testing.T) {
353+ client := &fakeImagesClient{
354+ pruneResp: &dto.ImagePruneResponse{
355+ Runtime: dto.RuntimePruneResult{DeletedCount: 0},
356+ Registry: dto.RegistryPruneResult{TagsRemoved: 7, BlobsRemoved: 3, SpaceReclaimed: 9000},
357+ },
358+ }
110 359
111 var out bytes.Buffer360 var out bytes.Buffer
112- err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 9, RuntimeOnly: true}, &out)361+ opts := imagesPruneOptions{KeepReleases: 10, NoConfirm: true}
362+ err := runImagesPrune(context.Background(), client, opts, &out)
113 require.NoError(t, err)363 require.NoError(t, err)
114 364
365+ require.NotNil(t, client.lastPruneOpts.KeepLast)
366+ assert.Equal(t, 10, *client.lastPruneOpts.KeepLast)
367+}
368+
369+func TestRunImagesPrune_RejectsNegativeKeepReleases(t *testing.T) {
370+ err := runImagesPrune(context.Background(), &fakeImagesClient{}, imagesPruneOptions{KeepReleases: -1}, &bytes.Buffer{})
371+ require.Error(t, err)
372+ assert.Contains(t, err.Error(), "--keep-releases must be >= 0")
373+}
374+
375+// ---------------------------------------------------------------------------
376+// images prune: confirmation behavior
377+// ---------------------------------------------------------------------------
378+
379+func TestRunImagesPrune_NonDryRunCallsConfirm(t *testing.T) {
380+ confirmCalled := false
381+ origConfirm := pruneConfirmFunc
382+ pruneConfirmFunc = func(_ string) (bool, error) {
383+ confirmCalled = true
384+ return true, nil
385+ }
386+ t.Cleanup(func() { pruneConfirmFunc = origConfirm })
387+
388+ client := &fakeImagesClient{
389+ pruneResp: &dto.ImagePruneResponse{
390+ Runtime: dto.RuntimePruneResult{DeletedCount: 1},
391+ Registry: dto.RegistryPruneResult{TagsRemoved: 1},
392+ },
393+ }
394+
395+ var out bytes.Buffer
396+ opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast}
397+ err := runImagesPrune(context.Background(), client, opts, &out)
398+ require.NoError(t, err)
399+
400+ assert.True(t, confirmCalled, "confirmation should have been called")
115 assert.Equal(t, 1, client.pruneCalls)401 assert.Equal(t, 1, client.pruneCalls)
116- assert.Equal(t, 0, client.lastPruneKeep)
117- assert.Contains(t, out.String(), "Registry cleanup skipped")
118 }402 }
119 403
120-func TestRunImagesPrune_UsesKeepFlagWhenNotRuntimeOnly(t *testing.T) {404+func TestRunImagesPrune_ConfirmRejectCancelsOperation(t *testing.T) {
405+ origConfirm := pruneConfirmFunc
406+ pruneConfirmFunc = func(_ string) (bool, error) {
407+ return false, nil
408+ }
409+ t.Cleanup(func() { pruneConfirmFunc = origConfirm })
410+
411+ client := &fakeImagesClient{pruneResp: &dto.ImagePruneResponse{}}
412+
413+ var out bytes.Buffer
414+ opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast}
415+ err := runImagesPrune(context.Background(), client, opts, &out)
416+ require.NoError(t, err)
417+
418+ assert.Equal(t, 0, client.pruneCalls, "prune should not be called after rejection")
419+ assert.Contains(t, out.String(), "cancelled")
420+}
421+
422+func TestRunImagesPrune_NoConfirmSkipsPrompt(t *testing.T) {
423+ confirmCalled := false
424+ origConfirm := pruneConfirmFunc
425+ pruneConfirmFunc = func(_ string) (bool, error) {
426+ confirmCalled = true
427+ return true, nil
428+ }
429+ t.Cleanup(func() { pruneConfirmFunc = origConfirm })
430+
121 client := &fakeImagesClient{431 client := &fakeImagesClient{
122 pruneResp: &dto.ImagePruneResponse{432 pruneResp: &dto.ImagePruneResponse{
123- Runtime: dto.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},433+ Runtime: dto.RuntimePruneResult{DeletedCount: 1},
124- Registry: dto.RegistryPruneResult{TagsRemoved: 3, BlobsRemoved: 1, SpaceReclaimed: 8192},
125 },434 },
126 }435 }
127 436
128 var out bytes.Buffer437 var out bytes.Buffer
129- err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 5}, &out)438+ opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true}
439+ err := runImagesPrune(context.Background(), client, opts, &out)
130 require.NoError(t, err)440 require.NoError(t, err)
131 441
132- text := out.String()442+ assert.False(t, confirmCalled, "confirmation should not be called with --no-confirm")
133 assert.Equal(t, 1, client.pruneCalls)443 assert.Equal(t, 1, client.pruneCalls)
134- assert.Equal(t, 5, client.lastPruneKeep)
135- assert.Contains(t, text, "Runtime: deleted=2")
136- assert.Contains(t, text, "Registry: tags_removed=3")
137 }444 }
138 445
446+func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) {
447+ confirmCalled := false
448+ origConfirm := pruneConfirmFunc
449+ pruneConfirmFunc = func(_ string) (bool, error) {
450+ confirmCalled = true
451+ return true, nil
452+ }
453+ t.Cleanup(func() { pruneConfirmFunc = origConfirm })
454+
455+ client := &fakeImagesClient{
456+ listImagesResp: []dto.Image{{Dangling: true}},
457+ }
458+
459+ var out bytes.Buffer
460+ opts := imagesPruneOptions{DryRun: true, KeepReleases: 3}
461+ err := runImagesPrune(context.Background(), client, opts, &out)
462+ require.NoError(t, err)
463+
464+ assert.False(t, confirmCalled, "confirmation should not be called during dry-run")
465+}
466+
467+// ---------------------------------------------------------------------------
468+// images prune: error handling
469+// ---------------------------------------------------------------------------
470+
139 func TestRunImagesPrune_ReturnsRemoteErrors(t *testing.T) {471 func TestRunImagesPrune_ReturnsRemoteErrors(t *testing.T) {
472+ origConfirm := pruneConfirmFunc
473+ pruneConfirmFunc = func(_ string) (bool, error) { return true, nil }
474+ t.Cleanup(func() { pruneConfirmFunc = origConfirm })
475+
140 client := &fakeImagesClient{pruneErr: errors.New("request failed")}476 client := &fakeImagesClient{pruneErr: errors.New("request failed")}
141 477
142- err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 2}, &bytes.Buffer{})478+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 2}, &bytes.Buffer{})
143 require.Error(t, err)479 require.Error(t, err)
144 assert.Contains(t, err.Error(), "failed to prune images")480 assert.Contains(t, err.Error(), "failed to prune images")
145 }481 }
146 482
147-func TestRunImagesPrune_RejectsNegativeKeep(t *testing.T) {
148- err := runImagesPrune(context.Background(), &fakeImagesClient{}, imagesPruneOptions{KeepLast: -1}, &bytes.Buffer{})
149- require.Error(t, err)
150- assert.Contains(t, err.Error(), "--keep must be >= 0")
151-}
152-
153 func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) {483 func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) {
484+ origConfirm := pruneConfirmFunc
485+ pruneConfirmFunc = func(_ string) (bool, error) { return true, nil }
486+ t.Cleanup(func() { pruneConfirmFunc = origConfirm })
487+
154 client := &fakeImagesClient{pruneResp: nil}488 client := &fakeImagesClient{pruneResp: nil}
155- err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 1}, &bytes.Buffer{})489+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 1}, &bytes.Buffer{})
156 require.Error(t, err)490 require.Error(t, err)
157 assert.Contains(t, err.Error(), "empty response")491 assert.Contains(t, err.Error(), "empty response")
158 }492 }
493+
494+// ---------------------------------------------------------------------------
495+// test helpers
496+// ---------------------------------------------------------------------------
497+
498+func stripANSI(value string) string {
499+ ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
500+ return ansiRegex.ReplaceAllString(value, "")
501+}
502+
503+func findHeaderLine(text string) string {
504+ for _, line := range strings.Split(text, "\n") {
505+ if strings.Contains(line, "REPOSITORY") && strings.Contains(line, "DANGLING") {
506+ return line
507+ }
508+ }
509+
510+ return ""
511+}
512+
513+func findImageListRowLine(text string, tag string) string {
514+ for _, line := range strings.Split(text, "\n") {
515+ trimmed := strings.TrimSpace(line)
516+ if trimmed == "" {
517+ continue
518+ }
519+ if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") {
520+ continue
521+ }
522+
523+ cols := splitTableColumns(line)
524+ if len(cols) == 6 && cols[1] == tag {
525+ return line
526+ }
527+ }
528+
529+ return ""
530+}
531+
532+func findImageListRows(text string) [][]string {
533+ rows := make([][]string, 0)
534+ for _, line := range strings.Split(text, "\n") {
535+ trimmed := strings.TrimSpace(line)
536+ if trimmed == "" {
537+ continue
538+ }
539+ if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") {
540+ continue
541+ }
542+
543+ cols := splitTableColumns(line)
544+ if len(cols) == 6 && cols[0] != "REPOSITORY" {
545+ rows = append(rows, cols)
546+ }
547+ }
548+
549+ return rows
550+}
551+
552+func splitTableColumns(line string) []string {
553+ if strings.Contains(line, "│") {
554+ parts := strings.Split(line, "│")
555+ cols := make([]string, 0, len(parts))
556+ for _, part := range parts {
557+ cell := strings.TrimSpace(part)
558+ if cell != "" {
559+ cols = append(cols, cell)
560+ }
561+ }
562+ return cols
563+ }
564+
565+ return regexp.MustCompile(`\s{2,}`).Split(strings.TrimSpace(line), -1)
566+}
M internal/adapters/in/cli/parity_matrix_test.go +65 -0
@@ -1,11 +1,43 @@
11 package cli
22
33 import (
4+ "go/ast"
5+ "go/parser"
6+ "go/token"
47 "os"
58 "strings"
69 "testing"
710 )
811
12+type uiAdoptionExpectation struct {
13+ family string
14+ file string
15+ functions []string
16+}
17+
18+var uiAdoptionExpectations = []uiAdoptionExpectation{
19+ {
20+ family: "server",
21+ file: "serve.go",
22+ functions: []string{"newStartCmd"},
23+ },
24+ {
25+ family: "root/server",
26+ file: "root.go",
27+ functions: []string{"newVersionCmd", "runReloadRemote", "runLogsRemote", "streamLogsRemote", "showContainerLogsLocal"},
28+ },
29+ {
30+ family: "backups",
31+ file: "backup.go",
32+ functions: []string{"newBackupListCmd", "newBackupRunCmd", "newBackupDetectCmd", "newBackupStatusCmd"},
33+ },
34+ {
35+ family: "images",
36+ file: "images.go",
37+ functions: []string{"runImagesList", "runImagesPrune"},
38+ },
39+}
40+
941 func TestLocalParityMatrix(t *testing.T) {
1042 checks := []struct {
1143 file string
@@ -29,3 +61,36 @@ func TestLocalParityMatrix(t *testing.T) {
2961 }
3062 }
3163 }
64+
65+func TestUIAdoptionMatrixCoverage(t *testing.T) {
66+ for _, expect := range uiAdoptionExpectations {
67+ expect := expect
68+ t.Run(expect.family, func(t *testing.T) {
69+ if len(expect.functions) == 0 {
70+ t.Fatalf("ui adoption matrix for %s is empty", expect.family)
71+ }
72+
73+ fset := token.NewFileSet()
74+ fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors)
75+ if err != nil {
76+ t.Fatalf("failed to parse %s: %v", expect.file, err)
77+ }
78+
79+ found := make(map[string]bool, len(expect.functions))
80+ ast.Inspect(fileNode, func(n ast.Node) bool {
81+ fn, ok := n.(*ast.FuncDecl)
82+ if !ok {
83+ return true
84+ }
85+ found[fn.Name.Name] = true
86+ return true
87+ })
88+
89+ for _, fn := range expect.functions {
90+ if !found[fn] {
91+ t.Fatalf("ui adoption matrix references missing function %s in %s", fn, expect.file)
92+ }
93+ }
94+ })
95+ }
96+}
@@ -1,11 +1,43 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "go/ast"
5+ "go/parser"
6+ "go/token"
4 "os"7 "os"
5 "strings"8 "strings"
6 "testing"9 "testing"
7 )10 )
8 11
12+type uiAdoptionExpectation struct {
13+ family string
14+ file string
15+ functions []string
16+}
17+
18+var uiAdoptionExpectations = []uiAdoptionExpectation{
19+ {
20+ family: "server",
21+ file: "serve.go",
22+ functions: []string{"newStartCmd"},
23+ },
24+ {
25+ family: "root/server",
26+ file: "root.go",
27+ functions: []string{"newVersionCmd", "runReloadRemote", "runLogsRemote", "streamLogsRemote", "showContainerLogsLocal"},
28+ },
29+ {
30+ family: "backups",
31+ file: "backup.go",
32+ functions: []string{"newBackupListCmd", "newBackupRunCmd", "newBackupDetectCmd", "newBackupStatusCmd"},
33+ },
34+ {
35+ family: "images",
36+ file: "images.go",
37+ functions: []string{"runImagesList", "runImagesPrune"},
38+ },
39+}
40+
9 func TestLocalParityMatrix(t *testing.T) {41 func TestLocalParityMatrix(t *testing.T) {
10 checks := []struct {42 checks := []struct {
11 file string43 file string
@@ -29,3 +61,36 @@ func TestLocalParityMatrix(t *testing.T) {
29 }61 }
30 }62 }
31 }63 }
64+
65+func TestUIAdoptionMatrixCoverage(t *testing.T) {
66+ for _, expect := range uiAdoptionExpectations {
67+ expect := expect
68+ t.Run(expect.family, func(t *testing.T) {
69+ if len(expect.functions) == 0 {
70+ t.Fatalf("ui adoption matrix for %s is empty", expect.family)
71+ }
72+
73+ fset := token.NewFileSet()
74+ fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors)
75+ if err != nil {
76+ t.Fatalf("failed to parse %s: %v", expect.file, err)
77+ }
78+
79+ found := make(map[string]bool, len(expect.functions))
80+ ast.Inspect(fileNode, func(n ast.Node) bool {
81+ fn, ok := n.(*ast.FuncDecl)
82+ if !ok {
83+ return true
84+ }
85+ found[fn.Name.Name] = true
86+ return true
87+ })
88+
89+ for _, fn := range expect.functions {
90+ if !found[fn] {
91+ t.Fatalf("ui adoption matrix references missing function %s in %s", fn, expect.file)
92+ }
93+ }
94+ })
95+ }
96+}
A internal/adapters/in/cli/presentation.go +51 -0
@@ -0,0 +1,51 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "io"
6+
7+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
8+)
9+
10+var cliWriteLine = func(w io.Writer, msg string) error {
11+ _, err := fmt.Fprintln(w, msg)
12+ return err
13+}
14+
15+var cliWritef = func(w io.Writer, format string, args ...any) error {
16+ _, err := fmt.Fprintf(w, format, args...)
17+ return err
18+}
19+
20+func cliRenderTitle(msg string) string {
21+ return styles.Theme.Title.Render(msg)
22+}
23+
24+func cliRenderMuted(msg string) string {
25+ return styles.Theme.Muted.Render(msg)
26+}
27+
28+func cliRenderEmptyState(msg string) string {
29+ return cliRenderMuted(msg)
30+}
31+
32+func cliRenderListItem(msg string) string {
33+ const selected = false // CLI list items are never in a selected/highlighted state
34+ return styles.RenderListItem(msg, selected)
35+}
36+
37+func cliRenderMeta(label, value string) string {
38+ return styles.Theme.Bold.Render(label) + " " + styles.Theme.Muted.Render(value)
39+}
40+
41+func cliRenderSuccess(msg string) string {
42+ return styles.RenderSuccess(msg)
43+}
44+
45+func cliRenderWarning(msg string) string {
46+ return styles.RenderWarning(msg)
47+}
48+
49+func cliRenderInfo(msg string) string {
50+ return styles.RenderInfo(msg)
51+}
@@ -0,0 +1,51 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "io"
6+
7+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
8+)
9+
10+var cliWriteLine = func(w io.Writer, msg string) error {
11+ _, err := fmt.Fprintln(w, msg)
12+ return err
13+}
14+
15+var cliWritef = func(w io.Writer, format string, args ...any) error {
16+ _, err := fmt.Fprintf(w, format, args...)
17+ return err
18+}
19+
20+func cliRenderTitle(msg string) string {
21+ return styles.Theme.Title.Render(msg)
22+}
23+
24+func cliRenderMuted(msg string) string {
25+ return styles.Theme.Muted.Render(msg)
26+}
27+
28+func cliRenderEmptyState(msg string) string {
29+ return cliRenderMuted(msg)
30+}
31+
32+func cliRenderListItem(msg string) string {
33+ const selected = false // CLI list items are never in a selected/highlighted state
34+ return styles.RenderListItem(msg, selected)
35+}
36+
37+func cliRenderMeta(label, value string) string {
38+ return styles.Theme.Bold.Render(label) + " " + styles.Theme.Muted.Render(value)
39+}
40+
41+func cliRenderSuccess(msg string) string {
42+ return styles.RenderSuccess(msg)
43+}
44+
45+func cliRenderWarning(msg string) string {
46+ return styles.RenderWarning(msg)
47+}
48+
49+func cliRenderInfo(msg string) string {
50+ return styles.RenderInfo(msg)
51+}
M internal/adapters/in/cli/remote/client.go +4 -4
@@ -501,12 +501,12 @@ func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) {
501501 }
502502
503503 // PruneImages prunes runtime and registry images.
504-func (c *Client) PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) {
505- if keepLast < 0 {
506- return nil, fmt.Errorf("keepLast must be >= 0")
504+func (c *Client) PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
505+ if req.KeepLast != nil && *req.KeepLast < 0 {
506+ return nil, fmt.Errorf("keep_last must be >= 0")
507507 }
508508
509- resp, err := c.request(ctx, http.MethodPost, "/images/prune", dto.ImagePruneRequest{KeepLast: &keepLast})
509+ resp, err := c.request(ctx, http.MethodPost, "/images/prune", req)
510510 if err != nil {
511511 return nil, err
512512 }
@@ -501,12 +501,12 @@ func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) {
501 }501 }
502 502
503 // PruneImages prunes runtime and registry images.503 // PruneImages prunes runtime and registry images.
504-func (c *Client) PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) {504+func (c *Client) PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
505- if keepLast < 0 {505+ if req.KeepLast != nil && *req.KeepLast < 0 {
506- return nil, fmt.Errorf("keepLast must be >= 0")506+ return nil, fmt.Errorf("keep_last must be >= 0")
507 }507 }
508 508
509- resp, err := c.request(ctx, http.MethodPost, "/images/prune", dto.ImagePruneRequest{KeepLast: &keepLast})509+ resp, err := c.request(ctx, http.MethodPost, "/images/prune", req)
510 if err != nil {510 if err != nil {
511 return nil, err511 return nil, err
512 }512 }
M internal/adapters/in/cli/remote/client_test.go +12 -1
@@ -138,14 +138,25 @@ func TestClientPruneImages(t *testing.T) {
138138 require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
139139 require.NotNil(t, req.KeepLast)
140140 assert.Equal(t, 4, *req.KeepLast)
141+ require.NotNil(t, req.PruneDangling)
142+ assert.True(t, *req.PruneDangling)
143+ require.NotNil(t, req.PruneRegistry)
144+ assert.True(t, *req.PruneRegistry)
141145
142146 w.Header().Set("Content-Type", "application/json")
143147 _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`))
144148 }))
145149 defer srv.Close()
146150
151+ keepLast := 4
152+ pruneDangling := true
153+ pruneRegistry := true
147154 client := NewClient(srv.URL)
148- resp, err := client.PruneImages(context.Background(), 4)
155+ resp, err := client.PruneImages(context.Background(), dto.ImagePruneRequest{
156+ KeepLast: &keepLast,
157+ PruneDangling: &pruneDangling,
158+ PruneRegistry: &pruneRegistry,
159+ })
149160 require.NoError(t, err)
150161 require.NotNil(t, resp)
151162 assert.Equal(t, 2, resp.Runtime.DeletedCount)
@@ -138,14 +138,25 @@ func TestClientPruneImages(t *testing.T) {
138 require.NoError(t, json.NewDecoder(r.Body).Decode(&req))138 require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
139 require.NotNil(t, req.KeepLast)139 require.NotNil(t, req.KeepLast)
140 assert.Equal(t, 4, *req.KeepLast)140 assert.Equal(t, 4, *req.KeepLast)
141+ require.NotNil(t, req.PruneDangling)
142+ assert.True(t, *req.PruneDangling)
143+ require.NotNil(t, req.PruneRegistry)
144+ assert.True(t, *req.PruneRegistry)
141 145
142 w.Header().Set("Content-Type", "application/json")146 w.Header().Set("Content-Type", "application/json")
143 _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`))147 _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`))
144 }))148 }))
145 defer srv.Close()149 defer srv.Close()
146 150
151+ keepLast := 4
152+ pruneDangling := true
153+ pruneRegistry := true
147 client := NewClient(srv.URL)154 client := NewClient(srv.URL)
148- resp, err := client.PruneImages(context.Background(), 4)155+ resp, err := client.PruneImages(context.Background(), dto.ImagePruneRequest{
156+ KeepLast: &keepLast,
157+ PruneDangling: &pruneDangling,
158+ PruneRegistry: &pruneRegistry,
159+ })
149 require.NoError(t, err)160 require.NoError(t, err)
150 require.NotNil(t, resp)161 require.NotNil(t, resp)
151 assert.Equal(t, 2, resp.Runtime.DeletedCount)162 assert.Equal(t, 2, resp.Runtime.DeletedCount)
M internal/adapters/in/cli/root.go +48 -15
@@ -195,10 +195,18 @@ func newVersionCmd() *cobra.Command {
195195 return &cobra.Command{
196196 Use: "version",
197197 Short: "Print version information",
198- Run: func(cmd *cobra.Command, args []string) {
199- cmd.Printf("Gordon %s\n", Version)
200- cmd.Printf("Commit: %s\n", Commit)
201- cmd.Printf("Build Date: %s\n", BuildDate)
198+ RunE: func(cmd *cobra.Command, args []string) error {
199+ out := cmd.OutOrStdout()
200+ if err := cliWriteLine(out, cliRenderTitle("Gordon "+Version)); err != nil {
201+ return err
202+ }
203+ if err := cliWriteLine(out, cliRenderMeta("Commit:", Commit)); err != nil {
204+ return err
205+ }
206+ if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil {
207+ return err
208+ }
209+ return nil
202210 },
203211 }
204212 }
@@ -214,15 +222,21 @@ func runReloadRemote(ctx context.Context, client *remote.Client) error {
214222 if shouldFallbackToLocal(err) {
215223 localErr := runReload()
216224 if localErr == nil {
217- fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err)
218- fmt.Println("Configuration reloaded successfully")
225+ if writeErr := cliWriteLine(os.Stdout, cliRenderWarning(fmt.Sprintf("Remote reload failed (%v), used local signal fallback", err))); writeErr != nil {
226+ return writeErr
227+ }
228+ if writeErr := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); writeErr != nil {
229+ return writeErr
230+ }
219231 return nil
220232 }
221233 return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr)
222234 }
223235 return fmt.Errorf("failed to reload: %w", err)
224236 }
225- fmt.Println("Configuration reloaded successfully")
237+ if err := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); err != nil {
238+ return err
239+ }
226240 return nil
227241 }
228242
@@ -291,7 +305,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i
291305 return fmt.Errorf("failed to get process logs: %w", err)
292306 }
293307 for _, line := range logLines {
294- fmt.Println(line)
308+ if err := cliWriteLine(os.Stdout, line); err != nil {
309+ return err
310+ }
295311 }
296312 } else {
297313 // Container logs
@@ -300,7 +316,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i
300316 return fmt.Errorf("failed to get container logs: %w", err)
301317 }
302318 for _, line := range logLines {
303- fmt.Println(line)
319+ if err := cliWriteLine(os.Stdout, line); err != nil {
320+ return err
321+ }
304322 }
305323 }
306324 return nil
@@ -321,7 +339,9 @@ func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain stri
321339 }
322340
323341 for line := range ch {
324- fmt.Println(line)
342+ if err := cliWriteLine(os.Stdout, line); err != nil {
343+ return err
344+ }
325345 }
326346 return nil
327347 }
@@ -342,13 +362,26 @@ func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines
342362 // For local container logs, we need Docker access which requires
343363 // the runtime to be initialized. For now, suggest using remote mode
344364 // or direct docker logs command.
345- fmt.Printf("Container logs for %s\n", logDomain)
346- fmt.Println("To view container logs locally, use:")
347- fmt.Printf(" docker logs --tail %d %s\n", lines, logDomain)
365+ if err := cliWriteLine(os.Stdout, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil {
366+ return err
367+ }
368+ if err := cliWriteLine(os.Stdout, cliRenderInfo("To view container logs locally, use:")); err != nil {
369+ return err
370+ }
371+ if err := cliWritef(os.Stdout, " docker logs --tail %d %s\n", lines, logDomain); err != nil {
372+ return err
373+ }
348374 if follow {
349- fmt.Printf(" docker logs -f --tail %d %s\n", lines, logDomain)
375+ if err := cliWritef(os.Stdout, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil {
376+ return err
377+ }
378+ }
379+ if err := cliWriteLine(os.Stdout, ""); err != nil {
380+ return err
381+ }
382+ if err := cliWriteLine(os.Stdout, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil {
383+ return err
350384 }
351- fmt.Println("\nOr use remote mode to access logs via the admin API.")
352385 return nil
353386 }
354387
@@ -195,10 +195,18 @@ func newVersionCmd() *cobra.Command {
195 return &cobra.Command{195 return &cobra.Command{
196 Use: "version",196 Use: "version",
197 Short: "Print version information",197 Short: "Print version information",
198- Run: func(cmd *cobra.Command, args []string) {198+ RunE: func(cmd *cobra.Command, args []string) error {
199- cmd.Printf("Gordon %s\n", Version)199+ out := cmd.OutOrStdout()
200- cmd.Printf("Commit: %s\n", Commit)200+ if err := cliWriteLine(out, cliRenderTitle("Gordon "+Version)); err != nil {
201- cmd.Printf("Build Date: %s\n", BuildDate)201+ return err
202+ }
203+ if err := cliWriteLine(out, cliRenderMeta("Commit:", Commit)); err != nil {
204+ return err
205+ }
206+ if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil {
207+ return err
208+ }
209+ return nil
202 },210 },
203 }211 }
204 }212 }
@@ -214,15 +222,21 @@ func runReloadRemote(ctx context.Context, client *remote.Client) error {
214 if shouldFallbackToLocal(err) {222 if shouldFallbackToLocal(err) {
215 localErr := runReload()223 localErr := runReload()
216 if localErr == nil {224 if localErr == nil {
217- fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err)225+ if writeErr := cliWriteLine(os.Stdout, cliRenderWarning(fmt.Sprintf("Remote reload failed (%v), used local signal fallback", err))); writeErr != nil {
218- fmt.Println("Configuration reloaded successfully")226+ return writeErr
227+ }
228+ if writeErr := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); writeErr != nil {
229+ return writeErr
230+ }
219 return nil231 return nil
220 }232 }
221 return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr)233 return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr)
222 }234 }
223 return fmt.Errorf("failed to reload: %w", err)235 return fmt.Errorf("failed to reload: %w", err)
224 }236 }
225- fmt.Println("Configuration reloaded successfully")237+ if err := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); err != nil {
238+ return err
239+ }
226 return nil240 return nil
227 }241 }
228 242
@@ -291,7 +305,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i
291 return fmt.Errorf("failed to get process logs: %w", err)305 return fmt.Errorf("failed to get process logs: %w", err)
292 }306 }
293 for _, line := range logLines {307 for _, line := range logLines {
294- fmt.Println(line)308+ if err := cliWriteLine(os.Stdout, line); err != nil {
309+ return err
310+ }
295 }311 }
296 } else {312 } else {
297 // Container logs313 // Container logs
@@ -300,7 +316,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i
300 return fmt.Errorf("failed to get container logs: %w", err)316 return fmt.Errorf("failed to get container logs: %w", err)
301 }317 }
302 for _, line := range logLines {318 for _, line := range logLines {
303- fmt.Println(line)319+ if err := cliWriteLine(os.Stdout, line); err != nil {
320+ return err
321+ }
304 }322 }
305 }323 }
306 return nil324 return nil
@@ -321,7 +339,9 @@ func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain stri
321 }339 }
322 340
323 for line := range ch {341 for line := range ch {
324- fmt.Println(line)342+ if err := cliWriteLine(os.Stdout, line); err != nil {
343+ return err
344+ }
325 }345 }
326 return nil346 return nil
327 }347 }
@@ -342,13 +362,26 @@ func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines
342 // For local container logs, we need Docker access which requires362 // For local container logs, we need Docker access which requires
343 // the runtime to be initialized. For now, suggest using remote mode363 // the runtime to be initialized. For now, suggest using remote mode
344 // or direct docker logs command.364 // or direct docker logs command.
345- fmt.Printf("Container logs for %s\n", logDomain)365+ if err := cliWriteLine(os.Stdout, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil {
346- fmt.Println("To view container logs locally, use:")366+ return err
347- fmt.Printf(" docker logs --tail %d %s\n", lines, logDomain)367+ }
368+ if err := cliWriteLine(os.Stdout, cliRenderInfo("To view container logs locally, use:")); err != nil {
369+ return err
370+ }
371+ if err := cliWritef(os.Stdout, " docker logs --tail %d %s\n", lines, logDomain); err != nil {
372+ return err
373+ }
348 if follow {374 if follow {
349- fmt.Printf(" docker logs -f --tail %d %s\n", lines, logDomain)375+ if err := cliWritef(os.Stdout, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil {
376+ return err
377+ }
378+ }
379+ if err := cliWriteLine(os.Stdout, ""); err != nil {
380+ return err
381+ }
382+ if err := cliWriteLine(os.Stdout, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil {
383+ return err
350 }384 }
351- fmt.Println("\nOr use remote mode to access logs via the admin API.")
352 return nil385 return nil
353 }386 }
354 387
M internal/adapters/in/cli/serve.go +3 -2
@@ -2,7 +2,6 @@ package cli
22
33 import (
44 "context"
5- "fmt"
65 "os"
76
87 "github.com/bnema/gordon/internal/app"
@@ -40,7 +39,9 @@ func newStartCmd() *cobra.Command {
4039 Deprecated: "use 'gordon serve' instead",
4140 Hidden: true, // Hide from help but still works
4241 RunE: func(cmd *cobra.Command, args []string) error {
43- fmt.Fprintln(os.Stderr, "\nWarning: 'gordon start' is deprecated, use 'gordon serve' instead")
42+ if err := cliWriteLine(os.Stderr, cliRenderWarning("Warning: 'gordon start' is deprecated, use 'gordon serve' instead")); err != nil {
43+ return err
44+ }
4445 return app.Run(context.Background(), configPath)
4546 },
4647 }
@@ -2,7 +2,6 @@ package cli
2 2
3 import (3 import (
4 "context"4 "context"
5- "fmt"
6 "os"5 "os"
7 6
8 "github.com/bnema/gordon/internal/app"7 "github.com/bnema/gordon/internal/app"
@@ -40,7 +39,9 @@ func newStartCmd() *cobra.Command {
40 Deprecated: "use 'gordon serve' instead",39 Deprecated: "use 'gordon serve' instead",
41 Hidden: true, // Hide from help but still works40 Hidden: true, // Hide from help but still works
42 RunE: func(cmd *cobra.Command, args []string) error {41 RunE: func(cmd *cobra.Command, args []string) error {
43- fmt.Fprintln(os.Stderr, "\nWarning: 'gordon start' is deprecated, use 'gordon serve' instead")42+ if err := cliWriteLine(os.Stderr, cliRenderWarning("Warning: 'gordon start' is deprecated, use 'gordon serve' instead")); err != nil {
43+ return err
44+ }
44 return app.Run(context.Background(), configPath)45 return app.Run(context.Background(), configPath)
45 },46 },
46 }47 }
M internal/adapters/in/cli/ui/components/table.go +67 -5
@@ -1,10 +1,14 @@
11 package components
22
33 import (
4+ "strings"
5+
46 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
57
68 "github.com/charmbracelet/lipgloss"
79 "github.com/charmbracelet/lipgloss/table"
10+ "github.com/mattn/go-runewidth"
11+ "github.com/rivo/uniseg"
812 )
913
1014 // TableColumn defines a table column.
@@ -137,7 +141,19 @@ func (t *TableModel) Render() string {
137141 // Extract headers
138142 headers := make([]string, len(t.columns))
139143 for i, col := range t.columns {
140- headers[i] = col.Title
144+ headers[i] = truncateCell(col.Title, col.Width)
145+ }
146+
147+ rows := make([][]string, len(t.rows))
148+ for rowIdx, row := range t.rows {
149+ rows[rowIdx] = make([]string, len(row))
150+ for colIdx, cell := range row {
151+ width := 0
152+ if colIdx < len(t.columns) {
153+ width = t.columns[colIdx].Width
154+ }
155+ rows[rowIdx][colIdx] = truncateCell(cell, width)
156+ }
141157 }
142158
143159 // Create table
@@ -145,15 +161,27 @@ func (t *TableModel) Render() string {
145161 Border(t.border).
146162 BorderStyle(t.borderStyle).
147163 Headers(headers...).
148- Rows(t.rows...).
164+ Rows(rows...).
149165 StyleFunc(func(row, col int) lipgloss.Style {
166+ width := 0
167+ if col >= 0 && col < len(t.columns) {
168+ width = t.columns[col].Width
169+ }
170+
171+ applyWidth := func(s lipgloss.Style) lipgloss.Style {
172+ if width > 0 {
173+ return s.Width(width).MaxWidth(width)
174+ }
175+ return s
176+ }
177+
150178 if row == table.HeaderRow {
151- return t.headerStyle
179+ return applyWidth(t.headerStyle)
152180 }
153181 if row%2 == 0 {
154- return t.evenStyle
182+ return applyWidth(t.evenStyle)
155183 }
156- return t.oddStyle
184+ return applyWidth(t.oddStyle)
157185 })
158186
159187 // Apply width if set
@@ -164,6 +192,40 @@ func (t *TableModel) Render() string {
164192 return tbl.String()
165193 }
166194
195+func truncateCell(value string, maxWidth int) string {
196+ if strings.Contains(value, "\x1b[") {
197+ return value
198+ }
199+
200+ if maxWidth <= 0 || runewidth.StringWidth(value) <= maxWidth {
201+ return value
202+ }
203+
204+ if maxWidth <= 3 {
205+ return strings.Repeat(".", maxWidth)
206+ }
207+
208+ targetWidth := maxWidth - 3
209+ b := strings.Builder{}
210+ currentWidth := 0
211+ g := uniseg.NewGraphemes(value)
212+ for g.Next() {
213+ grapheme := g.Str()
214+ graphemeWidth := runewidth.StringWidth(grapheme)
215+ if currentWidth+graphemeWidth > targetWidth {
216+ break
217+ }
218+ b.WriteString(grapheme)
219+ currentWidth += graphemeWidth
220+ }
221+
222+ if b.Len() == 0 {
223+ return strings.Repeat(".", maxWidth)
224+ }
225+
226+ return b.String() + "..."
227+}
228+
167229 // View returns the table view (alias for Render).
168230 func (t *TableModel) View() string {
169231 return t.Render()
@@ -1,10 +1,14 @@
1 package components1 package components
2 2
3 import (3 import (
4+ "strings"
5+
4 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"6 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
5 7
6 "github.com/charmbracelet/lipgloss"8 "github.com/charmbracelet/lipgloss"
7 "github.com/charmbracelet/lipgloss/table"9 "github.com/charmbracelet/lipgloss/table"
10+ "github.com/mattn/go-runewidth"
11+ "github.com/rivo/uniseg"
8 )12 )
9 13
10 // TableColumn defines a table column.14 // TableColumn defines a table column.
@@ -137,7 +141,19 @@ func (t *TableModel) Render() string {
137 // Extract headers141 // Extract headers
138 headers := make([]string, len(t.columns))142 headers := make([]string, len(t.columns))
139 for i, col := range t.columns {143 for i, col := range t.columns {
140- headers[i] = col.Title144+ headers[i] = truncateCell(col.Title, col.Width)
145+ }
146+
147+ rows := make([][]string, len(t.rows))
148+ for rowIdx, row := range t.rows {
149+ rows[rowIdx] = make([]string, len(row))
150+ for colIdx, cell := range row {
151+ width := 0
152+ if colIdx < len(t.columns) {
153+ width = t.columns[colIdx].Width
154+ }
155+ rows[rowIdx][colIdx] = truncateCell(cell, width)
156+ }
141 }157 }
142 158
143 // Create table159 // Create table
@@ -145,15 +161,27 @@ func (t *TableModel) Render() string {
145 Border(t.border).161 Border(t.border).
146 BorderStyle(t.borderStyle).162 BorderStyle(t.borderStyle).
147 Headers(headers...).163 Headers(headers...).
148- Rows(t.rows...).164+ Rows(rows...).
149 StyleFunc(func(row, col int) lipgloss.Style {165 StyleFunc(func(row, col int) lipgloss.Style {
166+ width := 0
167+ if col >= 0 && col < len(t.columns) {
168+ width = t.columns[col].Width
169+ }
170+
171+ applyWidth := func(s lipgloss.Style) lipgloss.Style {
172+ if width > 0 {
173+ return s.Width(width).MaxWidth(width)
174+ }
175+ return s
176+ }
177+
150 if row == table.HeaderRow {178 if row == table.HeaderRow {
151- return t.headerStyle179+ return applyWidth(t.headerStyle)
152 }180 }
153 if row%2 == 0 {181 if row%2 == 0 {
154- return t.evenStyle182+ return applyWidth(t.evenStyle)
155 }183 }
156- return t.oddStyle184+ return applyWidth(t.oddStyle)
157 })185 })
158 186
159 // Apply width if set187 // Apply width if set
@@ -164,6 +192,40 @@ func (t *TableModel) Render() string {
164 return tbl.String()192 return tbl.String()
165 }193 }
166 194
195+func truncateCell(value string, maxWidth int) string {
196+ if strings.Contains(value, "\x1b[") {
197+ return value
198+ }
199+
200+ if maxWidth <= 0 || runewidth.StringWidth(value) <= maxWidth {
201+ return value
202+ }
203+
204+ if maxWidth <= 3 {
205+ return strings.Repeat(".", maxWidth)
206+ }
207+
208+ targetWidth := maxWidth - 3
209+ b := strings.Builder{}
210+ currentWidth := 0
211+ g := uniseg.NewGraphemes(value)
212+ for g.Next() {
213+ grapheme := g.Str()
214+ graphemeWidth := runewidth.StringWidth(grapheme)
215+ if currentWidth+graphemeWidth > targetWidth {
216+ break
217+ }
218+ b.WriteString(grapheme)
219+ currentWidth += graphemeWidth
220+ }
221+
222+ if b.Len() == 0 {
223+ return strings.Repeat(".", maxWidth)
224+ }
225+
226+ return b.String() + "..."
227+}
228+
167 // View returns the table view (alias for Render).229 // View returns the table view (alias for Render).
168 func (t *TableModel) View() string {230 func (t *TableModel) View() string {
169 return t.Render()231 return t.Render()
A internal/adapters/in/cli/ui/components/table_test.go +90 -0
@@ -0,0 +1,90 @@
1+package components
2+
3+import (
4+ "regexp"
5+ "strings"
6+ "testing"
7+
8+ "github.com/charmbracelet/lipgloss"
9+ "github.com/mattn/go-runewidth"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestTableRender_AppliesConfiguredColumnWidth(t *testing.T) {
15+ tableModel := NewTable(
16+ WithColumns([]TableColumn{{Title: "ID", Width: 5}}),
17+ WithRows([][]string{{"abc"}}),
18+ WithHeaderStyle(lipgloss.NewStyle()),
19+ WithCellStyle(lipgloss.NewStyle()),
20+ )
21+
22+ rendered := stripANSI(tableModel.Render())
23+ lines := strings.Split(rendered, "\n")
24+
25+ var rowLine string
26+ for _, line := range lines {
27+ if strings.Contains(line, "abc") {
28+ rowLine = line
29+ break
30+ }
31+ }
32+
33+ require.NotEmpty(t, rowLine)
34+ assert.Contains(t, rowLine, "abc ")
35+}
36+
37+func TestTableRender_TruncatesLongCellTextWithEllipsis(t *testing.T) {
38+ tableModel := NewTable(
39+ WithColumns([]TableColumn{{Title: "ID", Width: 5}}),
40+ WithRows([][]string{{"abcdef"}}),
41+ WithHeaderStyle(lipgloss.NewStyle()),
42+ WithCellStyle(lipgloss.NewStyle()),
43+ )
44+
45+ rendered := stripANSI(tableModel.Render())
46+ assert.Contains(t, rendered, "ab...")
47+ assert.NotContains(t, rendered, "abcdef")
48+}
49+
50+func TestTruncateCell_LeavesShortTextUnchanged(t *testing.T) {
51+ assert.Equal(t, "abc", truncateCell("abc", 5))
52+}
53+
54+func TestTruncateCell_EdgeCases(t *testing.T) {
55+ tests := []struct {
56+ name string
57+ value string
58+ maxWidth int
59+ expected string
60+ }{
61+ {name: "zero width passthrough", value: "abcdef", maxWidth: 0, expected: "abcdef"},
62+ {name: "width three all dots", value: "abcdef", maxWidth: 3, expected: "..."},
63+ {name: "ascii truncates", value: "abcdef", maxWidth: 5, expected: "ab..."},
64+ {name: "cjk truncates by display width", value: "你好世界", maxWidth: 5, expected: "你..."},
65+ {name: "grapheme-safe emoji truncation", value: "👨<U+200D>👩<U+200D>👧<U+200D>👦abcd", maxWidth: 5, expected: "👨<U+200D>👩<U+200D>👧<U+200D>👦..."},
66+ }
67+
68+ for _, tt := range tests {
69+ tt := tt
70+ t.Run(tt.name, func(t *testing.T) {
71+ got := truncateCell(tt.value, tt.maxWidth)
72+ assert.Equal(t, tt.expected, got)
73+ if tt.maxWidth > 0 {
74+ assert.LessOrEqual(t, runewidth.StringWidth(got), tt.maxWidth)
75+ }
76+ })
77+ }
78+}
79+
80+func TestTruncateCell_AnsiInputPassthrough(t *testing.T) {
81+ styled := "\x1b[32mactive\x1b[0m"
82+ got := truncateCell(styled, 3)
83+ assert.Equal(t, styled, got)
84+ assert.Contains(t, got, "\x1b[")
85+}
86+
87+func stripANSI(input string) string {
88+ ansiPattern := regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
89+ return ansiPattern.ReplaceAllString(input, "")
90+}
@@ -0,0 +1,90 @@
1+package components
2+
3+import (
4+ "regexp"
5+ "strings"
6+ "testing"
7+
8+ "github.com/charmbracelet/lipgloss"
9+ "github.com/mattn/go-runewidth"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestTableRender_AppliesConfiguredColumnWidth(t *testing.T) {
15+ tableModel := NewTable(
16+ WithColumns([]TableColumn{{Title: "ID", Width: 5}}),
17+ WithRows([][]string{{"abc"}}),
18+ WithHeaderStyle(lipgloss.NewStyle()),
19+ WithCellStyle(lipgloss.NewStyle()),
20+ )
21+
22+ rendered := stripANSI(tableModel.Render())
23+ lines := strings.Split(rendered, "\n")
24+
25+ var rowLine string
26+ for _, line := range lines {
27+ if strings.Contains(line, "abc") {
28+ rowLine = line
29+ break
30+ }
31+ }
32+
33+ require.NotEmpty(t, rowLine)
34+ assert.Contains(t, rowLine, "abc ")
35+}
36+
37+func TestTableRender_TruncatesLongCellTextWithEllipsis(t *testing.T) {
38+ tableModel := NewTable(
39+ WithColumns([]TableColumn{{Title: "ID", Width: 5}}),
40+ WithRows([][]string{{"abcdef"}}),
41+ WithHeaderStyle(lipgloss.NewStyle()),
42+ WithCellStyle(lipgloss.NewStyle()),
43+ )
44+
45+ rendered := stripANSI(tableModel.Render())
46+ assert.Contains(t, rendered, "ab...")
47+ assert.NotContains(t, rendered, "abcdef")
48+}
49+
50+func TestTruncateCell_LeavesShortTextUnchanged(t *testing.T) {
51+ assert.Equal(t, "abc", truncateCell("abc", 5))
52+}
53+
54+func TestTruncateCell_EdgeCases(t *testing.T) {
55+ tests := []struct {
56+ name string
57+ value string
58+ maxWidth int
59+ expected string
60+ }{
61+ {name: "zero width passthrough", value: "abcdef", maxWidth: 0, expected: "abcdef"},
62+ {name: "width three all dots", value: "abcdef", maxWidth: 3, expected: "..."},
63+ {name: "ascii truncates", value: "abcdef", maxWidth: 5, expected: "ab..."},
64+ {name: "cjk truncates by display width", value: "你好世界", maxWidth: 5, expected: "你..."},
65+ {name: "grapheme-safe emoji truncation", value: "👨<U+200D>👩<U+200D>👧<U+200D>👦abcd", maxWidth: 5, expected: "👨<U+200D>👩<U+200D>👧<U+200D>👦..."},
66+ }
67+
68+ for _, tt := range tests {
69+ tt := tt
70+ t.Run(tt.name, func(t *testing.T) {
71+ got := truncateCell(tt.value, tt.maxWidth)
72+ assert.Equal(t, tt.expected, got)
73+ if tt.maxWidth > 0 {
74+ assert.LessOrEqual(t, runewidth.StringWidth(got), tt.maxWidth)
75+ }
76+ })
77+ }
78+}
79+
80+func TestTruncateCell_AnsiInputPassthrough(t *testing.T) {
81+ styled := "\x1b[32mactive\x1b[0m"
82+ got := truncateCell(styled, 3)
83+ assert.Equal(t, styled, got)
84+ assert.Contains(t, got, "\x1b[")
85+}
86+
87+func stripANSI(input string) string {
88+ ansiPattern := regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
89+ return ansiPattern.ReplaceAllString(input, "")
90+}
A internal/adapters/in/cli/ui_adoption_test.go +233 -0
@@ -0,0 +1,233 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "go/ast"
7+ "go/parser"
8+ "go/token"
9+ "io"
10+ "strings"
11+ "sync"
12+ "testing"
13+
14+ "github.com/bnema/gordon/internal/adapters/dto"
15+)
16+
17+var uiAdoptionHelperCalls = map[string]struct{}{
18+ "cliWriteLine": {},
19+ "cliWritef": {},
20+ "cliRenderTitle": {},
21+ "cliRenderMuted": {},
22+ "cliRenderEmptyState": {},
23+ "cliRenderListItem": {},
24+ "cliRenderMeta": {},
25+ "cliRenderSuccess": {},
26+ "cliRenderWarning": {},
27+ "cliRenderInfo": {},
28+}
29+
30+// uiAdoptionSeamMu guards mutations of the package-level cliWriteLine and
31+// cliWritef variables. Any test that overrides these seams must hold this
32+// mutex for the duration of the override and restore the originals on cleanup.
33+var uiAdoptionSeamMu sync.Mutex
34+
35+func TestPresentationHelpers(t *testing.T) {
36+ if got := cliRenderTitle("Title"); got == "" {
37+ t.Fatal("cliRenderTitle returned empty output")
38+ }
39+ if got := cliRenderEmptyState("none"); got == "" {
40+ t.Fatal("cliRenderEmptyState returned empty output")
41+ }
42+ if got := cliRenderSuccess("ok"); got == "" {
43+ t.Fatal("cliRenderSuccess returned empty output")
44+ }
45+ if got := cliRenderWarning("warn"); got == "" {
46+ t.Fatal("cliRenderWarning returned empty output")
47+ }
48+ if got := cliRenderInfo("info"); got == "" {
49+ t.Fatal("cliRenderInfo returned empty output")
50+ }
51+ if got := cliRenderListItem("item"); got == "" {
52+ t.Fatal("cliRenderListItem returned empty output")
53+ }
54+}
55+
56+func TestUIAdoption(t *testing.T) {
57+ for _, expect := range uiAdoptionExpectations {
58+ expect := expect
59+ t.Run(expect.family, func(t *testing.T) {
60+ fset := token.NewFileSet()
61+ fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors)
62+ if err != nil {
63+ t.Fatalf("failed to parse %s: %v", expect.file, err)
64+ }
65+
66+ for _, fnName := range expect.functions {
67+ fnName := fnName
68+ t.Run(fnName, func(t *testing.T) {
69+ fn := findFuncDecl(fileNode, fnName)
70+ if fn == nil {
71+ t.Fatalf("function %s not found in %s", fnName, expect.file)
72+ }
73+
74+ hasHelperCall := false
75+ hasSharedUIUsage := false
76+ hasForbiddenRawPrint := false
77+
78+ ast.Inspect(fn.Body, func(n ast.Node) bool {
79+ call, ok := n.(*ast.CallExpr)
80+ if !ok {
81+ return true
82+ }
83+
84+ switch fun := call.Fun.(type) {
85+ case *ast.Ident:
86+ if _, ok := uiAdoptionHelperCalls[fun.Name]; ok {
87+ hasHelperCall = true
88+ }
89+ case *ast.SelectorExpr:
90+ if isForbiddenRawPrintCall(call, fun) {
91+ hasForbiddenRawPrint = true
92+ }
93+ pkgIdent, ok := fun.X.(*ast.Ident)
94+ if !ok {
95+ return true
96+ }
97+ if pkgIdent.Name == "styles" || pkgIdent.Name == "components" {
98+ hasSharedUIUsage = true
99+ }
100+ }
101+
102+ return true
103+ })
104+
105+ if !hasHelperCall && !hasSharedUIUsage {
106+ t.Fatalf("%s in %s does not use presentation helpers or shared ui styles/components", fnName, expect.file)
107+ }
108+
109+ if hasForbiddenRawPrint {
110+ t.Fatalf("%s in %s still uses forbidden raw print calls", fnName, expect.file)
111+ }
112+ })
113+ }
114+ })
115+ }
116+}
117+
118+func isForbiddenRawPrintCall(call *ast.CallExpr, sel *ast.SelectorExpr) bool {
119+ pkgIdent, ok := sel.X.(*ast.Ident)
120+ if !ok {
121+ return false
122+ }
123+
124+ if pkgIdent.Name == "fmt" {
125+ switch sel.Sel.Name {
126+ case "Print", "Printf", "Println":
127+ return true
128+ case "Fprint", "Fprintf", "Fprintln":
129+ if len(call.Args) == 0 {
130+ return true
131+ }
132+
133+ switch dst := call.Args[0].(type) {
134+ case *ast.SelectorExpr:
135+ if dstPkg, ok := dst.X.(*ast.Ident); ok && dstPkg.Name == "os" && (dst.Sel.Name == "Stdout" || dst.Sel.Name == "Stderr") {
136+ return true
137+ }
138+ case *ast.Ident:
139+ if dst.Name == "out" {
140+ return true
141+ }
142+ }
143+ }
144+ }
145+
146+ if pkgIdent.Name == "cmd" && strings.HasPrefix(sel.Sel.Name, "Print") {
147+ return true
148+ }
149+
150+ return false
151+}
152+
153+type imagesClientStub struct {
154+ listResp []dto.Image
155+ pruneResp *dto.ImagePruneResponse
156+ err error
157+}
158+
159+func (s imagesClientStub) ListImages(context.Context) ([]dto.Image, error) {
160+ if s.err != nil {
161+ return nil, s.err
162+ }
163+ return s.listResp, nil
164+}
165+
166+func (s imagesClientStub) PruneImages(context.Context, dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
167+ if s.err != nil {
168+ return nil, s.err
169+ }
170+ return s.pruneResp, nil
171+}
172+
173+func TestUIAdoptionRuntimeSeams(t *testing.T) {
174+ uiAdoptionSeamMu.Lock()
175+ defer uiAdoptionSeamMu.Unlock()
176+
177+ origWriteLine := cliWriteLine
178+ origWritef := cliWritef
179+ defer func() {
180+ cliWriteLine = origWriteLine
181+ cliWritef = origWritef
182+ }()
183+
184+ lineCalls := 0
185+ writefCalls := 0
186+ cliWriteLine = func(w io.Writer, msg string) error {
187+ lineCalls++
188+ _, err := io.WriteString(w, msg+"\n")
189+ return err
190+ }
191+ cliWritef = func(w io.Writer, format string, args ...any) error {
192+ writefCalls++
193+ _, err := io.WriteString(w, "formatted\n")
194+ return err
195+ }
196+
197+ versionCmd := newVersionCmd()
198+ versionCmd.SetArgs(nil)
199+ versionCmd.SetOut(new(bytes.Buffer))
200+ if err := versionCmd.Execute(); err != nil {
201+ t.Fatalf("version command execution failed: %v", err)
202+ }
203+
204+ imgBuf := new(bytes.Buffer)
205+ if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf); err != nil {
206+ t.Fatalf("runImagesList failed: %v", err)
207+ }
208+
209+ pruneBuf := new(bytes.Buffer)
210+ if err := runImagesPrune(context.Background(), imagesClientStub{listResp: []dto.Image{}, pruneResp: &dto.ImagePruneResponse{}}, imagesPruneOptions{DryRun: true}, pruneBuf); err != nil {
211+ t.Fatalf("runImagesPrune failed: %v", err)
212+ }
213+
214+ if lineCalls == 0 {
215+ t.Fatal("expected cliWriteLine to be called")
216+ }
217+ if writefCalls == 0 {
218+ t.Fatal("expected cliWritef to be called")
219+ }
220+}
221+
222+func findFuncDecl(file *ast.File, name string) *ast.FuncDecl {
223+ for _, decl := range file.Decls {
224+ fn, ok := decl.(*ast.FuncDecl)
225+ if !ok {
226+ continue
227+ }
228+ if fn.Name.Name == name {
229+ return fn
230+ }
231+ }
232+ return nil
233+}
@@ -0,0 +1,233 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "go/ast"
7+ "go/parser"
8+ "go/token"
9+ "io"
10+ "strings"
11+ "sync"
12+ "testing"
13+
14+ "github.com/bnema/gordon/internal/adapters/dto"
15+)
16+
17+var uiAdoptionHelperCalls = map[string]struct{}{
18+ "cliWriteLine": {},
19+ "cliWritef": {},
20+ "cliRenderTitle": {},
21+ "cliRenderMuted": {},
22+ "cliRenderEmptyState": {},
23+ "cliRenderListItem": {},
24+ "cliRenderMeta": {},
25+ "cliRenderSuccess": {},
26+ "cliRenderWarning": {},
27+ "cliRenderInfo": {},
28+}
29+
30+// uiAdoptionSeamMu guards mutations of the package-level cliWriteLine and
31+// cliWritef variables. Any test that overrides these seams must hold this
32+// mutex for the duration of the override and restore the originals on cleanup.
33+var uiAdoptionSeamMu sync.Mutex
34+
35+func TestPresentationHelpers(t *testing.T) {
36+ if got := cliRenderTitle("Title"); got == "" {
37+ t.Fatal("cliRenderTitle returned empty output")
38+ }
39+ if got := cliRenderEmptyState("none"); got == "" {
40+ t.Fatal("cliRenderEmptyState returned empty output")
41+ }
42+ if got := cliRenderSuccess("ok"); got == "" {
43+ t.Fatal("cliRenderSuccess returned empty output")
44+ }
45+ if got := cliRenderWarning("warn"); got == "" {
46+ t.Fatal("cliRenderWarning returned empty output")
47+ }
48+ if got := cliRenderInfo("info"); got == "" {
49+ t.Fatal("cliRenderInfo returned empty output")
50+ }
51+ if got := cliRenderListItem("item"); got == "" {
52+ t.Fatal("cliRenderListItem returned empty output")
53+ }
54+}
55+
56+func TestUIAdoption(t *testing.T) {
57+ for _, expect := range uiAdoptionExpectations {
58+ expect := expect
59+ t.Run(expect.family, func(t *testing.T) {
60+ fset := token.NewFileSet()
61+ fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors)
62+ if err != nil {
63+ t.Fatalf("failed to parse %s: %v", expect.file, err)
64+ }
65+
66+ for _, fnName := range expect.functions {
67+ fnName := fnName
68+ t.Run(fnName, func(t *testing.T) {
69+ fn := findFuncDecl(fileNode, fnName)
70+ if fn == nil {
71+ t.Fatalf("function %s not found in %s", fnName, expect.file)
72+ }
73+
74+ hasHelperCall := false
75+ hasSharedUIUsage := false
76+ hasForbiddenRawPrint := false
77+
78+ ast.Inspect(fn.Body, func(n ast.Node) bool {
79+ call, ok := n.(*ast.CallExpr)
80+ if !ok {
81+ return true
82+ }
83+
84+ switch fun := call.Fun.(type) {
85+ case *ast.Ident:
86+ if _, ok := uiAdoptionHelperCalls[fun.Name]; ok {
87+ hasHelperCall = true
88+ }
89+ case *ast.SelectorExpr:
90+ if isForbiddenRawPrintCall(call, fun) {
91+ hasForbiddenRawPrint = true
92+ }
93+ pkgIdent, ok := fun.X.(*ast.Ident)
94+ if !ok {
95+ return true
96+ }
97+ if pkgIdent.Name == "styles" || pkgIdent.Name == "components" {
98+ hasSharedUIUsage = true
99+ }
100+ }
101+
102+ return true
103+ })
104+
105+ if !hasHelperCall && !hasSharedUIUsage {
106+ t.Fatalf("%s in %s does not use presentation helpers or shared ui styles/components", fnName, expect.file)
107+ }
108+
109+ if hasForbiddenRawPrint {
110+ t.Fatalf("%s in %s still uses forbidden raw print calls", fnName, expect.file)
111+ }
112+ })
113+ }
114+ })
115+ }
116+}
117+
118+func isForbiddenRawPrintCall(call *ast.CallExpr, sel *ast.SelectorExpr) bool {
119+ pkgIdent, ok := sel.X.(*ast.Ident)
120+ if !ok {
121+ return false
122+ }
123+
124+ if pkgIdent.Name == "fmt" {
125+ switch sel.Sel.Name {
126+ case "Print", "Printf", "Println":
127+ return true
128+ case "Fprint", "Fprintf", "Fprintln":
129+ if len(call.Args) == 0 {
130+ return true
131+ }
132+
133+ switch dst := call.Args[0].(type) {
134+ case *ast.SelectorExpr:
135+ if dstPkg, ok := dst.X.(*ast.Ident); ok && dstPkg.Name == "os" && (dst.Sel.Name == "Stdout" || dst.Sel.Name == "Stderr") {
136+ return true
137+ }
138+ case *ast.Ident:
139+ if dst.Name == "out" {
140+ return true
141+ }
142+ }
143+ }
144+ }
145+
146+ if pkgIdent.Name == "cmd" && strings.HasPrefix(sel.Sel.Name, "Print") {
147+ return true
148+ }
149+
150+ return false
151+}
152+
153+type imagesClientStub struct {
154+ listResp []dto.Image
155+ pruneResp *dto.ImagePruneResponse
156+ err error
157+}
158+
159+func (s imagesClientStub) ListImages(context.Context) ([]dto.Image, error) {
160+ if s.err != nil {
161+ return nil, s.err
162+ }
163+ return s.listResp, nil
164+}
165+
166+func (s imagesClientStub) PruneImages(context.Context, dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
167+ if s.err != nil {
168+ return nil, s.err
169+ }
170+ return s.pruneResp, nil
171+}
172+
173+func TestUIAdoptionRuntimeSeams(t *testing.T) {
174+ uiAdoptionSeamMu.Lock()
175+ defer uiAdoptionSeamMu.Unlock()
176+
177+ origWriteLine := cliWriteLine
178+ origWritef := cliWritef
179+ defer func() {
180+ cliWriteLine = origWriteLine
181+ cliWritef = origWritef
182+ }()
183+
184+ lineCalls := 0
185+ writefCalls := 0
186+ cliWriteLine = func(w io.Writer, msg string) error {
187+ lineCalls++
188+ _, err := io.WriteString(w, msg+"\n")
189+ return err
190+ }
191+ cliWritef = func(w io.Writer, format string, args ...any) error {
192+ writefCalls++
193+ _, err := io.WriteString(w, "formatted\n")
194+ return err
195+ }
196+
197+ versionCmd := newVersionCmd()
198+ versionCmd.SetArgs(nil)
199+ versionCmd.SetOut(new(bytes.Buffer))
200+ if err := versionCmd.Execute(); err != nil {
201+ t.Fatalf("version command execution failed: %v", err)
202+ }
203+
204+ imgBuf := new(bytes.Buffer)
205+ if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf); err != nil {
206+ t.Fatalf("runImagesList failed: %v", err)
207+ }
208+
209+ pruneBuf := new(bytes.Buffer)
210+ if err := runImagesPrune(context.Background(), imagesClientStub{listResp: []dto.Image{}, pruneResp: &dto.ImagePruneResponse{}}, imagesPruneOptions{DryRun: true}, pruneBuf); err != nil {
211+ t.Fatalf("runImagesPrune failed: %v", err)
212+ }
213+
214+ if lineCalls == 0 {
215+ t.Fatal("expected cliWriteLine to be called")
216+ }
217+ if writefCalls == 0 {
218+ t.Fatal("expected cliWritef to be called")
219+ }
220+}
221+
222+func findFuncDecl(file *ast.File, name string) *ast.FuncDecl {
223+ for _, decl := range file.Decls {
224+ fn, ok := decl.(*ast.FuncDecl)
225+ if !ok {
226+ continue
227+ }
228+ if fn.Name.Name == name {
229+ return fn
230+ }
231+ }
232+ return nil
233+}
M internal/adapters/in/http/admin/handler_test.go +83 -6
@@ -21,7 +21,7 @@ import (
2121
2222 type stubImageService struct {
2323 listImagesFunc func(context.Context) ([]domain.ImageInfo, error)
24- pruneFunc func(context.Context, int) (domain.ImagePruneReport, error)
24+ pruneFunc func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error)
2525 }
2626
2727 func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
@@ -31,11 +31,11 @@ func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo,
3131 return s.listImagesFunc(ctx)
3232 }
3333
34-func (s *stubImageService) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
34+func (s *stubImageService) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
3535 if s.pruneFunc == nil {
3636 return domain.ImagePruneReport{}, nil
3737 }
38- return s.pruneFunc(ctx, keepLast)
38+ return s.pruneFunc(ctx, opts)
3939 }
4040
4141 func testLogger() zerowrap.Logger {
@@ -1393,9 +1393,9 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
13931393 t.Run(tt.name, func(t *testing.T) {
13941394 called := false
13951395 imageSvc := &stubImageService{
1396- pruneFunc: func(_ context.Context, keepLast int) (domain.ImagePruneReport, error) {
1396+ pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
13971397 called = true
1398- assert.Equal(t, tt.expectedKeepLast, keepLast)
1398+ assert.Equal(t, tt.expectedKeepLast, opts.KeepLast)
13991399 return domain.ImagePruneReport{
14001400 Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
14011401 Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048},
@@ -1452,7 +1452,7 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
14521452 secretSvc := inmocks.NewMockSecretService(t)
14531453
14541454 imageSvc := &stubImageService{
1455- pruneFunc: func(context.Context, int) (domain.ImagePruneReport, error) {
1455+ pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
14561456 return domain.ImagePruneReport{}, errors.New("prune failed: confidential details")
14571457 },
14581458 }
@@ -1509,6 +1509,83 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
15091509 })
15101510 }
15111511
1512+func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) {
1513+ configSvc := inmocks.NewMockConfigService(t)
1514+ authSvc := inmocks.NewMockAuthService(t)
1515+ containerSvc := inmocks.NewMockContainerService(t)
1516+ secretSvc := inmocks.NewMockSecretService(t)
1517+
1518+ tests := []struct {
1519+ name string
1520+ body string
1521+ expectedPruneDangling bool
1522+ expectedPruneRegistry bool
1523+ expectedKeepLast int
1524+ wantStatus int
1525+ }{
1526+ {
1527+ name: "missing scope fields defaults to both true",
1528+ body: `{}`,
1529+ expectedPruneDangling: true,
1530+ expectedPruneRegistry: true,
1531+ expectedKeepLast: domain.DefaultImagePruneKeepLast,
1532+ wantStatus: http.StatusOK,
1533+ },
1534+ {
1535+ name: "explicit dangling only",
1536+ body: `{"prune_dangling": true, "prune_registry": false}`,
1537+ expectedPruneDangling: true,
1538+ expectedPruneRegistry: false,
1539+ expectedKeepLast: domain.DefaultImagePruneKeepLast,
1540+ wantStatus: http.StatusOK,
1541+ },
1542+ {
1543+ name: "explicit registry only",
1544+ body: `{"prune_dangling": false, "prune_registry": true, "keep_last": 5}`,
1545+ expectedPruneDangling: false,
1546+ expectedPruneRegistry: true,
1547+ expectedKeepLast: 5,
1548+ wantStatus: http.StatusOK,
1549+ },
1550+ {
1551+ name: "both scopes false returns 400",
1552+ body: `{"prune_dangling": false, "prune_registry": false}`,
1553+ wantStatus: http.StatusBadRequest,
1554+ },
1555+ }
1556+
1557+ for _, tt := range tests {
1558+ t.Run(tt.name, func(t *testing.T) {
1559+ called := false
1560+ imageSvc := &stubImageService{
1561+ pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
1562+ called = true
1563+ assert.Equal(t, tt.expectedPruneDangling, opts.PruneDangling)
1564+ assert.Equal(t, tt.expectedPruneRegistry, opts.PruneRegistry)
1565+ assert.Equal(t, tt.expectedKeepLast, opts.KeepLast)
1566+ return domain.ImagePruneReport{}, nil
1567+ },
1568+ }
1569+
1570+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1571+
1572+ req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
1573+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1574+ rec := httptest.NewRecorder()
1575+
1576+ handler.ServeHTTP(rec, req)
1577+
1578+ assert.Equal(t, tt.wantStatus, rec.Code)
1579+ if tt.wantStatus == http.StatusOK {
1580+ assert.True(t, called, "prune should have been called")
1581+ }
1582+ if tt.wantStatus == http.StatusBadRequest {
1583+ assert.Contains(t, rec.Body.String(), "at least one prune scope")
1584+ }
1585+ })
1586+ }
1587+}
1588+
15121589 func TestHandler_Images_Authorization(t *testing.T) {
15131590 configSvc := inmocks.NewMockConfigService(t)
15141591 authSvc := inmocks.NewMockAuthService(t)
@@ -21,7 +21,7 @@ import (
21 21
22 type stubImageService struct {22 type stubImageService struct {
23 listImagesFunc func(context.Context) ([]domain.ImageInfo, error)23 listImagesFunc func(context.Context) ([]domain.ImageInfo, error)
24- pruneFunc func(context.Context, int) (domain.ImagePruneReport, error)24+ pruneFunc func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error)
25 }25 }
26 26
27 func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {27 func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
@@ -31,11 +31,11 @@ func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo,
31 return s.listImagesFunc(ctx)31 return s.listImagesFunc(ctx)
32 }32 }
33 33
34-func (s *stubImageService) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {34+func (s *stubImageService) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
35 if s.pruneFunc == nil {35 if s.pruneFunc == nil {
36 return domain.ImagePruneReport{}, nil36 return domain.ImagePruneReport{}, nil
37 }37 }
38- return s.pruneFunc(ctx, keepLast)38+ return s.pruneFunc(ctx, opts)
39 }39 }
40 40
41 func testLogger() zerowrap.Logger {41 func testLogger() zerowrap.Logger {
@@ -1393,9 +1393,9 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
1393 t.Run(tt.name, func(t *testing.T) {1393 t.Run(tt.name, func(t *testing.T) {
1394 called := false1394 called := false
1395 imageSvc := &stubImageService{1395 imageSvc := &stubImageService{
1396- pruneFunc: func(_ context.Context, keepLast int) (domain.ImagePruneReport, error) {1396+ pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
1397 called = true1397 called = true
1398- assert.Equal(t, tt.expectedKeepLast, keepLast)1398+ assert.Equal(t, tt.expectedKeepLast, opts.KeepLast)
1399 return domain.ImagePruneReport{1399 return domain.ImagePruneReport{
1400 Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},1400 Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
1401 Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048},1401 Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048},
@@ -1452,7 +1452,7 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1452 secretSvc := inmocks.NewMockSecretService(t)1452 secretSvc := inmocks.NewMockSecretService(t)
1453 1453
1454 imageSvc := &stubImageService{1454 imageSvc := &stubImageService{
1455- pruneFunc: func(context.Context, int) (domain.ImagePruneReport, error) {1455+ pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
1456 return domain.ImagePruneReport{}, errors.New("prune failed: confidential details")1456 return domain.ImagePruneReport{}, errors.New("prune failed: confidential details")
1457 },1457 },
1458 }1458 }
@@ -1509,6 +1509,83 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
1509 })1509 })
1510 }1510 }
1511 1511
1512+func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) {
1513+ configSvc := inmocks.NewMockConfigService(t)
1514+ authSvc := inmocks.NewMockAuthService(t)
1515+ containerSvc := inmocks.NewMockContainerService(t)
1516+ secretSvc := inmocks.NewMockSecretService(t)
1517+
1518+ tests := []struct {
1519+ name string
1520+ body string
1521+ expectedPruneDangling bool
1522+ expectedPruneRegistry bool
1523+ expectedKeepLast int
1524+ wantStatus int
1525+ }{
1526+ {
1527+ name: "missing scope fields defaults to both true",
1528+ body: `{}`,
1529+ expectedPruneDangling: true,
1530+ expectedPruneRegistry: true,
1531+ expectedKeepLast: domain.DefaultImagePruneKeepLast,
1532+ wantStatus: http.StatusOK,
1533+ },
1534+ {
1535+ name: "explicit dangling only",
1536+ body: `{"prune_dangling": true, "prune_registry": false}`,
1537+ expectedPruneDangling: true,
1538+ expectedPruneRegistry: false,
1539+ expectedKeepLast: domain.DefaultImagePruneKeepLast,
1540+ wantStatus: http.StatusOK,
1541+ },
1542+ {
1543+ name: "explicit registry only",
1544+ body: `{"prune_dangling": false, "prune_registry": true, "keep_last": 5}`,
1545+ expectedPruneDangling: false,
1546+ expectedPruneRegistry: true,
1547+ expectedKeepLast: 5,
1548+ wantStatus: http.StatusOK,
1549+ },
1550+ {
1551+ name: "both scopes false returns 400",
1552+ body: `{"prune_dangling": false, "prune_registry": false}`,
1553+ wantStatus: http.StatusBadRequest,
1554+ },
1555+ }
1556+
1557+ for _, tt := range tests {
1558+ t.Run(tt.name, func(t *testing.T) {
1559+ called := false
1560+ imageSvc := &stubImageService{
1561+ pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
1562+ called = true
1563+ assert.Equal(t, tt.expectedPruneDangling, opts.PruneDangling)
1564+ assert.Equal(t, tt.expectedPruneRegistry, opts.PruneRegistry)
1565+ assert.Equal(t, tt.expectedKeepLast, opts.KeepLast)
1566+ return domain.ImagePruneReport{}, nil
1567+ },
1568+ }
1569+
1570+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1571+
1572+ req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
1573+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1574+ rec := httptest.NewRecorder()
1575+
1576+ handler.ServeHTTP(rec, req)
1577+
1578+ assert.Equal(t, tt.wantStatus, rec.Code)
1579+ if tt.wantStatus == http.StatusOK {
1580+ assert.True(t, called, "prune should have been called")
1581+ }
1582+ if tt.wantStatus == http.StatusBadRequest {
1583+ assert.Contains(t, rec.Body.String(), "at least one prune scope")
1584+ }
1585+ })
1586+ }
1587+}
1588+
1512 func TestHandler_Images_Authorization(t *testing.T) {1589 func TestHandler_Images_Authorization(t *testing.T) {
1513 configSvc := inmocks.NewMockConfigService(t)1590 configSvc := inmocks.NewMockConfigService(t)
1514 authSvc := inmocks.NewMockAuthService(t)1591 authSvc := inmocks.NewMockAuthService(t)
M internal/adapters/in/http/admin/images.go +14 -4
@@ -97,18 +97,28 @@ func (h *Handler) handleImagesPrune(w http.ResponseWriter, r *http.Request) {
9797 return
9898 }
9999
100- keepLast := domain.DefaultImagePruneKeepLast
100+ opts := domain.DefaultImagePruneOptions()
101101 if req.KeepLast != nil {
102102 if *req.KeepLast < 0 {
103103 h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0")
104104 return
105105 }
106- keepLast = *req.KeepLast
106+ opts.KeepLast = *req.KeepLast
107+ }
108+ if req.PruneDangling != nil {
109+ opts.PruneDangling = *req.PruneDangling
110+ }
111+ if req.PruneRegistry != nil {
112+ opts.PruneRegistry = *req.PruneRegistry
113+ }
114+ if !opts.PruneDangling && !opts.PruneRegistry {
115+ h.sendError(w, http.StatusBadRequest, "at least one prune scope must be enabled")
116+ return
107117 }
108118
109- report, err := h.imageSvc.Prune(ctx, keepLast)
119+ report, err := h.imageSvc.Prune(ctx, opts)
110120 if err != nil {
111- log.Error().Err(err).Int("keep_last", keepLast).Msg("image prune failed")
121+ log.Error().Err(err).Int("keep_last", opts.KeepLast).Msg("image prune failed")
112122 h.sendError(w, http.StatusInternalServerError, "failed to prune images")
113123 return
114124 }
@@ -97,18 +97,28 @@ func (h *Handler) handleImagesPrune(w http.ResponseWriter, r *http.Request) {
97 return97 return
98 }98 }
99 99
100- keepLast := domain.DefaultImagePruneKeepLast100+ opts := domain.DefaultImagePruneOptions()
101 if req.KeepLast != nil {101 if req.KeepLast != nil {
102 if *req.KeepLast < 0 {102 if *req.KeepLast < 0 {
103 h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0")103 h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0")
104 return104 return
105 }105 }
106- keepLast = *req.KeepLast106+ opts.KeepLast = *req.KeepLast
107+ }
108+ if req.PruneDangling != nil {
109+ opts.PruneDangling = *req.PruneDangling
110+ }
111+ if req.PruneRegistry != nil {
112+ opts.PruneRegistry = *req.PruneRegistry
113+ }
114+ if !opts.PruneDangling && !opts.PruneRegistry {
115+ h.sendError(w, http.StatusBadRequest, "at least one prune scope must be enabled")
116+ return
107 }117 }
108 118
109- report, err := h.imageSvc.Prune(ctx, keepLast)119+ report, err := h.imageSvc.Prune(ctx, opts)
110 if err != nil {120 if err != nil {
111- log.Error().Err(err).Int("keep_last", keepLast).Msg("image prune failed")121+ log.Error().Err(err).Int("keep_last", opts.KeepLast).Msg("image prune failed")
112 h.sendError(w, http.StatusInternalServerError, "failed to prune images")122 h.sendError(w, http.StatusInternalServerError, "failed to prune images")
113 return123 return
114 }124 }
M internal/app/run.go +5 -1
@@ -1812,7 +1812,11 @@ func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, lo
18121812 keepLast = domain.DefaultImagePruneKeepLast
18131813 }
18141814
1815- report, err := svc.imageSvc.Prune(jobCtx, keepLast)
1815+ report, err := svc.imageSvc.Prune(jobCtx, domain.ImagePruneOptions{
1816+ KeepLast: keepLast,
1817+ PruneDangling: true,
1818+ PruneRegistry: true,
1819+ })
18161820 if err != nil {
18171821 return err
18181822 }
@@ -1812,7 +1812,11 @@ func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, lo
1812 keepLast = domain.DefaultImagePruneKeepLast1812 keepLast = domain.DefaultImagePruneKeepLast
1813 }1813 }
1814 1814
1815- report, err := svc.imageSvc.Prune(jobCtx, keepLast)1815+ report, err := svc.imageSvc.Prune(jobCtx, domain.ImagePruneOptions{
1816+ KeepLast: keepLast,
1817+ PruneDangling: true,
1818+ PruneRegistry: true,
1819+ })
1816 if err != nil {1820 if err != nil {
1817 return err1821 return err
1818 }1822 }
M internal/boundaries/in/images.go +2 -2
@@ -12,6 +12,6 @@ type ImageService interface {
1212 // ListImages returns runtime images and known registry tags.
1313 ListImages(ctx context.Context) ([]domain.ImageInfo, error)
1414
15- // Prune removes unused images and applies retention.
16- Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error)
15+ // Prune removes unused images and applies retention based on options.
16+ Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error)
1717 }
@@ -12,6 +12,6 @@ type ImageService interface {
12 // ListImages returns runtime images and known registry tags.12 // ListImages returns runtime images and known registry tags.
13 ListImages(ctx context.Context) ([]domain.ImageInfo, error)13 ListImages(ctx context.Context) ([]domain.ImageInfo, error)
14 14
15- // Prune removes unused images and applies retention.15+ // Prune removes unused images and applies retention based on options.
16- Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error)16+ Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error)
17 }17 }
M internal/domain/images.go +19 -0
@@ -4,6 +4,25 @@ import "time"
44
55 const DefaultImagePruneKeepLast = 3
66
7+// ImagePruneOptions controls which prune subsystems are activated.
8+type ImagePruneOptions struct {
9+ // KeepLast is the number of most-recent non-latest tags to retain per repository.
10+ KeepLast int
11+ // PruneDangling enables runtime dangling image cleanup.
12+ PruneDangling bool
13+ // PruneRegistry enables registry tag retention and blob garbage collection.
14+ PruneRegistry bool
15+}
16+
17+// DefaultImagePruneOptions returns options that prune both scopes with the default retention.
18+func DefaultImagePruneOptions() ImagePruneOptions {
19+ return ImagePruneOptions{
20+ KeepLast: DefaultImagePruneKeepLast,
21+ PruneDangling: true,
22+ PruneRegistry: true,
23+ }
24+}
25+
726 // ImagePruneConfig defines scheduled image cleanup behavior.
827 type ImagePruneConfig struct {
928 Enabled bool
@@ -4,6 +4,25 @@ import "time"
4 4
5 const DefaultImagePruneKeepLast = 35 const DefaultImagePruneKeepLast = 3
6 6
7+// ImagePruneOptions controls which prune subsystems are activated.
8+type ImagePruneOptions struct {
9+ // KeepLast is the number of most-recent non-latest tags to retain per repository.
10+ KeepLast int
11+ // PruneDangling enables runtime dangling image cleanup.
12+ PruneDangling bool
13+ // PruneRegistry enables registry tag retention and blob garbage collection.
14+ PruneRegistry bool
15+}
16+
17+// DefaultImagePruneOptions returns options that prune both scopes with the default retention.
18+func DefaultImagePruneOptions() ImagePruneOptions {
19+ return ImagePruneOptions{
20+ KeepLast: DefaultImagePruneKeepLast,
21+ PruneDangling: true,
22+ PruneRegistry: true,
23+ }
24+}
25+
7 // ImagePruneConfig defines scheduled image cleanup behavior.26 // ImagePruneConfig defines scheduled image cleanup behavior.
8 type ImagePruneConfig struct {27 type ImagePruneConfig struct {
9 Enabled bool28 Enabled bool
M internal/usecase/images/service.go +23 -12
@@ -4,6 +4,7 @@ package images
44 import (
55 "context"
66 "encoding/json"
7+ "fmt"
78 "sort"
89 "strings"
910 "time"
@@ -331,30 +332,40 @@ func (s *Service) collectKeptTagDigests(
331332 return nil
332333 }
333334
334-// Prune runs runtime prune and registry prune and aggregates reports.
335-func (s *Service) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
335+// Prune runs runtime and/or registry prune based on options and aggregates reports.
336+func (s *Service) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
336337 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
337338 zerowrap.FieldLayer: "usecase",
338339 zerowrap.FieldUseCase: "Prune",
339- "keepLast": keepLast,
340+ "keepLast": opts.KeepLast,
341+ "pruneDangling": opts.PruneDangling,
342+ "pruneRegistry": opts.PruneRegistry,
340343 })
341344 log := zerowrap.FromCtx(ctx)
342345
343346 var report domain.ImagePruneReport
344347
345- runtimeReport, err := s.PruneRuntime(ctx)
346- if err != nil {
347- log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune")
348- } else {
349- report.Runtime = runtimeReport.Runtime
348+ if opts.PruneDangling {
349+ runtimeReport, err := s.PruneRuntime(ctx)
350+ if err != nil {
351+ if !opts.PruneRegistry {
352+ // Dangling-only: surface the error to the caller.
353+ return report, fmt.Errorf("runtime prune failed: %w", err)
354+ }
355+ log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune")
356+ } else {
357+ report.Runtime = runtimeReport.Runtime
358+ }
350359 }
351360
352- registryReport, err := s.PruneRegistry(ctx, keepLast)
353- if err != nil {
354- return report, err
361+ if opts.PruneRegistry {
362+ registryReport, err := s.PruneRegistry(ctx, opts.KeepLast)
363+ if err != nil {
364+ return report, err
365+ }
366+ report.Registry = registryReport.Registry
355367 }
356368
357- report.Registry = registryReport.Registry
358369 return report, nil
359370 }
360371
@@ -4,6 +4,7 @@ package images
4 import (4 import (
5 "context"5 "context"
6 "encoding/json"6 "encoding/json"
7+ "fmt"
7 "sort"8 "sort"
8 "strings"9 "strings"
9 "time"10 "time"
@@ -331,30 +332,40 @@ func (s *Service) collectKeptTagDigests(
331 return nil332 return nil
332 }333 }
333 334
334-// Prune runs runtime prune and registry prune and aggregates reports.335+// Prune runs runtime and/or registry prune based on options and aggregates reports.
335-func (s *Service) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {336+func (s *Service) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
336 ctx = zerowrap.CtxWithFields(ctx, map[string]any{337 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
337 zerowrap.FieldLayer: "usecase",338 zerowrap.FieldLayer: "usecase",
338 zerowrap.FieldUseCase: "Prune",339 zerowrap.FieldUseCase: "Prune",
339- "keepLast": keepLast,340+ "keepLast": opts.KeepLast,
341+ "pruneDangling": opts.PruneDangling,
342+ "pruneRegistry": opts.PruneRegistry,
340 })343 })
341 log := zerowrap.FromCtx(ctx)344 log := zerowrap.FromCtx(ctx)
342 345
343 var report domain.ImagePruneReport346 var report domain.ImagePruneReport
344 347
345- runtimeReport, err := s.PruneRuntime(ctx)348+ if opts.PruneDangling {
346- if err != nil {349+ runtimeReport, err := s.PruneRuntime(ctx)
347- log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune")350+ if err != nil {
348- } else {351+ if !opts.PruneRegistry {
349- report.Runtime = runtimeReport.Runtime352+ // Dangling-only: surface the error to the caller.
353+ return report, fmt.Errorf("runtime prune failed: %w", err)
354+ }
355+ log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune")
356+ } else {
357+ report.Runtime = runtimeReport.Runtime
358+ }
350 }359 }
351 360
352- registryReport, err := s.PruneRegistry(ctx, keepLast)361+ if opts.PruneRegistry {
353- if err != nil {362+ registryReport, err := s.PruneRegistry(ctx, opts.KeepLast)
354- return report, err363+ if err != nil {
364+ return report, err
365+ }
366+ report.Registry = registryReport.Registry
355 }367 }
356 368
357- report.Registry = registryReport.Registry
358 return report, nil369 return report, nil
359 }370 }
360 371
M internal/usecase/images/service_test.go +64 -2
@@ -469,7 +469,9 @@ func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
469469
470470 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
471471
472- report, err := svc.Prune(context.Background(), 1)
472+ report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
473+ KeepLast: 1, PruneDangling: true, PruneRegistry: true,
474+ })
473475
474476 require.NoError(t, err)
475477 assert.Equal(t, 1, report.Runtime.DeletedCount)
@@ -494,7 +496,9 @@ func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
494496
495497 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
496498
497- report, err := svc.Prune(context.Background(), 1)
499+ report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
500+ KeepLast: 1, PruneDangling: true, PruneRegistry: true,
501+ })
498502
499503 require.NoError(t, err)
500504 assert.Equal(t, 0, report.Runtime.DeletedCount)
@@ -505,6 +509,64 @@ func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
505509 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
506510 }
507511
512+func TestService_Prune_DanglingOnlySkipsRegistry(t *testing.T) {
513+ manifestStorage := newFakeManifestStorage()
514+ manifestStorage.repositories = []string{"gordon/api"}
515+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
516+ blobStorage := &fakeBlobStorage{}
517+ rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 512}}
518+
519+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
520+
521+ report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
522+ KeepLast: 3, PruneDangling: true, PruneRegistry: false,
523+ })
524+
525+ require.NoError(t, err)
526+ assert.True(t, rt.pruneCalled, "runtime prune should have been called")
527+ assert.Equal(t, 1, report.Runtime.DeletedCount)
528+ assert.Equal(t, 0, report.Registry.TagsRemoved, "registry should not be pruned")
529+ assert.Equal(t, 0, manifestStorage.listRepositoriesCalls, "should not list repositories when registry disabled")
530+}
531+
532+func TestService_Prune_DanglingOnlyRuntimeFailureReturnsError(t *testing.T) {
533+ rt := &fakeRuntime{pruneErr: errors.New("docker daemon unavailable")}
534+ svc := NewService(rt, newFakeManifestStorage(), &fakeBlobStorage{}, zerowrap.Default())
535+
536+ _, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
537+ KeepLast: 3, PruneDangling: true, PruneRegistry: false,
538+ })
539+
540+ require.Error(t, err)
541+ assert.Contains(t, err.Error(), "runtime prune failed")
542+ assert.Contains(t, err.Error(), "docker daemon unavailable")
543+}
544+
545+func TestService_Prune_RegistryOnlySkipsRuntime(t *testing.T) {
546+ manifestStorage := newFakeManifestStorage()
547+ manifestStorage.repositories = []string{"gordon/api"}
548+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
549+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
550+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
551+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
552+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
553+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
554+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
555+ rt := &fakeRuntime{}
556+
557+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
558+
559+ report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
560+ KeepLast: 1, PruneDangling: false, PruneRegistry: true,
561+ })
562+
563+ require.NoError(t, err)
564+ assert.False(t, rt.pruneCalled, "runtime prune should not have been called")
565+ assert.Equal(t, 0, report.Runtime.DeletedCount)
566+ assert.Equal(t, 1, report.Registry.TagsRemoved)
567+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
568+}
569+
508570 type fakeRuntime struct {
509571 pkgruntime.Runtime
510572
@@ -469,7 +469,9 @@ func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
469 469
470 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())470 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
471 471
472- report, err := svc.Prune(context.Background(), 1)472+ report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
473+ KeepLast: 1, PruneDangling: true, PruneRegistry: true,
474+ })
473 475
474 require.NoError(t, err)476 require.NoError(t, err)
475 assert.Equal(t, 1, report.Runtime.DeletedCount)477 assert.Equal(t, 1, report.Runtime.DeletedCount)
@@ -494,7 +496,9 @@ func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
494 496
495 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())497 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
496 498
497- report, err := svc.Prune(context.Background(), 1)499+ report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
500+ KeepLast: 1, PruneDangling: true, PruneRegistry: true,
501+ })
498 502
499 require.NoError(t, err)503 require.NoError(t, err)
500 assert.Equal(t, 0, report.Runtime.DeletedCount)504 assert.Equal(t, 0, report.Runtime.DeletedCount)
@@ -505,6 +509,64 @@ func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
505 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)509 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
506 }510 }
507 511
512+func TestService_Prune_DanglingOnlySkipsRegistry(t *testing.T) {
513+ manifestStorage := newFakeManifestStorage()
514+ manifestStorage.repositories = []string{"gordon/api"}
515+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
516+ blobStorage := &fakeBlobStorage{}
517+ rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 512}}
518+
519+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
520+
521+ report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
522+ KeepLast: 3, PruneDangling: true, PruneRegistry: false,
523+ })
524+
525+ require.NoError(t, err)
526+ assert.True(t, rt.pruneCalled, "runtime prune should have been called")
527+ assert.Equal(t, 1, report.Runtime.DeletedCount)
528+ assert.Equal(t, 0, report.Registry.TagsRemoved, "registry should not be pruned")
529+ assert.Equal(t, 0, manifestStorage.listRepositoriesCalls, "should not list repositories when registry disabled")
530+}
531+
532+func TestService_Prune_DanglingOnlyRuntimeFailureReturnsError(t *testing.T) {
533+ rt := &fakeRuntime{pruneErr: errors.New("docker daemon unavailable")}
534+ svc := NewService(rt, newFakeManifestStorage(), &fakeBlobStorage{}, zerowrap.Default())
535+
536+ _, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
537+ KeepLast: 3, PruneDangling: true, PruneRegistry: false,
538+ })
539+
540+ require.Error(t, err)
541+ assert.Contains(t, err.Error(), "runtime prune failed")
542+ assert.Contains(t, err.Error(), "docker daemon unavailable")
543+}
544+
545+func TestService_Prune_RegistryOnlySkipsRuntime(t *testing.T) {
546+ manifestStorage := newFakeManifestStorage()
547+ manifestStorage.repositories = []string{"gordon/api"}
548+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
549+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
550+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
551+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
552+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
553+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
554+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
555+ rt := &fakeRuntime{}
556+
557+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
558+
559+ report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
560+ KeepLast: 1, PruneDangling: false, PruneRegistry: true,
561+ })
562+
563+ require.NoError(t, err)
564+ assert.False(t, rt.pruneCalled, "runtime prune should not have been called")
565+ assert.Equal(t, 0, report.Runtime.DeletedCount)
566+ assert.Equal(t, 1, report.Registry.TagsRemoved)
567+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
568+}
569+
508 type fakeRuntime struct {570 type fakeRuntime struct {
509 pkgruntime.Runtime571 pkgruntime.Runtime
510 572

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