Skip to content

Commit a4be5fe

Browse files
committed
add sh.RunSh and sh.ExecSh that take functional options
this is primarily to enable passing in a working directory for the command.
1 parent 9e91a03 commit a4be5fe

File tree

2 files changed

+166
-45
lines changed

2 files changed

+166
-45
lines changed

sh/cmd.go

Lines changed: 145 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,152 @@ import (
1212
"github.com/magefile/mage/mg"
1313
)
1414

15+
// runOptions is a set of options to be applied with ExecSh.
16+
type runOptions struct {
17+
cmd string
18+
args []string
19+
dir string
20+
env map[string]string
21+
stderr, stdout io.Writer
22+
}
23+
24+
// RunOpt applies an option to a runOptions set.
25+
type RunOpt func(*runOptions)
26+
27+
// WithV sets stderr and stdout the standard streams
28+
func WithV() RunOpt {
29+
return func(options *runOptions) {
30+
options.stdout = os.Stdout
31+
options.stderr = os.Stderr
32+
}
33+
}
34+
35+
// WithEnv sets the env passed in env vars.
36+
func WithEnv(env map[string]string) RunOpt {
37+
return func(options *runOptions) {
38+
if options.env == nil {
39+
options.env = make(map[string]string)
40+
}
41+
for k, v := range env {
42+
options.env[k] = v
43+
}
44+
}
45+
}
46+
47+
// WithStderr sets the stderr stream.
48+
func WithStderr(w io.Writer) RunOpt {
49+
return func(options *runOptions) {
50+
options.stderr = w
51+
}
52+
}
53+
54+
// WithStdout sets the stdout stream.
55+
func WithStdout(w io.Writer) RunOpt {
56+
return func(options *runOptions) {
57+
options.stdout = w
58+
}
59+
}
60+
61+
// WithDir sets the working directory for the command.
62+
func WithDir(dir string) RunOpt {
63+
return func(options *runOptions) {
64+
options.dir = dir
65+
}
66+
}
67+
68+
// WithArgs appends command arguments.
69+
func WithArgs(args ...string) RunOpt {
70+
return func(options *runOptions) {
71+
if options.args == nil {
72+
options.args = make([]string, 0, len(args))
73+
}
74+
options.args = append(options.args, args...)
75+
}
76+
}
77+
78+
// RunSh returns a function that calls ExecSh, only returning errors.
79+
func RunSh(cmd string, options ...RunOpt) func(args ...string) error {
80+
run := ExecSh(cmd, options...)
81+
return func(args ...string) error {
82+
_, err := run()
83+
return err
84+
}
85+
}
86+
87+
// ExecSh returns a function that executes the command, piping its stdout and
88+
// stderr according to the config options. If the command fails, it will return
89+
// an error that, if returned from a target or mg.Deps call, will cause mage to
90+
// exit with the same code as the command failed with.
91+
//
92+
// ExecSh takes a variable list of RunOpt objects to configure how the command
93+
// is executed. See RunOpt docs for more details.
94+
//
95+
// Env vars configured on the command override the current environment variables
96+
// set (which are also passed to the command). The cmd and args may include
97+
// references to environment variables in $FOO format, in which case these will be
98+
// expanded before the command is run.
99+
//
100+
// Ran reports if the command ran (rather than was not found or not executable).
101+
// Code reports the exit code the command returned if it ran. If err == nil, ran
102+
// is always true and code is always 0.
103+
func ExecSh(cmd string, options ...RunOpt) func(args ...string) (bool, error) {
104+
opts := runOptions{
105+
cmd: cmd,
106+
}
107+
for _, o := range options {
108+
o(&opts)
109+
}
110+
111+
if opts.stdout == nil && mg.Verbose() {
112+
opts.stdout = os.Stdout
113+
}
114+
115+
return func(args ...string) (bool, error) {
116+
expand := func(s string) string {
117+
s2, ok := opts.env[s]
118+
if ok {
119+
return s2
120+
}
121+
return os.Getenv(s)
122+
}
123+
cmd = os.Expand(cmd, expand)
124+
finalArgs := append(opts.args, args...)
125+
for i := range finalArgs {
126+
finalArgs[i] = os.Expand(finalArgs[i], expand)
127+
}
128+
ran, code, err := run(opts.dir, opts.env, opts.stdout, opts.stderr, cmd, finalArgs...)
129+
130+
if err == nil {
131+
return ran, nil
132+
}
133+
if ran {
134+
return ran, mg.Fatalf(code, `running "%s %s" failed with exit code %d`, cmd, strings.Join(args, " "), code)
135+
}
136+
return ran, fmt.Errorf(`failed to run "%s %s: %v"`, cmd, strings.Join(args, " "), err)
137+
}
138+
}
139+
15140
// RunCmd returns a function that will call Run with the given command. This is
16141
// useful for creating command aliases to make your scripts easier to read, like
17142
// this:
18143
//
19-
// // in a helper file somewhere
20-
// var g0 = sh.RunCmd("go") // go is a keyword :(
144+
// // in a helper file somewhere
145+
// var g0 = sh.RunCmd("go") // go is a keyword :(
21146
//
22-
// // somewhere in your main code
23-
// if err := g0("install", "github.com/gohugo/hugo"); err != nil {
24-
// return err
25-
// }
147+
// // somewhere in your main code
148+
// if err := g0("install", "github.com/gohugo/hugo"); err != nil {
149+
// return err
150+
// }
26151
//
27152
// Args passed to command get baked in as args to the command when you run it.
28153
// Any args passed in when you run the returned function will be appended to the
29154
// original args. For example, this is equivalent to the above:
30155
//
31-
// var goInstall = sh.RunCmd("go", "install") goInstall("github.com/gohugo/hugo")
156+
// var goInstall = sh.RunCmd("go", "install") goInstall("github.com/gohugo/hugo")
32157
//
33158
// RunCmd uses Exec underneath, so see those docs for more details.
34159
func RunCmd(cmd string, args ...string) func(args ...string) error {
35-
return func(args2 ...string) error {
36-
return Run(cmd, append(args, args2...)...)
37-
}
160+
return RunSh(cmd, WithArgs(args...))
38161
}
39162

