diff --git a/sh/cmd.go b/sh/cmd.go index 312de65a..7310af2e 100644 --- a/sh/cmd.go +++ b/sh/cmd.go @@ -7,59 +7,61 @@ import ( "log" "os" "os/exec" + "path/filepath" "strings" "github.com/magefile/mage/mg" ) -// RunCmd returns a function that will call Run with the given command. This is +// RunCmd returns a function that will call [Run] with the given command. This is // useful for creating command aliases to make your scripts easier to read, like // this: // -// // in a helper file somewhere -// var g0 = sh.RunCmd("go") // go is a keyword :( +// // in a helper file somewhere +// var g0 = sh.RunCmd("go") // go is a keyword :( // -// // somewhere in your main code +// // somewhere in your main code // if err := g0("install", "github.com/gohugo/hugo"); err != nil { // return err -// } +// } // // Args passed to command get baked in as args to the command when you run it. // Any args passed in when you run the returned function will be appended to the // original args. For example, this is equivalent to the above: // -// var goInstall = sh.RunCmd("go", "install") goInstall("github.com/gohugo/hugo") +// var goInstall = sh.RunCmd("go", "install") +// goInstall("github.com/gohugo/hugo") // -// RunCmd uses Exec underneath, so see those docs for more details. +// RunCmd expands environment variables and file globs the same way [Exec] does. func RunCmd(cmd string, args ...string) func(args ...string) error { return func(args2 ...string) error { return Run(cmd, append(args, args2...)...) } } -// OutCmd is like RunCmd except the command returns the output of the -// command. +// OutCmd is like [RunCmd] except the command returns the output of the command. func OutCmd(cmd string, args ...string) func(args ...string) (string, error) { return func(args2 ...string) (string, error) { return Output(cmd, append(args, args2...)...) } } -// Run is like RunWith, but doesn't specify any environment variables. +// Run is like [RunWith], but doesn't specify any environment variables. func Run(cmd string, args ...string) error { return RunWith(nil, cmd, args...) } -// RunV is like Run, but always sends the command's stdout to os.Stdout. +// RunV is like [Run], but always sends the command's stdout to [os.Stdout]. func RunV(cmd string, args ...string) error { _, err := Exec(nil, os.Stdout, os.Stderr, cmd, args...) return err } // RunWith runs the given command, directing stderr to this program's stderr and -// printing stdout to stdout if mage was run with -v. It adds adds env to the -// environment variables for the command being run. Environment variables should -// be in the format name=value. +// printing stdout to stdout if mage was run with -v. It adds env to the +// environment variables for the command being run. +// +// RunWith expands environment variables and file globs the same way [Exec] does. func RunWith(env map[string]string, cmd string, args ...string) error { var output io.Writer if mg.Verbose() { @@ -69,20 +71,21 @@ func RunWith(env map[string]string, cmd string, args ...string) error { return err } -// RunWithV is like RunWith, but always sends the command's stdout to os.Stdout. +// RunWithV is like [RunWith], but always sends the command's stdout to [os.Stdout]. func RunWithV(env map[string]string, cmd string, args ...string) error { _, err := Exec(env, os.Stdout, os.Stderr, cmd, args...) return err } // Output runs the command and returns the text from stdout. +// Output expands environment variables and file globs the same way [Exec] does. func Output(cmd string, args ...string) (string, error) { buf := &bytes.Buffer{} _, err := Exec(nil, buf, os.Stderr, cmd, args...) return strings.TrimSuffix(buf.String(), "\n"), err } -// OutputWith is like RunWith, but returns what is written to stdout. +// OutputWith is like [RunWith], but returns what is written to stdout. func OutputWith(env map[string]string, cmd string, args ...string) (string, error) { buf := &bytes.Buffer{} _, err := Exec(env, buf, os.Stderr, cmd, args...) @@ -91,16 +94,20 @@ func OutputWith(env map[string]string, cmd string, args ...string) (string, erro // Exec executes the command, piping its stdout and stderr to the given // writers. If the command fails, it will return an error that, if returned -// from a target or mg.Deps call, will cause mage to exit with the same code as -// the command failed with. Env is a list of environment variables to set when -// running the command, these override the current environment variables set -// (which are also passed to the command). cmd and args may include references -// to environment variables in $FOO format, in which case these will be -// expanded before the command is run. +// from a target or [mg.Deps] call, will cause mage to exit with the same code as +// the command failed with. env is a list of environment variables to set when +// running the command; these override the current environment variables set +// (which are also passed to the command). +// +// cmd and args may include references to environment variables in $FOO format, +// in which case these will be expanded before the command is run. // -// Ran reports if the command ran (rather than was not found or not executable). -// Code reports the exit code the command returned if it ran. If err == nil, ran -// is always true and code is always 0. +// Also, any file glob patterns in args are expanded. For example, "*.go" will be +// expanded to the list of Go files in the current directory. See [filepath.Match] +// for the syntax. There is no glob expansion of cmd, however. +// +// ran reports if the command ran (rather than was not found or not executable). +// If err == nil, ran is always true. func Exec(env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, err error) { expand := func(s string) string { s2, ok := env[s] @@ -124,7 +131,12 @@ func Exec(env map[string]string, stdout, stderr io.Writer, cmd string, args ...s } func run(env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, code int, err error) { - c := exec.Command(cmd, args...) + expanded, err := expandGlob(args) + if err != nil { + return false, 0, err + } + + c := exec.Command(cmd, expanded...) c.Env = os.Environ() for k, v := range env { c.Env = append(c.Env, k+"="+v) @@ -133,10 +145,11 @@ func run(env map[string]string, stdout, stderr io.Writer, cmd string, args ...st c.Stdout = stdout c.Stdin = os.Stdin - var quoted []string - for i := range args { - quoted = append(quoted, fmt.Sprintf("%q", args[i])); + var quoted []string + for _, arg := range expanded { + quoted = append(quoted, fmt.Sprintf("%q", arg)) } + // To protect against logging from doing exec in global variables if mg.Verbose() { log.Println("exec:", cmd, strings.Join(quoted, " ")) @@ -144,10 +157,28 @@ func run(env map[string]string, stdout, stderr io.Writer, cmd string, args ...st err = c.Run() return CmdRan(err), ExitStatus(err), err } + +func expandGlob(value []string) (result []string, err error) { + for _, v := range value { + matches, err := filepath.Glob(v) + if err != nil { + return nil, err + } + if len(matches) > 0 { + for _, x := range matches { + result = append(result, x) + } + } else { + result = append(result, v) + } + } + return result, nil +} + // CmdRan examines the error to determine if it was generated as a result of a -// command running via os/exec.Command. If the error is nil, or the command ran +// command running via [exec.Command]. If the error is nil, or the command ran // (even if it exited with a non-zero exit code), CmdRan reports true. If the -// error is an unrecognized type, or it is an error from exec.Command that says +// error is an unrecognized type, or it is an error from [exec.Command] that says // the command failed to run (usually due to the command not existing or not // being executable), it reports false. func CmdRan(err error) bool { @@ -165,7 +196,7 @@ type exitStatus interface { ExitStatus() int } -// ExitStatus returns the exit status of the error if it is an exec.ExitError +// ExitStatus returns the exit status of the error if it is an [exec.ExitError] // or if it implements ExitStatus() int. // 0 if it is nil or 1 if it is a different error. func ExitStatus(err error) int { diff --git a/sh/cmd_test.go b/sh/cmd_test.go index c2f5d04f..010e86b9 100644 --- a/sh/cmd_test.go +++ b/sh/cmd_test.go @@ -3,6 +3,7 @@ package sh import ( "bytes" "os" + "strings" "testing" ) @@ -57,7 +58,7 @@ func TestNotRun(t *testing.T) { } } -func TestAutoExpand(t *testing.T) { +func TestAutoEnvExpand(t *testing.T) { if err := os.Setenv("MAGE_FOOBAR", "baz"); err != nil { t.Fatal(err) } @@ -68,5 +69,26 @@ func TestAutoExpand(t *testing.T) { if s != "baz" { t.Fatalf(`Expected "baz" but got %q`, s) } +} +func TestAutoGlobExpand(t *testing.T) { + // these deliberately specify a set of files that should be stable over the longer term + // (i.e. avoiding the names of source code files) + t.Run("* ? and [] glob", func(t *testing.T) { + s, err := Output("ls", "../R*.md", "../go.??[dm]", "../*/config.toml") + if err != nil { + t.Fatal(err) + } + if s != "../go.mod\n../go.sum\n../README.md\n../site/config.toml" { + t.Errorf(`Expected "../go.mod\n../go.sum\n../README.md\n../site/config.toml" but got %q`, s) + } + }) + t.Run("glob syntax error", func(t *testing.T) { + _, err := Output("ls", "../go.\\") + if err == nil { + t.Fatalf("expected error, but got nil") + } else if !strings.Contains(err.Error(), `failed to run "ls ../go.\:`) { + t.Errorf("Actual error was %q", err.Error()) + } + }) }