Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions internal/ghcmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ func Main() exitCode {
return exitCode(extError.ExitCode())
}

printError(stderr, err, cmd, hasDebug)
printError(stderr, ioStreams.ColorScheme(), err, cmd, hasDebug, invokingAgent != "")

if strings.Contains(err.Error(), "Incorrect function") {
fmt.Fprintln(stderr, "You appear to be running in MinTTY without pseudo terminal support.")
Expand Down Expand Up @@ -279,7 +279,12 @@ func isExtensionCommand(rootCmd *cobra.Command, args []string) bool {
return err == nil && c != nil && c.GroupID == "extension"
}

func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
// printError writes err to out, followed by usage information when the error
// is the result of command misuse. When fullHelp is set the complete help text
// is written instead of the terse usage string, giving AI agents the examples,
// JSON fields and environment variables they need to correct themselves without
// a second round trip.
func printError(out io.Writer, cs *iostreams.ColorScheme, err error, cmd *cobra.Command, debug, fullHelp bool) {
var dnsError *net.DNSError
if errors.As(err, &dnsError) {
fmt.Fprintf(out, "error connecting to %s\n", dnsError.Name)
Expand All @@ -297,6 +302,13 @@ func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
if !strings.HasSuffix(err.Error(), "\n") {
fmt.Fprintln(out)
}
if fullHelp {
// Render into out rather than calling cmd.Help(), which would send
// the help text to stdout and split a single failure across two
// streams.
root.WriteHelp(out, cs, cmd)
return
}
fmt.Fprintln(out, cmd.UsageString())
}
}
Expand Down
101 changes: 91 additions & 10 deletions internal/ghcmd/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,37 @@ import (
"os"
"testing"

"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/agents"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/gh"
ghmock "github.com/cli/cli/v2/internal/gh/mock"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
ghAPI "github.com/cli/go-gh/v2/pkg/api"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func Test_printError(t *testing.T) {
cmd := &cobra.Command{}
rootCmd := &cobra.Command{Use: "gh"}
cmd := &cobra.Command{
Use: "spend",
Short: "Spend money",
Example: heredoc.Doc(`
$ gh spend --amount 1
`),
}
cmd.Flags().Int("amount", 0, "How much to spend")
rootCmd.AddCommand(cmd)

type args struct {
err error
cmd *cobra.Command
debug bool
err error
cmd *cobra.Command
debug bool
fullHelp bool
}
tests := []struct {
name string
Expand Down Expand Up @@ -63,7 +75,7 @@ check your internet connection or https://githubstatus.com
cmd: cmd,
debug: false,
},
wantOut: "unknown flag --foo\n\nUsage:\n\n",
wantOut: "unknown flag --foo\n\n" + cmd.UsageString() + "\n",
},
{
name: "unknown Cobra command error",
Expand All @@ -72,17 +84,86 @@ check your internet connection or https://githubstatus.com
cmd: cmd,
debug: false,
},
wantOut: "unknown command foo\n\nUsage:\n\n",
wantOut: "unknown command foo\n\n" + cmd.UsageString() + "\n",
},
{
name: "Cobra flag error with full help",
args: args{
err: cmdutil.FlagErrorf("unknown flag --foo"),
cmd: cmd,
debug: false,
fullHelp: true,
},
wantOut: heredoc.Doc(`
unknown flag --foo

Spend money

USAGE
gh spend [flags]

FLAGS
--amount int How much to spend

EXAMPLES
$ gh spend --amount 1

LEARN MORE
Use ` + "`gh <command> <subcommand> --help`" + ` for more information about a command.
Read the manual at https://cli.github.com/manual
Learn about exit codes using ` + "`gh help exit-codes`" + `
Learn about accessibility experiences using ` + "`gh help accessibility`" + `

`),
},
{
name: "unknown Cobra command error with full help",
args: args{
err: errors.New("unknown command foo"),
cmd: cmd,
debug: false,
fullHelp: true,
},
wantOut: heredoc.Doc(`
unknown command foo

Spend money

USAGE
gh spend [flags]

FLAGS
--amount int How much to spend

EXAMPLES
$ gh spend --amount 1

LEARN MORE
Use ` + "`gh <command> <subcommand> --help`" + ` for more information about a command.
Read the manual at https://cli.github.com/manual
Learn about exit codes using ` + "`gh help exit-codes`" + `
Learn about accessibility experiences using ` + "`gh help accessibility`" + `

`),
},
{
name: "generic error is unaffected by full help",
args: args{
err: errors.New("the app exploded"),
cmd: cmd,
debug: false,
fullHelp: true,
},
wantOut: "the app exploded\n",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
out := &bytes.Buffer{}
printError(out, tt.args.err, tt.args.cmd, tt.args.debug)
if gotOut := out.String(); gotOut != tt.wantOut {
t.Errorf("printError() = %q, want %q", gotOut, tt.wantOut)
}
printError(out, ios.ColorScheme(), tt.args.err, tt.args.cmd, tt.args.debug, tt.args.fullHelp)
assert.Equal(t, tt.wantOut, out.String())
})
}
}
Expand Down
3 changes: 3 additions & 0 deletions pkg/cmd/root/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ func NewCmdAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.C
},
GroupID: "alias",
DisableFlagParsing: true,
Annotations: map[string]string{
aliasExpansionAnnotation: aliasValue,
},
}
cmdutil.DisableAuthCheck(cmd)
// Aliases are user-defined names and must not be reported as telemetry
Expand Down
48 changes: 43 additions & 5 deletions pkg/cmd/root/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
Expand Down Expand Up @@ -109,6 +111,20 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) {
return
}