40163
// OutCmd is like RunCmd except the command returns the output of the
@@ -47,45 +170,38 @@ func OutCmd(cmd string, args ...string) func(args ...string) (string, error) {
47170

48171
// Run is like RunWith, but doesn't specify any environment variables.
49172
func Run(cmd string, args ...string) error {
50-
return RunWith(nil, cmd, args...)
173+
return RunSh(cmd, WithArgs(args...))()
51174
}
52175

53176
// RunV is like Run, but always sends the command's stdout to os.Stdout.
54177
func RunV(cmd string, args ...string) error {
55-
_, err := Exec(nil, os.Stdout, os.Stderr, cmd, args...)
56-
return err
178+
return RunSh(cmd, WithV(), WithArgs(args...))()
57179
}
58180

59181
// RunWith runs the given command, directing stderr to this program's stderr and
60182
// printing stdout to stdout if mage was run with -v. It adds adds env to the
61183
// environment variables for the command being run. Environment variables should
62184
// be in the format name=value.
63185
func RunWith(env map[string]string, cmd string, args ...string) error {
64-
var output io.Writer
65-
if mg.Verbose() {
66-
output = os.Stdout
67-
}
68-
_, err := Exec(env, output, os.Stderr, cmd, args...)
69-
return err
186+
return RunSh(cmd, WithEnv(env), WithArgs(args...))()
70187
}
71188

72189
// RunWithV is like RunWith, but always sends the command's stdout to os.Stdout.
73190
func RunWithV(env map[string]string, cmd string, args ...string) error {
74-
_, err := Exec(env, os.Stdout, os.Stderr, cmd, args...)
75-
return err
191+
return RunSh(cmd, WithV(), WithEnv(env), WithArgs(args...))()
76192
}
77193

78194
// Output runs the command and returns the text from stdout.
79195
func Output(cmd string, args ...string) (string, error) {
80196
buf := &bytes.Buffer{}
81-
_, err := Exec(nil, buf, os.Stderr, cmd, args...)
197+
err := RunSh(cmd, WithStderr(os.Stderr), WithStdout(buf), WithArgs(args...))()
82198
return strings.TrimSuffix(buf.String(), "\n"), err
83199
}
84200

85201
// OutputWith is like RunWith, but returns what is written to stdout.
86202
func OutputWith(env map[string]string, cmd string, args ...string) (string, error) {
87203
buf := &bytes.Buffer{}
88-
_, err := Exec(env, buf, os.Stderr, cmd, args...)
204+
err := RunSh(cmd, WithEnv(env), WithStderr(os.Stderr), WithStdout(buf), WithArgs(args...))()
89205
return strings.TrimSuffix(buf.String(), "\n"), err
90206
}
91207

@@ -102,40 +218,23 @@ func OutputWith(env map[string]string, cmd string, args ...string) (string, erro
102218
// Code reports the exit code the command returned if it ran. If err == nil, ran
103219
// is always true and code is always 0.
104220
func Exec(env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, err error) {
105-
expand := func(s string) string {
106-
s2, ok := env[s]
107-
if ok {
108-
return s2
109-
}
110-
return os.Getenv(s)
111-
}
112-
cmd = os.Expand(cmd, expand)
113-
for i := range args {
114-
args[i] = os.Expand(args[i], expand)
115-
}
116-
ran, code, err := run(env, stdout, stderr, cmd, args...)
117-
if err == nil {
118-
return true, nil
119-
}
120-
if ran {
121-
return ran, mg.Fatalf(code, `running "%s %s" failed with exit code %d`, cmd, strings.Join(args, " "), code)
122-
}
123-
return ran, fmt.Errorf(`failed to run "%s %s: %v"`, cmd, strings.Join(args, " "), err)
221+
return ExecSh(cmd, WithArgs(args...), WithStderr(stderr), WithStdout(stdout), WithEnv(env))()
124222
}
125223

126-
func run(env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, code int, err error) {
224+
func run(dir string, env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, code int, err error) {
127225
c := exec.Command(cmd, args...)
128226
c.Env = os.Environ()
129227
for k, v := range env {
130228
c.Env = append(c.Env, k+"="+v)
131229
}
230+
c.Dir = dir
132231
c.Stderr = stderr
133232
c.Stdout = stdout
134233
c.Stdin = os.Stdin
135234

136-
var quoted []string
235+
var quoted []string
137236
for i := range args {
138-
quoted = append(quoted, fmt.Sprintf("%q", args[i]));
237+
quoted = append(quoted, fmt.Sprintf("%q", args[i]))
139238
}
140239
// To protect against logging from doing exec in global variables
141240
if mg.Verbose() {
@@ -144,6 +243,7 @@ func run(env map[string]string, stdout, stderr io.Writer, cmd string, args ...st
144243
err = c.Run()
145244
return CmdRan(err), ExitStatus(err), err
146245
}
246+
147247
// CmdRan examines the error to determine if it was generated as a result of a
148248
// command running via os/exec.Command. If the error is nil, or the command ran
149249
// (even if it exited with a non-zero exit code), CmdRan reports true. If the

sh/cmd_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package sh
33
import (
44
"bytes"
55
"os"
6+
"path/filepath"
7+
"strings"
68
"testing"
79
)
810

@@ -68,5 +70,24 @@ func TestAutoExpand(t *testing.T) {
6870
if s != "baz" {
6971
t.Fatalf(`Expected "baz" but got %q`, s)
7072
}
73+
}
7174

75+
func TestDirectory(t *testing.T) {
76+
tmp := t.TempDir()
77+
buf := &bytes.Buffer{}
78+
err := RunSh("pwd", WithDir(tmp), WithStdout(buf))()
79+
if err != nil {
80+
t.Fatal(err)
81+
}
82+
dir, err := filepath.EvalSymlinks(strings.TrimSpace(buf.String()))
83+
if err != nil {
84+
t.Fatal(err)
85+
}
86+
tmpDir, err := filepath.EvalSymlinks(tmp)
87+
if err != nil {
88+
t.Fatal(err)
89+
}
90+
if dir != tmpDir {
91+
t.Fatalf(`Expected %q but got %q`, tmpDir, dir)
92+
}
7293
}

0 commit comments

Comments
 (0)