diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index 9ce3b53e5bbb..c746afb1609a 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -592,6 +592,13 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error { } } + // run and create register a local --help so -h can stay --hostname. + // That flag shadows the persistent one, which HandleGlobalFlags already + // consumed, so `docker --help run ` would start the container. + if ccmd := shadowedHelpCommand(cmd, args); ccmd != nil { + return ccmd.Help() + } + // This is a fallback for the case where the command does not exit // based on context cancellation. go forceExitAfter3TerminationSignals(ctx, dockerCli) @@ -613,6 +620,30 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error { return err } +// shadowedHelpCommand returns the subcommand whose help should be printed +// when a global --help was parsed before the command name. Only commands +// that define their own --help flag need this; everyone else still inherits +// the persistent flag and cobra shows help on its own. +func shadowedHelpCommand(root *cobra.Command, args []string) *cobra.Command { + if root == nil || len(args) == 0 { + return nil + } + help, err := root.PersistentFlags().GetBool("help") + if err != nil || !help { + return nil + } + ccmd, _, err := root.Find(args) + if err != nil || ccmd == nil || ccmd == root { + return nil + } + local := ccmd.Flags().Lookup("help") + persistent := root.PersistentFlags().Lookup("help") + if local == nil || persistent == nil || local == persistent { + return nil + } + return ccmd +} + // hasCompletionArg returns true if a cobra completion arg request is found. func hasCompletionArg(args []string) bool { for _, arg := range args { diff --git a/cmd/docker/docker_test.go b/cmd/docker/docker_test.go index e68ba48db92b..8002f52060f5 100644 --- a/cmd/docker/docker_test.go +++ b/cmd/docker/docker_test.go @@ -99,6 +99,68 @@ func TestVersion(t *testing.T) { assert.Check(t, is.Contains(b.String(), "Docker version")) } +func TestShadowedHelpCommand(t *testing.T) { + parse := func(args ...string) (*cobra.Command, []string) { + t.Helper() + cli, err := command.NewDockerCli(command.WithBaseContext(context.TODO())) + assert.NilError(t, err) + tcmd := newDockerCommand(cli) + tcmd.SetArgs(args) + cmd, rest, err := tcmd.HandleGlobalFlags() + assert.NilError(t, err) + return cmd, rest + } + + cmd, args := parse("--help", "run", "hello-world") + got := shadowedHelpCommand(cmd, args) + assert.Assert(t, got != nil) + assert.Check(t, is.Equal(got.Name(), "run")) + + cmd, args = parse("--help", "create", "hello-world") + got = shadowedHelpCommand(cmd, args) + assert.Assert(t, got != nil) + assert.Check(t, is.Equal(got.Name(), "create")) + + cmd, args = parse("--help", "container", "run", "hello-world") + got = shadowedHelpCommand(cmd, args) + assert.Assert(t, got != nil) + assert.Check(t, is.Equal(got.Name(), "run")) + + // --help after the command name is still cobra's job. + cmd, args = parse("run", "--help") + assert.Assert(t, shadowedHelpCommand(cmd, args) == nil) + + // inherited --help already works; don't steal it. + cmd, args = parse("--help", "images") + assert.Assert(t, shadowedHelpCommand(cmd, args) == nil) + + // --help after the image is a container arg. + cmd, args = parse("run", "hello-world", "--help") + assert.Assert(t, shadowedHelpCommand(cmd, args) == nil) +} + +func TestLeadingHelpDoesNotRunContainer(t *testing.T) { + var b bytes.Buffer + ctx := context.TODO() + cli, err := command.NewDockerCli( + command.WithBaseContext(ctx), + command.WithCombinedStreams(&b), + ) + assert.NilError(t, err) + + orig := os.Args + t.Cleanup(func() { os.Args = orig }) + + os.Args = []string{"docker", "--help", "run", "hello-world"} + assert.NilError(t, runDocker(ctx, cli)) + assert.Check(t, is.Contains(b.String(), "Create and run a new container")) + + b.Reset() + os.Args = []string{"docker", "--help", "create", "hello-world"} + assert.NilError(t, runDocker(ctx, cli)) + assert.Check(t, is.Contains(b.String(), "Create a new container")) +} + func TestUserTerminatedError(t *testing.T) { ctx, cancel := context.WithTimeoutCause(context.Background(), time.Second*1, errors.New("test timeout")) t.Cleanup(cancel)