From c41815f17ac3c50610eff5c266e837312470e91f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Wed, 15 Oct 2025 14:21:00 +0200 Subject: [PATCH 1/5] image/list: Show collapsed tree by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the new tree view by default and only fallback if format or old view-related options are used. The expanded view is shown when `--tree` is passed. Signed-off-by: Paweł Gronowski --- cli/command/image/list.go | 57 ++++++++++++++++++++++++++------------- cli/command/image/tree.go | 52 +++++++++++++++++++++++------------ 2 files changed, 73 insertions(+), 36 deletions(-) diff --git a/cli/command/image/list.go b/cli/command/image/list.go index fde226b6095c..6ad7798d88c4 100644 --- a/cli/command/image/list.go +++ b/cli/command/image/list.go @@ -82,38 +82,28 @@ func newListCommand(dockerCLI command.Cli) *cobra.Command { return &cmd } -//nolint:gocyclo func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions) error { filters := options.filter.Value() if options.matchName != "" { filters.Add("reference", options.matchName) } - if options.tree { - if options.quiet { - return errors.New("--quiet is not yet supported with --tree") - } - if options.noTrunc { - return errors.New("--no-trunc is not yet supported with --tree") - } - if options.showDigests { - return errors.New("--show-digest is not yet supported with --tree") - } - if options.format != "" { - return errors.New("--format is not yet supported with --tree") - } + useTree, err := shouldUseTree(options) + if err != nil { + return err } listOpts := client.ImageListOptions{ All: options.all, Filters: filters, - Manifests: options.tree, + Manifests: useTree, } res, err := dockerCLI.Client().ImageList(ctx, listOpts) if err != nil { return err } + images := res.Items if !options.all { if _, ok := filters["dangling"]; !ok { @@ -121,11 +111,12 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions } } - if options.tree { + if useTree { return runTree(ctx, dockerCLI, treeOptions{ - images: images, - all: options.all, - filters: filters, + images: images, + all: options.all, + filters: filters, + expanded: options.tree, }) } @@ -155,6 +146,34 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions return nil } +func shouldUseTree(options imagesOptions) (bool, error) { + if options.quiet { + if options.tree { + return false, errors.New("--quiet is not yet supported with --tree") + } + return false, nil + } + if options.noTrunc { + if options.tree { + return false, errors.New("--no-trunc is not yet supported with --tree") + } + return false, nil + } + if options.showDigests { + if options.tree { + return false, errors.New("--show-digest is not yet supported with --tree") + } + return false, nil + } + if options.format != "" { + if options.tree { + return false, errors.New("--format is not yet supported with --tree") + } + return false, nil + } + return true, nil +} + // isDangling is a copy of [formatter.isDangling]. func isDangling(img image.Summary) bool { if len(img.RepoTags) == 0 && len(img.RepoDigests) == 0 { diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go index c1586af58d47..d356aef80891 100644 --- a/cli/command/image/tree.go +++ b/cli/command/image/tree.go @@ -24,9 +24,10 @@ import ( ) type treeOptions struct { - images []imagetypes.Summary - all bool - filters client.Filters + images []imagetypes.Summary + all bool + filters client.Filters + expanded bool } type treeView struct { @@ -48,7 +49,7 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error if ctx.Err() != nil { return ctx.Err() } - details := imageDetails{ + topDetails := imageDetails{ ID: img.ID, DiskUsage: units.HumanSizeWithPrecision(float64(img.Size), 3), InUse: img.Containers > 0, @@ -67,41 +68,58 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error continue } + inUse := len(im.ImageData.Containers) > 0 + if inUse { + // Mark top-level parent image as used if any of its subimages are used. + topDetails.InUse = true + } + + if !opts.expanded { + continue + } + sub := subImage{ Platform: platforms.Format(im.ImageData.Platform), Available: im.Available, Details: imageDetails{ ID: im.ID, DiskUsage: units.HumanSizeWithPrecision(float64(im.Size.Total), 3), - InUse: len(im.ImageData.Containers) > 0, + InUse: inUse, ContentSize: units.HumanSizeWithPrecision(float64(im.Size.Content), 3), }, } - if sub.Details.InUse { - // Mark top-level parent image as used if any of its subimages are used. - details.InUse = true - } - children = append(children, sub) // Add extra spacing between images if there's at least one entry with children. view.imageSpacing = true } - details.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3) + topDetails.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3) // Sort tags for this image sortedTags := make([]string, len(img.RepoTags)) copy(sortedTags, img.RepoTags) slices.Sort(sortedTags) - view.images = append(view.images, topImage{ - Names: sortedTags, - Details: details, - Children: children, - created: img.Created, - }) + if opts.expanded { + view.images = append(view.images, topImage{ + Names: sortedTags, + Details: topDetails, + Children: children, + created: img.Created, + }) + continue + } + + for _, tag := range sortedTags { + view.images = append(view.images, topImage{ + Names: []string{tag}, + Details: topDetails, + Children: children, + created: img.Created, + }) + } } slices.SortFunc(view.images, func(a, b topImage) int { From 631f32ee9d90522e2247831d82dbc515e527e235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Wed, 15 Oct 2025 15:03:45 +0200 Subject: [PATCH 2/5] images/list: Add print ambiguous warning for tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Paweł Gronowski --- cli/command/image/list.go | 27 +++++++++++++-------------- cli/command/image/tree.go | 12 +++++------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/cli/command/image/list.go b/cli/command/image/list.go index 6ad7798d88c4..6520bd0a84f7 100644 --- a/cli/command/image/list.go +++ b/cli/command/image/list.go @@ -29,7 +29,6 @@ type imagesOptions struct { showDigests bool format string filter opts.FilterOpt - calledAs string tree bool } @@ -45,11 +44,14 @@ func newImagesCommand(dockerCLI command.Cli) *cobra.Command { if len(args) > 0 { options.matchName = args[0] } - // Pass through how the command was invoked. We use this to print - // warnings when an ambiguous argument was passed when using the - // legacy (top-level) "docker images" subcommand. - options.calledAs = cmd.CalledAs() - return runImages(cmd.Context(), dockerCLI, options) + numImages, err := runImages(cmd.Context(), dockerCLI, options) + if err != nil { + return err + } + if numImages == 0 && options.matchName != "" && cmd.CalledAs() == "images" { + printAmbiguousHint(dockerCLI.Err(), options.matchName) + } + return nil }, Annotations: map[string]string{ "category-top": "7", @@ -82,7 +84,7 @@ func newListCommand(dockerCLI command.Cli) *cobra.Command { return &cmd } -func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions) error { +func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions) (int, error) { filters := options.filter.Value() if options.matchName != "" { filters.Add("reference", options.matchName) @@ -90,7 +92,7 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions useTree, err := shouldUseTree(options) if err != nil { - return err + return 0, err } listOpts := client.ImageListOptions{ @@ -101,7 +103,7 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions res, err := dockerCLI.Client().ImageList(ctx, listOpts) if err != nil { - return err + return 0, err } images := res.Items @@ -138,12 +140,9 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions Digest: options.showDigests, } if err := formatter.ImageWrite(imageCtx, images); err != nil { - return err - } - if options.matchName != "" && len(images) == 0 && options.calledAs == "images" { - printAmbiguousHint(dockerCLI.Err(), options.matchName) + return 0, err } - return nil + return len(images), nil } func shouldUseTree(options imagesOptions) (bool, error) { diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go index d356aef80891..a2686e2939cb 100644 --- a/cli/command/image/tree.go +++ b/cli/command/image/tree.go @@ -37,7 +37,7 @@ type treeView struct { imageSpacing bool } -func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error { +func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) (int, error) { images := opts.images view := treeView{ @@ -47,7 +47,7 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error for _, img := range images { if ctx.Err() != nil { - return ctx.Err() + return 0, ctx.Err() } topDetails := imageDetails{ ID: img.ID, @@ -141,7 +141,8 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error return strings.Compare(nameA, nameB) }) - return printImageTree(dockerCLI, view) + printImageTree(dockerCLI, view) + return len(view.images), nil } type imageDetails struct { @@ -224,7 +225,7 @@ func getPossibleChips(view treeView) (chips []imageChip) { return possible } -func printImageTree(dockerCLI command.Cli, view treeView) error { +func printImageTree(dockerCLI command.Cli, view treeView) { if streamRedirected(dockerCLI.Out()) { _, _ = fmt.Fprintln(dockerCLI.Err(), "WARNING: This output is designed for human readability. For machine-readable output, please use --format.") } @@ -331,8 +332,6 @@ func printImageTree(dockerCLI command.Cli, view treeView) error { printChildren(out, columns, img, normalColor) _, _ = fmt.Fprintln(out) } - - return nil } // adjustColumns adjusts the width of the first column to maximize the space @@ -374,7 +373,6 @@ func generateLegend(out tui.Output, width uint) string { legend += " |" } } - legend += " " r := int(width) - tui.Width(legend) if r < 0 { From f6feef8fe267add0069c992a16e06f3a9fe7f8e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Wed, 15 Oct 2025 15:04:47 +0200 Subject: [PATCH 3/5] image/test: Fix `go test` args being used by CLI commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By default cobra inherit the `os.Args` if there's no non-nil Args slice set. Signed-off-by: Paweł Gronowski --- cli/command/image/list_test.go | 12 ++++++++++-- cli/command/image/remove_test.go | 5 +++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/cli/command/image/list_test.go b/cli/command/image/list_test.go index 20b57328e4db..b11b9d106021 100644 --- a/cli/command/image/list_test.go +++ b/cli/command/image/list_test.go @@ -38,7 +38,7 @@ func TestNewImagesCommandErrors(t *testing.T) { cmd := newImagesCommand(test.NewFakeCli(&fakeClient{imageListFunc: tc.imageListFunc})) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) - cmd.SetArgs(tc.args) + cmd.SetArgs(nilToEmptySlice(tc.args)) assert.ErrorContains(t, cmd.Execute(), tc.expectedError) }) } @@ -87,7 +87,7 @@ func TestNewImagesCommandSuccess(t *testing.T) { cmd := newImagesCommand(cli) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) - cmd.SetArgs(tc.args) + cmd.SetArgs(nilToEmptySlice(tc.args)) err := cmd.Execute() assert.NilError(t, err) golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("list-command-success.%s.golden", tc.name)) @@ -97,6 +97,7 @@ func TestNewImagesCommandSuccess(t *testing.T) { func TestNewListCommandAlias(t *testing.T) { cmd := newListCommand(test.NewFakeCli(&fakeClient{})) + cmd.SetArgs([]string{""}) assert.Check(t, cmd.HasAlias("list")) assert.Check(t, !cmd.HasAlias("other")) } @@ -114,3 +115,10 @@ func TestNewListCommandAmbiguous(t *testing.T) { assert.NilError(t, err) golden.Assert(t, cli.ErrBuffer().String(), "list-command-ambiguous.golden") } + +func nilToEmptySlice[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} diff --git a/cli/command/image/remove_test.go b/cli/command/image/remove_test.go index 8d6ebe825d40..e9386bc9d939 100644 --- a/cli/command/image/remove_test.go +++ b/cli/command/image/remove_test.go @@ -26,6 +26,7 @@ func (notFound) NotFound() {} func TestNewRemoveCommandAlias(t *testing.T) { cmd := newImageRemoveCommand(test.NewFakeCli(&fakeClient{})) + cmd.SetArgs([]string{""}) assert.Check(t, cmd.HasAlias("rmi")) assert.Check(t, cmd.HasAlias("remove")) assert.Check(t, !cmd.HasAlias("other")) @@ -69,7 +70,7 @@ func TestNewRemoveCommandErrors(t *testing.T) { })) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) - cmd.SetArgs(tc.args) + cmd.SetArgs(nilToEmptySlice(tc.args)) assert.ErrorContains(t, cmd.Execute(), tc.expectedError) }) } @@ -134,7 +135,7 @@ func TestNewRemoveCommandSuccess(t *testing.T) { cmd := newRemoveCommand(cli) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) - cmd.SetArgs(tc.args) + cmd.SetArgs(nilToEmptySlice(tc.args)) assert.NilError(t, cmd.Execute()) assert.Check(t, is.Equal(tc.expectedStderr, cli.ErrBuffer().String())) golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("remove-command-success.%s.golden", tc.name)) From 5836040ec98f1490c61045f1fb0e3702b9afd0c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Wed, 15 Oct 2025 15:07:27 +0200 Subject: [PATCH 4/5] Update golden files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Paweł Gronowski --- cli/command/image/testdata/list-command-ambiguous.golden | 1 + cli/command/image/testdata/list-command-success.filters.golden | 3 ++- cli/command/image/testdata/list-command-success.format.golden | 2 ++ .../image/testdata/list-command-success.match-name.golden | 3 ++- cli/command/image/testdata/list-command-success.simple.golden | 3 ++- 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cli/command/image/testdata/list-command-ambiguous.golden b/cli/command/image/testdata/list-command-ambiguous.golden index 2d8ffa9efa90..f49f1091ebfb 100644 --- a/cli/command/image/testdata/list-command-ambiguous.golden +++ b/cli/command/image/testdata/list-command-ambiguous.golden @@ -1,2 +1,3 @@ +WARNING: This output is designed for human readability. For machine-readable output, please use --format. No images found matching "ls": did you mean "docker image ls"? diff --git a/cli/command/image/testdata/list-command-success.filters.golden b/cli/command/image/testdata/list-command-success.filters.golden index 28b0b71e7865..2f1898cdb23f 100644 --- a/cli/command/image/testdata/list-command-success.filters.golden +++ b/cli/command/image/testdata/list-command-success.filters.golden @@ -1 +1,2 @@ -REPOSITORY TAG IMAGE ID CREATED SIZE + Info -> U In Use +IMAGE ID DISK USAGE CONTENT SIZE EXTRA diff --git a/cli/command/image/testdata/list-command-success.format.golden b/cli/command/image/testdata/list-command-success.format.golden index e69de29bb2d1..2f1898cdb23f 100644 --- a/cli/command/image/testdata/list-command-success.format.golden +++ b/cli/command/image/testdata/list-command-success.format.golden @@ -0,0 +1,2 @@ + Info -> U In Use +IMAGE ID DISK USAGE CONTENT SIZE EXTRA diff --git a/cli/command/image/testdata/list-command-success.match-name.golden b/cli/command/image/testdata/list-command-success.match-name.golden index 28b0b71e7865..2f1898cdb23f 100644 --- a/cli/command/image/testdata/list-command-success.match-name.golden +++ b/cli/command/image/testdata/list-command-success.match-name.golden @@ -1 +1,2 @@ -REPOSITORY TAG IMAGE ID CREATED SIZE + Info -> U In Use +IMAGE ID DISK USAGE CONTENT SIZE EXTRA diff --git a/cli/command/image/testdata/list-command-success.simple.golden b/cli/command/image/testdata/list-command-success.simple.golden index 28b0b71e7865..2f1898cdb23f 100644 --- a/cli/command/image/testdata/list-command-success.simple.golden +++ b/cli/command/image/testdata/list-command-success.simple.golden @@ -1 +1,2 @@ -REPOSITORY TAG IMAGE ID CREATED SIZE + Info -> U In Use +IMAGE ID DISK USAGE CONTENT SIZE EXTRA From 6fa59003391ed24343d463a3e4bb667144695c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Fri, 31 Oct 2025 17:55:45 +0100 Subject: [PATCH 5/5] image/tree: Remove longest->shortest sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Paweł Gronowski --- cli/command/image/tree.go | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go index a2686e2939cb..e3488f0ca055 100644 --- a/cli/command/image/tree.go +++ b/cli/command/image/tree.go @@ -8,7 +8,6 @@ import ( "fmt" "os" "slices" - "sort" "strings" "github.com/containerd/platforms" @@ -422,15 +421,7 @@ func printNames(out tui.Output, headers []imgColumn, img topImage, color, untagg _, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "")) } - // TODO: Replace with namesLongestToShortest := slices.SortedFunc(slices.Values(img.Names)) - // once we move to Go 1.23. - namesLongestToShortest := make([]string, len(img.Names)) - copy(namesLongestToShortest, img.Names) - sort.Slice(namesLongestToShortest, func(i, j int) bool { - return len(namesLongestToShortest[i]) > len(namesLongestToShortest[j]) - }) - - for nameIdx, name := range namesLongestToShortest { + for nameIdx, name := range img.Names { // Don't limit first names to the column width because only the last // name will be printed alongside other columns. if nameIdx < len(img.Names)-1 {