From bd9016dca258b35389a2f1cc547f30e37a0bc52b Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Tue, 17 Mar 2026 19:57:01 +0100 Subject: [PATCH] fix: show global options in subcommand help Fixes ipfs/kubo#6640 - add collectGlobalOptions() to gather ancestor options via Resolve() - render GLOBAL OPTIONS section in long help for non-root commands - include global options in synopsis line - add "Use ' --help' for global options." hint in short help - deduplicate options already defined on the leaf command --- cli/helptext.go | 77 +++++++++++++++++++++-- cli/helptext_test.go | 147 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 219 insertions(+), 5 deletions(-) diff --git a/cli/helptext.go b/cli/helptext.go index 95205207..22cc818b 100644 --- a/cli/helptext.go +++ b/cli/helptext.go @@ -35,6 +35,7 @@ type helpFields struct { Tagline string Arguments string Options string + GlobalOptions string Synopsis string Subcommands string ExperimentalSubcommands string @@ -42,6 +43,7 @@ type helpFields struct { RemovedSubcommands string Description string MoreHelp bool + GlobalOptionsHint string } // TrimNewlines removes extra newlines from fields. This makes aligning @@ -60,6 +62,7 @@ func (f *helpFields) TrimNewlines() { f.Tagline = strings.Trim(f.Tagline, "\n") f.Arguments = strings.Trim(f.Arguments, "\n") f.Options = strings.Trim(f.Options, "\n") + f.GlobalOptions = strings.Trim(f.GlobalOptions, "\n") f.Synopsis = strings.Trim(f.Synopsis, "\n") f.Subcommands = strings.Trim(f.Subcommands, "\n") f.ExperimentalSubcommands = strings.Trim(f.ExperimentalSubcommands, "\n") @@ -81,6 +84,7 @@ func (f *helpFields) IndentAll() { f.Usage = indent(f.Usage) f.Arguments = indent(f.Arguments) f.Options = indent(f.Options) + f.GlobalOptions = indent(f.GlobalOptions) f.Synopsis = indent(f.Synopsis) f.Subcommands = indent(f.Subcommands) f.DeprecatedSubcommands = indent(f.DeprecatedSubcommands) @@ -105,6 +109,10 @@ const longHelpFormat = `{{if .Warning}}WARNING: {{.Warning}} {{.Options}} +{{end}}{{if .GlobalOptions}}GLOBAL OPTIONS + +{{.GlobalOptions}} + {{end}}{{if .Description}}DESCRIPTION {{.Description}} @@ -134,6 +142,8 @@ const shortHelpFormat = `{{if .Warning}}WARNING: {{.Warning}} {{.Synopsis}} {{end}}{{if .Description}} {{.Description}} +{{end}}{{if .GlobalOptionsHint}} +{{.GlobalOptionsHint}} {{end}}{{if .Subcommands}} SUBCOMMANDS {{.Subcommands}} @@ -193,6 +203,45 @@ func HandleHelp(appName string, req *cmds.Request, out io.Writer) error { } } +// collectGlobalOptions gathers options from ancestor commands that are not +// already defined on the leaf command. Returns nil when cmd is the root. +func collectGlobalOptions(root *cmds.Command, path []string, cmd *cmds.Command) []cmds.Option { + if cmd == root || len(path) == 0 { + return nil + } + + chain, err := root.Resolve(path) + if err != nil { + return nil + } + + // Build a set of option names on the leaf command for dedup. + leafNames := make(map[string]struct{}) + for _, opt := range cmd.Options { + for _, n := range opt.Names() { + leafNames[n] = struct{}{} + } + } + + // Collect options from all ancestors (everything except the last element). + seen := make(map[string]struct{}) + var global []cmds.Option + for _, ancestor := range chain[:len(chain)-1] { + for _, opt := range ancestor.Options { + primary := opt.Name() + if _, ok := leafNames[primary]; ok { + continue + } + if _, ok := seen[primary]; ok { + continue + } + seen[primary] = struct{}{} + global = append(global, opt) + } + } + return global +} + // LongHelp writes a formatted CLI helptext string to a Writer for the given command func LongHelp(rootName string, root *cmds.Command, path []string, out io.Writer) error { cmd, err := root.Get(path) @@ -243,8 +292,15 @@ func LongHelp(rootName string, root *cmds.Command, path []string, out io.Writer) fields.DeprecatedSubcommands = strings.Join(subcommandText(width, cmd, rootName, path, cmds.Deprecated), "\n") fields.RemovedSubcommands = strings.Join(subcommandText(width, cmd, rootName, path, cmds.Removed), "\n") } + + globalOpts := collectGlobalOptions(root, path, cmd) + if len(globalOpts) > 0 { + // Build a temporary command to pass to optionText for rendering. + fields.GlobalOptions = strings.Join(optionText(width, &cmds.Command{Options: globalOpts}), "\n") + } + if len(fields.Synopsis) == 0 { - fields.Synopsis = generateSynopsis(width, cmd, pathStr) + fields.Synopsis = generateSynopsis(width, cmd, pathStr, globalOpts) } // trim the extra newlines (see TrimNewlines doc) @@ -298,8 +354,15 @@ func ShortHelp(rootName string, root *cmds.Command, path []string, out io.Writer fields.DeprecatedSubcommands = strings.Join(subcommandText(width, cmd, rootName, path, cmds.Deprecated), "\n") fields.RemovedSubcommands = strings.Join(subcommandText(width, cmd, rootName, path, cmds.Removed), "\n") } + + globalOpts := collectGlobalOptions(root, path, cmd) + if len(fields.Synopsis) == 0 { - fields.Synopsis = generateSynopsis(width, cmd, pathStr) + fields.Synopsis = generateSynopsis(width, cmd, pathStr, globalOpts) + } + + if len(globalOpts) > 0 { + fields.GlobalOptionsHint = fmt.Sprintf("%sUse '%s --help' for global options.", indentStr, rootName) } // trim the extra newlines (see TrimNewlines doc) @@ -311,7 +374,7 @@ func ShortHelp(rootName string, root *cmds.Command, path []string, out io.Writer return shortHelpTemplate.Execute(out, fields) } -func generateSynopsis(width int, cmd *cmds.Command, path string) string { +func generateSynopsis(width int, cmd *cmds.Command, path string, globalOpts []cmds.Option) string { res := path currentLineLength := len(res) appendText := func(text string) { @@ -322,7 +385,13 @@ func generateSynopsis(width int, cmd *cmds.Command, path string) string { currentLineLength += len(text) + 1 res += " " + text } - for _, opt := range cmd.Options { + + allOpts := cmd.Options + if len(globalOpts) > 0 { + allOpts = append(append([]cmds.Option{}, cmd.Options...), globalOpts...) + } + + for _, opt := range allOpts { valopt, ok := cmd.Helptext.SynopsisOptionsValues[opt.Name()] if !ok { valopt = opt.Name() diff --git a/cli/helptext_test.go b/cli/helptext_test.go index 7e135683..5b409796 100644 --- a/cli/helptext_test.go +++ b/cli/helptext_test.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "strings" "testing" @@ -24,7 +25,7 @@ func TestSynopsisGenerator(t *testing.T) { }, } terminalWidth := 100 - syn := generateSynopsis(terminalWidth, command, "cmd") + syn := generateSynopsis(terminalWidth, command, "cmd", nil) t.Logf("Synopsis is: %s", syn) if !strings.HasPrefix(syn, "cmd ") { t.Fatal("Synopsis should start with command name") @@ -48,3 +49,147 @@ func TestSynopsisGenerator(t *testing.T) { t.Fatal("Synopsis should contain options finalizer") } } + +func newTestRootAndSub() (root *cmds.Command, sub *cmds.Command) { + sub = &cmds.Command{ + Helptext: cmds.HelpText{ + Tagline: "Add a file", + }, + Options: []cmds.Option{ + cmds.BoolOption("pin", "p", "Pin the file"), + }, + Arguments: []cmds.Argument{ + cmds.StringArg("file", true, false, "File to add"), + }, + } + root = &cmds.Command{ + Helptext: cmds.HelpText{ + Tagline: "Global root", + }, + Options: []cmds.Option{ + cmds.StringOption("encoding", "enc", "Output encoding"), + cmds.StringOption("timeout", "Max time"), + }, + Subcommands: map[string]*cmds.Command{ + "add": sub, + }, + } + return root, sub +} + +func TestLongHelpGlobalOptions(t *testing.T) { + root, _ := newTestRootAndSub() + + var buf bytes.Buffer + if err := LongHelp("ipfs", root, []string{"add"}, &buf); err != nil { + t.Fatal(err) + } + out := buf.String() + t.Log(out) + + if !strings.Contains(out, "OPTIONS") { + t.Fatal("long help should contain OPTIONS section") + } + if !strings.Contains(out, "--pin") { + t.Fatal("long help OPTIONS should contain subcommand option --pin") + } + if !strings.Contains(out, "GLOBAL OPTIONS") { + t.Fatal("long help should contain GLOBAL OPTIONS section") + } + if !strings.Contains(out, "--encoding") { + t.Fatal("GLOBAL OPTIONS should contain --encoding") + } + if !strings.Contains(out, "--timeout") { + t.Fatal("GLOBAL OPTIONS should contain --timeout") + } +} + +func TestLongHelpRootNoGlobalSection(t *testing.T) { + root, _ := newTestRootAndSub() + + var buf bytes.Buffer + if err := LongHelp("ipfs", root, nil, &buf); err != nil { + t.Fatal(err) + } + out := buf.String() + + if strings.Contains(out, "GLOBAL OPTIONS") { + t.Fatal("root long help should NOT contain GLOBAL OPTIONS section") + } +} + +func TestShortHelpGlobalOptionsHint(t *testing.T) { + root, _ := newTestRootAndSub() + + var buf bytes.Buffer + if err := ShortHelp("ipfs", root, []string{"add"}, &buf); err != nil { + t.Fatal(err) + } + out := buf.String() + t.Log(out) + + if !strings.Contains(out, "Use 'ipfs --help' for global options.") { + t.Fatal("short help should contain global options hint") + } +} + +func TestShortHelpRootNoHint(t *testing.T) { + root, _ := newTestRootAndSub() + + var buf bytes.Buffer + if err := ShortHelp("ipfs", root, nil, &buf); err != nil { + t.Fatal(err) + } + out := buf.String() + + if strings.Contains(out, "global options") { + t.Fatal("root short help should NOT contain global options hint") + } +} + +func TestSynopsisIncludesGlobalOptions(t *testing.T) { + root, sub := newTestRootAndSub() + globalOpts := collectGlobalOptions(root, []string{"add"}, sub) + + syn := generateSynopsis(120, sub, "ipfs add", globalOpts) + t.Log(syn) + + if !strings.Contains(syn, "--pin") { + t.Fatal("synopsis should contain subcommand option --pin") + } + if !strings.Contains(syn, "--encoding") { + t.Fatal("synopsis should contain global option --encoding") + } + if !strings.Contains(syn, "--timeout") { + t.Fatal("synopsis should contain global option --timeout") + } +} + +func TestCollectGlobalOptionsDedup(t *testing.T) { + // If the subcommand redefines an option name from the root, it should + // not appear in the global options list. + sub := &cmds.Command{ + Options: []cmds.Option{ + cmds.StringOption("encoding", "Override encoding"), + }, + } + root := &cmds.Command{ + Options: []cmds.Option{ + cmds.StringOption("encoding", "enc", "Output encoding"), + cmds.StringOption("timeout", "Max time"), + }, + Subcommands: map[string]*cmds.Command{ + "sub": sub, + }, + } + + globalOpts := collectGlobalOptions(root, []string{"sub"}, sub) + for _, opt := range globalOpts { + if opt.Name() == "encoding" { + t.Fatal("global options should not include option already on leaf command") + } + } + if len(globalOpts) != 1 || globalOpts[0].Name() != "timeout" { + t.Fatalf("expected only timeout in global opts, got %v", globalOpts) + } +}