WriteHelp(f.IOStreams.Out, cs, command)
}

// WriteHelp renders the full help text for command to w. This is the same
// output produced by `gh <command> --help`, exposed separately so that callers
// such as error reporting can render help to a stream of their choosing rather
// than always writing to stdout.
//
// A user-defined alias is resolved to the command it expands to, so that
// callers see the target's flags and examples rather than the alias stub. This
// mirrors the usage and help funcs configured for aliases in NewCmdRoot.
func WriteHelp(w io.Writer, cs *iostreams.ColorScheme, command *cobra.Command) {
command = resolveAliasTarget(command)

type helpEntry struct {
Title string
Body string
Expand Down Expand Up @@ -193,17 +209,16 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) {
Learn about accessibility experiences using %[1]sgh help accessibility%[1]s
`, "`")})

out := f.IOStreams.Out
for _, e := range helpEntries {
if e.Title != "" {
// If there is a title, add indentation to each line in the body
fmt.Fprintln(out, cs.Bold(e.Title))
fmt.Fprintln(out, text.Indent(strings.Trim(e.Body, "\r\n"), " "))
fmt.Fprintln(w, cs.Bold(e.Title))
fmt.Fprintln(w, text.Indent(strings.Trim(e.Body, "\r\n"), " "))
} else {
// If there is no title print the body as is
fmt.Fprintln(out, e.Body)
fmt.Fprintln(w, e.Body)
}
fmt.Fprintln(out)
fmt.Fprintln(w)
}
}

Expand Down Expand Up @@ -332,3 +347,26 @@ func BuildAliasList(cmd *cobra.Command, aliases []string) []string {

return BuildAliasList(cmd.Parent(), aliasesWithParentAliases)
}

// aliasExpansionAnnotation records the command line a user-defined alias
// expands to, so that help rendering can resolve the alias to its target.
const aliasExpansionAnnotation = "gh:alias-expansion"

// resolveAliasTarget returns the command a user-defined alias expands to, or
// command unchanged when it is not an alias or the target cannot be resolved.
// Shell aliases have no target command and are always returned unchanged.
func resolveAliasTarget(command *cobra.Command) *cobra.Command {
expansion, ok := command.Annotations[aliasExpansionAnnotation]
if !ok {
return command
}
args, err := shlex.Split(expansion)
if err != nil || len(args) == 0 {
return command
}
target, _, err := command.Root().Find(args)
if err != nil || target == nil {
return command
}
return target
}
72 changes: 72 additions & 0 deletions pkg/cmd/root/help_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package root

import (
"bytes"
"fmt"
"testing"

Expand Down Expand Up @@ -123,3 +124,74 @@ func assertPipesAreInCodeBlocks(t *testing.T, cmd *cobra.Command) {

checkNode(doc)
}

func TestWriteHelp_aliasResolution(t *testing.T) {
newRoot := func() (*cobra.Command, *iostreams.IOStreams) {
ios, _, _, _ := iostreams.Test()
rootCmd := &cobra.Command{Use: "gh"}
prCmd := &cobra.Command{Use: "pr", Short: "Manage pull requests"}
listCmd := &cobra.Command{Use: "list", Short: "List pull requests", Run: func(*cobra.Command, []string) {}}
listCmd.Flags().Int("limit", 30, "Maximum number of items to fetch")
prCmd.AddCommand(listCmd)
rootCmd.AddCommand(prCmd)
return rootCmd, ios
}

t.Run("configured alias renders the target command help", func(t *testing.T) {
rootCmd, ios := newRoot()
aliasCmd := NewCmdAlias(ios, "prs", "pr list")
rootCmd.AddCommand(aliasCmd)

var buf bytes.Buffer
WriteHelp(&buf, ios.ColorScheme(), aliasCmd)

out := buf.String()
require.Contains(t, out, "gh pr list")
require.Contains(t, out, "--limit")
require.NotContains(t, out, `Alias for "pr list"`)
})

t.Run("alias expansion carrying flags still resolves to the target", func(t *testing.T) {
rootCmd, ios := newRoot()
aliasCmd := NewCmdAlias(ios, "prs", "pr list --limit 5")
rootCmd.AddCommand(aliasCmd)

var buf bytes.Buffer
WriteHelp(&buf, ios.ColorScheme(), aliasCmd)

require.Contains(t, buf.String(), "gh pr list")
})

t.Run("shell alias has no target and renders its own help", func(t *testing.T) {
rootCmd, ios := newRoot()
aliasCmd := NewCmdShellAlias(ios, "sh", "!echo hi")
rootCmd.AddCommand(aliasCmd)

var buf bytes.Buffer
WriteHelp(&buf, ios.ColorScheme(), aliasCmd)

require.Contains(t, buf.String(), "gh sh")
})

t.Run("alias expanding to an unknown command falls back to the alias", func(t *testing.T) {
rootCmd, ios := newRoot()
aliasCmd := NewCmdAlias(ios, "zz", "nope missing")
rootCmd.AddCommand(aliasCmd)

var buf bytes.Buffer
WriteHelp(&buf, ios.ColorScheme(), aliasCmd)

require.Contains(t, buf.String(), "gh zz")
})

t.Run("non-alias command is unaffected", func(t *testing.T) {
rootCmd, ios := newRoot()
target, _, err := rootCmd.Find([]string{"pr", "list"})
require.NoError(t, err)

var buf bytes.Buffer
WriteHelp(&buf, ios.ColorScheme(), target)

require.Contains(t, buf.String(), "gh pr list")
})
}
Loading