diff --git a/cli/command/image/list.go b/cli/command/image/list.go index 971cc4e7c571..6f01b84d542e 100644 --- a/cli/command/image/list.go +++ b/cli/command/image/list.go @@ -26,7 +26,6 @@ type imagesOptions struct { showDigests bool format string filter opts.FilterOpt - calledAs string tree bool } @@ -42,11 +41,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", @@ -79,29 +81,22 @@ 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) } - 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 0, err + } + if useTree { return runTree(ctx, dockerCLI, treeOptions{ - all: options.all, - filters: filters, + all: options.all, + filters: filters, + expanded: options.tree, }) } @@ -110,7 +105,7 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions Filters: filters, }) if err != nil { - return err + return 0, err } format := options.format @@ -131,12 +126,37 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions Digest: options.showDigests, } if err := formatter.ImageWrite(imageCtx, images); err != nil { - return err + return 0, err } - if options.matchName != "" && len(images) == 0 && options.calledAs == "images" { - printAmbiguousHint(dockerCLI.Err(), options.matchName) + return len(images), 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 nil + return true, nil } // isDangling is a copy of [formatter.isDangling]. diff --git a/cli/command/image/list_test.go b/cli/command/image/list_test.go index 52300beaacae..5e8e42e9b4d6 100644 --- a/cli/command/image/list_test.go +++ b/cli/command/image/list_test.go @@ -39,7 +39,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) }) } @@ -88,7 +88,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)) @@ -98,6 +98,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")) } @@ -115,3 +116,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 402ccc7ccb10..a91e2430cf67 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) }) } @@ -126,7 +127,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)) 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 diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go index 8d3101a76edd..55e3262e9874 100644 --- a/cli/command/image/tree.go +++ b/cli/command/image/tree.go @@ -24,8 +24,9 @@ import ( ) type treeOptions struct { - all bool - filters client.Filters + all bool + filters client.Filters + expanded bool } type treeView struct { @@ -35,14 +36,14 @@ 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, err := dockerCLI.Client().ImageList(ctx, client.ImageListOptions{ All: opts.all, Filters: opts.filters, Manifests: true, }) if err != nil { - return err + return 0, err } if !opts.all { images = slices.DeleteFunc(images, isDangling) @@ -54,7 +55,7 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error attested := make(map[digest.Digest]bool) for _, img := range images { - details := imageDetails{ + topDetails := imageDetails{ ID: img.ID, DiskUsage: units.HumanSizeWithPrecision(float64(img.Size), 3), InUse: img.Containers > 0, @@ -73,33 +74,38 @@ 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) view.images = append(view.images, topImage{ Names: img.RepoTags, - Details: details, + Details: topDetails, Children: children, created: img.Created, }) @@ -109,7 +115,8 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error return view.images[i].created > view.images[j].created }) - return printImageTree(dockerCLI, view) + printImageTree(dockerCLI, view) + return len(view.images), nil } type imageDetails struct { @@ -192,7 +199,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.") } @@ -299,8 +306,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 @@ -342,7 +347,6 @@ func generateLegend(out tui.Output, width uint) string { legend += " |" } } - legend += " " r := int(width) - tui.Width(legend) if r < 0 {