diff --git a/cmd/gh/main.go b/cmd/gh/main.go index bac165995..a52d23f21 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -45,6 +45,12 @@ func main() { fmt.Fprintf(stderr, "failed to process aliases: %s\n", err) os.Exit(2) } + + if expandedArgs == nil && err == nil { + // It was an external alias; we ran it and are now done. + os.Exit(0) + } + if hasDebug { fmt.Fprintf(stderr, "%v -> %v\n", originalArgs, expandedArgs) } diff --git a/command/alias.go b/command/alias.go index 1b21fcc61..b4df2411d 100644 --- a/command/alias.go +++ b/command/alias.go @@ -16,21 +16,35 @@ func init() { aliasCmd.AddCommand(aliasSetCmd) aliasCmd.AddCommand(aliasListCmd) aliasCmd.AddCommand(aliasDeleteCmd) + + aliasSetCmd.Flags().BoolP("shell", "s", false, "Declare an alias to be passed through a shell interpreter") } var aliasCmd = &cobra.Command{ Use: "alias", - Short: "Create shortcuts for gh commands", + Short: "Create command shortcuts", + Long: heredoc.Doc(` + Aliases can be used to make shortcuts for gh commands or to compose multiple commands. + + Run "gh help alias set" to learn more. + `), } var aliasSetCmd = &cobra.Command{ Use: "set ", Short: "Create a shortcut for a gh command", - Long: `Declare a word as a command alias that will expand to the specified command. + Long: heredoc.Doc(` + Declare a word as a command alias that will expand to the specified command(s). -The expansion may specify additional arguments and flags. If the expansion -includes positional placeholders such as '$1', '$2', etc., any extra arguments -that follow the invocation of an alias will be inserted appropriately.`, + The expansion may specify additional arguments and flags. If the expansion + includes positional placeholders such as '$1', '$2', etc., any extra arguments + that follow the invocation of an alias will be inserted appropriately. + + If '--shell' is specified, the alias will be run through a shell interpreter (sh). This allows you + to compose commands with "|" or redirect output with ">". Note that extra arguments are not passed + to shell-interpreted aliases; only placeholders ("$1", "$2", etc) are supported. + + Quotes must always be used when defining a command as in the examples.`), Example: heredoc.Doc(` $ gh alias set pv 'pr view' $ gh pv -w 123 @@ -41,13 +55,12 @@ that follow the invocation of an alias will be inserted appropriately.`, $ gh alias set epicsBy 'issue list --author="$1" --label="epic"' $ gh epicsBy vilmibm #=> gh issue list --author="vilmibm" --label="epic" - `), - Args: cobra.MinimumNArgs(2), - RunE: aliasSet, - // NB: this allows a user to eschew quotes when specifying an alias expansion. We'll have to - // revisit it if we ever want to add flags to alias set but we have no current plans for that. - DisableFlagParsing: true, + $ gh alias set --shell igrep 'gh issue list --label="$1" | grep $2' + $ gh igrep epic foo + #=> gh issue list --label="epic" | grep "foo"`), + Args: cobra.ExactArgs(2), + RunE: aliasSet, } func aliasSet(cmd *cobra.Command, args []string) error { @@ -63,19 +76,26 @@ func aliasSet(cmd *cobra.Command, args []string) error { } alias := args[0] - expansion := processArgs(args[1:]) - - expansionStr := strings.Join(expansion, " ") + expansion := args[1] out := colorableOut(cmd) - fmt.Fprintf(out, "- Adding alias for %s: %s\n", utils.Bold(alias), utils.Bold(expansionStr)) + fmt.Fprintf(out, "- Adding alias for %s: %s\n", utils.Bold(alias), utils.Bold(expansion)) - if validCommand([]string{alias}) { + shell, err := cmd.Flags().GetBool("shell") + if err != nil { + return err + } + if shell && !strings.HasPrefix(expansion, "!") { + expansion = "!" + expansion + } + isExternal := strings.HasPrefix(expansion, "!") + + if validCommand(alias) { return fmt.Errorf("could not create alias: %q is already a gh command", alias) } - if !validCommand(expansion) { - return fmt.Errorf("could not create alias: %s does not correspond to a gh command", utils.Bold(expansionStr)) + if !isExternal && !validCommand(expansion) { + return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion) } successMsg := fmt.Sprintf("%s Added alias.", utils.Green("✓")) @@ -86,11 +106,11 @@ func aliasSet(cmd *cobra.Command, args []string) error { utils.Green("✓"), utils.Bold(alias), utils.Bold(oldExpansion), - utils.Bold(expansionStr), + utils.Bold(expansion), ) } - err = aliasCfg.Add(alias, expansionStr) + err = aliasCfg.Add(alias, expansion) if err != nil { return fmt.Errorf("could not create alias: %s", err) } @@ -100,28 +120,15 @@ func aliasSet(cmd *cobra.Command, args []string) error { return nil } -func validCommand(expansion []string) bool { - cmd, _, err := RootCmd.Traverse(expansion) +func validCommand(expansion string) bool { + split, err := shlex.Split(expansion) + if err != nil { + return false + } + cmd, _, err := RootCmd.Traverse(split) return err == nil && cmd != RootCmd } -func processArgs(args []string) []string { - if len(args) == 1 { - split, _ := shlex.Split(args[0]) - return split - } - - newArgs := []string{} - for _, a := range args { - if !strings.HasPrefix(a, "-") && strings.Contains(a, " ") { - a = fmt.Sprintf("%q", a) - } - newArgs = append(newArgs, a) - } - - return newArgs -} - var aliasListCmd = &cobra.Command{ Use: "list", Short: "List your aliases", diff --git a/command/alias_test.go b/command/alias_test.go index a0d894e20..346e40c13 100644 --- a/command/alias_test.go +++ b/command/alias_test.go @@ -2,11 +2,13 @@ package command import ( "bytes" + "fmt" "strings" "testing" "github.com/cli/cli/internal/config" "github.com/cli/cli/test" + "github.com/stretchr/testify/assert" ) func TestAliasSet_gh_command(t *testing.T) { @@ -16,7 +18,7 @@ func TestAliasSet_gh_command(t *testing.T) { hostsBuf := bytes.Buffer{} defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - _, err := RunCommand("alias set pr pr status") + _, err := RunCommand("alias set pr 'pr status'") if err == nil { t.Fatal("expected error") } @@ -35,7 +37,7 @@ editor: vim hostsBuf := bytes.Buffer{} defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - output, err := RunCommand("alias set co pr checkout") + output, err := RunCommand("alias set co 'pr checkout'") if err != nil { t.Fatalf("unexpected error: %s", err) @@ -65,7 +67,7 @@ aliases: hostsBuf := bytes.Buffer{} defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - output, err := RunCommand("alias set co pr checkout -Rcool/repo") + output, err := RunCommand("alias set co 'pr checkout -Rcool/repo'") if err != nil { t.Fatalf("unexpected error: %s", err) @@ -81,7 +83,7 @@ func TestAliasSet_space_args(t *testing.T) { hostsBuf := bytes.Buffer{} defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - output, err := RunCommand(`alias set il issue list -l 'cool story'`) + output, err := RunCommand(`alias set il 'issue list -l "cool story"'`) if err != nil { t.Fatalf("unexpected error: %s", err) @@ -99,23 +101,17 @@ func TestAliasSet_arg_processing(t *testing.T) { ExpectedOutputLine string ExpectedConfigLine string }{ - {"alias set co pr checkout", "- Adding alias for co: pr checkout", "co: pr checkout"}, - {`alias set il "issue list"`, "- Adding alias for il: issue list", "il: issue list"}, {`alias set iz 'issue list'`, "- Adding alias for iz: issue list", "iz: issue list"}, - {`alias set iy issue list --author=\$1 --label=\$2`, - `- Adding alias for iy: issue list --author=\$1 --label=\$2`, - `iy: issue list --author=\$1 --label=\$2`}, - {`alias set ii 'issue list --author="$1" --label="$2"'`, - `- Adding alias for ii: issue list --author=\$1 --label=\$2`, - `ii: issue list --author=\$1 --label=\$2`}, + `- Adding alias for ii: issue list --author="\$1" --label="\$2"`, + `ii: issue list --author="\$1" --label="\$2"`}, - {`alias set ix issue list --author='$1' --label='$2'`, - `- Adding alias for ix: issue list --author=\$1 --label=\$2`, - `ix: issue list --author=\$1 --label=\$2`}, + {`alias set ix "issue list --author='\$1' --label='\$2'"`, + `- Adding alias for ix: issue list --author='\$1' --label='\$2'`, + `ix: issue list --author='\$1' --label='\$2'`}, } for _, c := range cases { @@ -143,7 +139,7 @@ editor: vim hostsBuf := bytes.Buffer{} defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - output, err := RunCommand("alias set diff pr diff") + output, err := RunCommand("alias set diff 'pr diff'") if err != nil { t.Fatalf("unexpected error: %s", err) } @@ -167,7 +163,7 @@ aliases: hostsBuf := bytes.Buffer{} defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - output, err := RunCommand("alias set view pr view") + output, err := RunCommand("alias set view 'pr view'") if err != nil { t.Fatalf("unexpected error: %s", err) } @@ -181,6 +177,35 @@ aliases: } +func TestExpandAlias_external(t *testing.T) { + cfg := `--- +aliases: + co: pr checkout + il: issue list --author="$1" --label="$2" + ia: issue list --author="$1" --assignee="$1" + ig: '!gh issue list --label=$1 | grep' +` + initBlankContext(cfg, "OWNER/REPO", "trunk") + + cs, teardown := test.InitCmdStubber() + defer teardown() + cs.Stub("") + + _, err := ExpandAlias([]string{"gh", "ig", "bug", "foo"}) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + assert.Equal(t, 1, len(cs.Calls)) + + fmt.Printf("DEBUG %#v\n", cs.Calls[0]) + + expected := []string{"sh", "-c", "gh issue list --label=$1 | grep", "--", "bug", "foo"} + + assert.Equal(t, expected, cs.Calls[0].Args) +} + func TestExpandAlias(t *testing.T) { cfg := `--- aliases: @@ -230,7 +255,7 @@ aliases: func TestAliasSet_invalid_command(t *testing.T) { initBlankContext("", "OWNER/REPO", "trunk") - _, err := RunCommand("alias set co pe checkout") + _, err := RunCommand("alias set co 'pe checkout'") if err == nil { t.Fatal("expected error") } @@ -319,3 +344,43 @@ aliases: eq(t, mainBuf.String(), expected) } + +func TestShellAlias_flag(t *testing.T) { + initBlankContext("", "OWNER/REPO", "trunk") + + mainBuf := bytes.Buffer{} + hostsBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, &hostsBuf)() + + output, err := RunCommand("alias set --shell igrep 'gh issue list | grep'") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + test.ExpectLines(t, output.String(), "Adding alias for igrep") + expected := `aliases: + igrep: '!gh issue list | grep' +` + + eq(t, mainBuf.String(), expected) +} + +func TestShellAlias_bang(t *testing.T) { + initBlankContext("", "OWNER/REPO", "trunk") + + mainBuf := bytes.Buffer{} + hostsBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, &hostsBuf)() + + output, err := RunCommand("alias set igrep '!gh issue list | grep'") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + test.ExpectLines(t, output.String(), "Adding alias for igrep") + expected := `aliases: + igrep: '!gh issue list | grep' +` + + eq(t, mainBuf.String(), expected) +} diff --git a/command/root.go b/command/root.go index df607a50d..fa93ca89e 100644 --- a/command/root.go +++ b/command/root.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "os" + "os/exec" "regexp" "runtime/debug" "strings" @@ -15,6 +16,7 @@ import ( "github.com/cli/cli/context" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/internal/run" apiCmd "github.com/cli/cli/pkg/cmd/api" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -364,24 +366,43 @@ func determineEditor(cmd *cobra.Command) (string, error) { } func ExpandAlias(args []string) ([]string, error) { - empty := []string{} if len(args) < 2 { // the command is lacking a subcommand - return empty, nil + return []string{}, nil } ctx := initContext() cfg, err := ctx.Config() if err != nil { - return empty, err + return nil, err } aliases, err := cfg.Aliases() if err != nil { - return empty, err + return nil, err } expansion, ok := aliases.Get(args[1]) if ok { + if strings.HasPrefix(expansion, "!") { + shellArgs := []string{"-c", expansion[1:]} + if len(args[2:]) > 0 { + shellArgs = append(shellArgs, "--") + shellArgs = append(shellArgs, args[2:]...) + } + externalCmd := exec.Command("sh", shellArgs...) + externalCmd.Stderr = os.Stderr + externalCmd.Stdout = os.Stdout + externalCmd.Stdin = os.Stdin + preparedCmd := run.PrepareCmd(externalCmd) + + err := preparedCmd.Run() + if err != nil { + return nil, fmt.Errorf("failed to run external command: %w", err) + } + + return nil, nil + } + extraArgs := []string{} for i, a := range args[2:] { if !strings.Contains(expansion, "$") { @@ -392,7 +413,7 @@ func ExpandAlias(args []string) ([]string, error) { } lingeringRE := regexp.MustCompile(`\$\d`) if lingeringRE.MatchString(expansion) { - return empty, fmt.Errorf("not enough arguments for alias: %s", expansion) + return nil, fmt.Errorf("not enough arguments for alias: %s", expansion) } newArgs, err := shlex.Split(expansion) @@ -400,9 +421,8 @@ func ExpandAlias(args []string) ([]string, error) { return nil, err } - newArgs = append(newArgs, extraArgs...) + return append(newArgs, extraArgs...), nil - return newArgs, nil } return args[1:], nil