Implement shell aliases

This command adds --shell to `gh alias set`, allowing specified aliases
to be run through a shell interpreter.
This commit is contained in:
vilmibm 2020-06-12 14:24:25 -05:00
parent 2b96d2ce1f
commit 3a9167cfe4
4 changed files with 162 additions and 64 deletions

View file

@ -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)
}

View file

@ -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 <alias> <expansion>",
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",

View file

@ -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)
}

View file

@ -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