From 58c0e6a184654f2ab75c7a37f5beca50eda5a318 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Fri, 6 Feb 2026 13:45:52 +0100 Subject: [PATCH 1/4] feat: Add ability to run commands at gives paths Sometimes a command needs to run at a given path. The current `sh` package does not have this ability. This PR adds the feature. It adds "At" to all the run functions where you can supply it with a path were it needs to run. The functions are added under a new name so this change is backwards compatable. Signed-off-by: Atze de Vries --- sh/cmd.go | 102 ++++++++++++++++++++++++++++++++++++++++--------- sh/cmd_test.go | 60 +++++++++++++++++++++++++++-- 2 files changed, 142 insertions(+), 20 deletions(-) diff --git a/sh/cmd.go b/sh/cmd.go index 312de65a..c77cd226 100644 --- a/sh/cmd.go +++ b/sh/cmd.go @@ -16,19 +16,19 @@ import ( // 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 -// if err := g0("install", "github.com/gohugo/hugo"); err != nil { -// return err -// } +// // 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. func RunCmd(cmd string, args ...string) func(args ...string) error { @@ -37,6 +37,31 @@ func RunCmd(cmd string, args ...string) func(args ...string) error { } } +// RunAtCmd returns a function that will call Run with the given command at a given path. +// This is useful for creating command aliases to make your scripts easier to read, like +// this: +// +// // in a helper file somewhere +// var g0 = sh.RunAtCmd("go") // go is a keyword :( +// +// // somewhere in your main code +// if err := g0("/tmp", "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.RunAtCmd("go", "install") goInstall("tmp", "github.com/gohugo/hugo") +// +// RunAtCmd uses Exec underneath, so see those docs for more details. +func RunAtCmd(cmd string, args ...string) func(pwd string, args ...string) error { + return func(pwd string, args2 ...string) error { + return RunAt(pwd, cmd, append(args, args2...)...) + } +} + // OutCmd is like RunCmd except the command returns the output of the // command. func OutCmd(cmd string, args ...string) func(args ...string) (string, error) { @@ -45,14 +70,32 @@ func OutCmd(cmd string, args ...string) func(args ...string) (string, error) { } } +// OutAtCmd is like RunAtCmd except the command returns the output of the +// command. +func OutAtCmd(cmd string, args ...string) func(pwd string, args ...string) (string, error) { + return func(pwd string, args2 ...string) (string, error) { + return OutputAt(pwd, cmd, append(args, args2...)...) + } +} + // Run is like RunWith, but doesn't specify any environment variables. func Run(cmd string, args ...string) error { return RunWith(nil, cmd, args...) } +// RunAt is like RunAtWith, but doesn't specify any environment variables. +func RunAt(pwd, cmd string, args ...string) error { + return RunAtWith(nil, pwd, cmd, args...) +} + // 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 RunAtV("", cmd, args...) +} + +// RunAtV is like RunAt, but always sends the command's stdout to os.Stdout. +func RunAtV(pwd string, cmd string, args ...string) error { + _, err := Exec(nil, os.Stdout, os.Stderr, pwd, cmd, args...) return err } @@ -61,31 +104,54 @@ func RunV(cmd string, args ...string) error { // environment variables for the command being run. Environment variables should // be in the format name=value. func RunWith(env map[string]string, cmd string, args ...string) error { + return RunAtWith(env, "", cmd, args...) +} + +// RunAtWith runs the given command at a certain path, 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. +func RunAtWith(env map[string]string, pwd string, cmd string, args ...string) error { var output io.Writer if mg.Verbose() { output = os.Stdout } - _, err := Exec(env, output, os.Stderr, cmd, args...) + _, err := Exec(env, output, os.Stderr, pwd, cmd, args...) return err } // 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 RunAtWithV(env, "", cmd, args...) +} + +// RunAtWithV is like RunAtWith, but always sends the command's stdout to os.Stdout. +func RunAtWithV(env map[string]string, pwd string, cmd string, args ...string) error { + _, err := Exec(env, os.Stdout, os.Stderr, pwd, cmd, args...) return err } // Output runs the command and returns the text from stdout. func Output(cmd string, args ...string) (string, error) { + return OutputAt("", cmd, args...) +} + +// OutputAt runs the command at a certain path and returns the text from stdout. +func OutputAt(pwd string, cmd string, args ...string) (string, error) { buf := &bytes.Buffer{} - _, err := Exec(nil, buf, os.Stderr, cmd, args...) + _, err := Exec(nil, buf, os.Stderr, pwd, cmd, args...) return strings.TrimSuffix(buf.String(), "\n"), err } // OutputWith is like RunWith, but returns what is written to stdout. func OutputWith(env map[string]string, cmd string, args ...string) (string, error) { + return OutputAtWith(env, "", cmd, args...) +} + +// OutputAt With is like RunAtWith, but returns what is written to stdout. +func OutputAtWith(env map[string]string, pwd string, cmd string, args ...string) (string, error) { buf := &bytes.Buffer{} - _, err := Exec(env, buf, os.Stderr, cmd, args...) + _, err := Exec(env, buf, os.Stderr, pwd, cmd, args...) return strings.TrimSuffix(buf.String(), "\n"), err } @@ -101,7 +167,7 @@ func OutputWith(env map[string]string, cmd string, args ...string) (string, erro // 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. -func Exec(env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, err error) { +func Exec(env map[string]string, stdout, stderr io.Writer, pwd string, cmd string, args ...string) (ran bool, err error) { expand := func(s string) string { s2, ok := env[s] if ok { @@ -113,7 +179,7 @@ func Exec(env map[string]string, stdout, stderr io.Writer, cmd string, args ...s for i := range args { args[i] = os.Expand(args[i], expand) } - ran, code, err := run(env, stdout, stderr, cmd, args...) + ran, code, err := run(env, stdout, stderr, pwd, cmd, args...) if err == nil { return true, nil } @@ -123,19 +189,20 @@ func Exec(env map[string]string, stdout, stderr io.Writer, cmd string, args ...s return ran, fmt.Errorf(`failed to run "%s %s: %v"`, cmd, strings.Join(args, " "), err) } -func run(env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, code int, err error) { +func run(env map[string]string, stdout, stderr io.Writer, pwd string, cmd string, args ...string) (ran bool, code int, err error) { c := exec.Command(cmd, args...) c.Env = os.Environ() for k, v := range env { c.Env = append(c.Env, k+"="+v) } + c.Dir = pwd c.Stderr = stderr c.Stdout = stdout c.Stdin = os.Stdin - var quoted []string + var quoted []string for i := range args { - quoted = append(quoted, fmt.Sprintf("%q", args[i])); + quoted = append(quoted, fmt.Sprintf("%q", args[i])) } // To protect against logging from doing exec in global variables if mg.Verbose() { @@ -144,6 +211,7 @@ func run(env map[string]string, stdout, stderr io.Writer, cmd string, args ...st err = c.Run() return CmdRan(err), ExitStatus(err), err } + // 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 // (even if it exited with a non-zero exit code), CmdRan reports true. If the diff --git a/sh/cmd_test.go b/sh/cmd_test.go index c2f5d04f..e2d34ef8 100644 --- a/sh/cmd_test.go +++ b/sh/cmd_test.go @@ -2,6 +2,7 @@ package sh import ( "bytes" + "fmt" "os" "testing" ) @@ -18,8 +19,20 @@ func TestOutCmd(t *testing.T) { } } +func TestOutAtCmd(t *testing.T) { + cmd := OutAtCmd("sh", "-c") + out, err := cmd("/", "pwd") + if err != nil { + t.Fatal(err) + } + expected := "/" + if out != expected { + t.Fatalf("expected %q but got %q", expected, out) + } +} + func TestExitCode(t *testing.T) { - ran, err := Exec(nil, nil, nil, os.Args[0], "-helper", "-exit", "99") + ran, err := Exec(nil, nil, nil, "", os.Args[0], "-helper", "-exit", "99") if err == nil { t.Fatal("unexpected nil error from run") } @@ -35,7 +48,7 @@ func TestExitCode(t *testing.T) { func TestEnv(t *testing.T) { env := "SOME_REALLY_LONG_MAGEFILE_SPECIFIC_THING" out := &bytes.Buffer{} - ran, err := Exec(map[string]string{env: "foobar"}, out, nil, os.Args[0], "-printVar", env) + ran, err := Exec(map[string]string{env: "foobar"}, out, nil, "", os.Args[0], "-printVar", env) if err != nil { t.Fatalf("unexpected error from runner: %#v", err) } @@ -48,7 +61,7 @@ func TestEnv(t *testing.T) { } func TestNotRun(t *testing.T) { - ran, err := Exec(nil, nil, nil, "thiswontwork") + ran, err := Exec(nil, nil, nil, "", "thiswontwork") if err == nil { t.Fatal("unexpected nil error") } @@ -68,5 +81,46 @@ func TestAutoExpand(t *testing.T) { if s != "baz" { t.Fatalf(`Expected "baz" but got %q`, s) } +} + +func TestSettingPwd(t *testing.T) { + pwd := "/" + out := &bytes.Buffer{} + ran, err := Exec(nil, out, nil, pwd, "pwd") + if err != nil { + t.Fatalf("unexpected error from runner: %#v", err) + } + if !ran { + t.Errorf("expected ran to be true but was false.") + } + if out.String() != fmt.Sprintf("%s\n", pwd) { + t.Errorf("expected %s, got %q", fmt.Sprintf("%s\n", pwd), out) + } +} +func TestSettingNoPwd(t *testing.T) { + currentWd, err := os.Getwd() + if err != nil { + t.Errorf("Failed getting current working directory") + } + out := &bytes.Buffer{} + ran, err := Exec(nil, out, nil, "", "pwd") + if err != nil { + t.Fatalf("unexpected error from runner: %#v", err) + } + if !ran { + t.Errorf("expected ran to be true but was false.") + } + if out.String() != fmt.Sprintf("%s\n", currentWd) { + t.Errorf("expected %s, got %q", fmt.Sprintf("%s\n", currentWd), out) + } +} + +func TestSettingInvalidPwd(t *testing.T) { + pwd := "/i-am-expected-to-not-exist" + out := &bytes.Buffer{} + _, err := Exec(nil, out, nil, pwd, "pwd") + if err == nil { + t.Fatalf("I am expected to fail because path %s does not exist", pwd) + } } From f3b80ef0070bbb375317b500ed9448faab41d11f Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Mon, 2 Mar 2026 14:59:10 +0100 Subject: [PATCH 2/4] also add ExecAt command and restore orig Exec Signed-off-by: Atze de Vries --- sh/cmd.go | 20 +++++++++++++------- sh/cmd_test.go | 12 ++++++------ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/sh/cmd.go b/sh/cmd.go index c77cd226..b56559e9 100644 --- a/sh/cmd.go +++ b/sh/cmd.go @@ -95,7 +95,7 @@ func RunV(cmd string, args ...string) error { // RunAtV is like RunAt, but always sends the command's stdout to os.Stdout. func RunAtV(pwd string, cmd string, args ...string) error { - _, err := Exec(nil, os.Stdout, os.Stderr, pwd, cmd, args...) + _, err := ExecAt(nil, os.Stdout, os.Stderr, pwd, cmd, args...) return err } @@ -116,7 +116,7 @@ func RunAtWith(env map[string]string, pwd string, cmd string, args ...string) er if mg.Verbose() { output = os.Stdout } - _, err := Exec(env, output, os.Stderr, pwd, cmd, args...) + _, err := ExecAt(env, output, os.Stderr, pwd, cmd, args...) return err } @@ -127,7 +127,7 @@ func RunWithV(env map[string]string, cmd string, args ...string) error { // RunAtWithV is like RunAtWith, but always sends the command's stdout to os.Stdout. func RunAtWithV(env map[string]string, pwd string, cmd string, args ...string) error { - _, err := Exec(env, os.Stdout, os.Stderr, pwd, cmd, args...) + _, err := ExecAt(env, os.Stdout, os.Stderr, pwd, cmd, args...) return err } @@ -139,7 +139,7 @@ func Output(cmd string, args ...string) (string, error) { // OutputAt runs the command at a certain path and returns the text from stdout. func OutputAt(pwd string, cmd string, args ...string) (string, error) { buf := &bytes.Buffer{} - _, err := Exec(nil, buf, os.Stderr, pwd, cmd, args...) + _, err := ExecAt(nil, buf, os.Stderr, pwd, cmd, args...) return strings.TrimSuffix(buf.String(), "\n"), err } @@ -151,11 +151,16 @@ func OutputWith(env map[string]string, cmd string, args ...string) (string, erro // OutputAt With is like RunAtWith, but returns what is written to stdout. func OutputAtWith(env map[string]string, pwd string, cmd string, args ...string) (string, error) { buf := &bytes.Buffer{} - _, err := Exec(env, buf, os.Stderr, pwd, cmd, args...) + _, err := ExecAt(env, buf, os.Stderr, pwd, cmd, args...) return strings.TrimSuffix(buf.String(), "\n"), err } -// Exec executes the command, piping its stdout and stderr to the given +// Exec is like execAt but always runs in the current workdir. +func Exec(env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, err error) { + return ExecAt(env, stdout, stderr, "", cmd, args...) +} + +// ExecAt 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 @@ -167,7 +172,8 @@ func OutputAtWith(env map[string]string, pwd string, cmd string, args ...string) // 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. -func Exec(env map[string]string, stdout, stderr io.Writer, pwd string, cmd string, args ...string) (ran bool, err error) { + +func ExecAt(env map[string]string, stdout, stderr io.Writer, pwd string, cmd string, args ...string) (ran bool, err error) { expand := func(s string) string { s2, ok := env[s] if ok { diff --git a/sh/cmd_test.go b/sh/cmd_test.go index e2d34ef8..3afa914a 100644 --- a/sh/cmd_test.go +++ b/sh/cmd_test.go @@ -32,7 +32,7 @@ func TestOutAtCmd(t *testing.T) { } func TestExitCode(t *testing.T) { - ran, err := Exec(nil, nil, nil, "", os.Args[0], "-helper", "-exit", "99") + ran, err := Exec(nil, nil, nil, os.Args[0], "-helper", "-exit", "99") if err == nil { t.Fatal("unexpected nil error from run") } @@ -48,7 +48,7 @@ func TestExitCode(t *testing.T) { func TestEnv(t *testing.T) { env := "SOME_REALLY_LONG_MAGEFILE_SPECIFIC_THING" out := &bytes.Buffer{} - ran, err := Exec(map[string]string{env: "foobar"}, out, nil, "", os.Args[0], "-printVar", env) + ran, err := Exec(map[string]string{env: "foobar"}, out, nil, os.Args[0], "-printVar", env) if err != nil { t.Fatalf("unexpected error from runner: %#v", err) } @@ -61,7 +61,7 @@ func TestEnv(t *testing.T) { } func TestNotRun(t *testing.T) { - ran, err := Exec(nil, nil, nil, "", "thiswontwork") + ran, err := Exec(nil, nil, nil, "thiswontwork") if err == nil { t.Fatal("unexpected nil error") } @@ -86,7 +86,7 @@ func TestAutoExpand(t *testing.T) { func TestSettingPwd(t *testing.T) { pwd := "/" out := &bytes.Buffer{} - ran, err := Exec(nil, out, nil, pwd, "pwd") + ran, err := ExecAt(nil, out, nil, pwd, "pwd") if err != nil { t.Fatalf("unexpected error from runner: %#v", err) } @@ -104,7 +104,7 @@ func TestSettingNoPwd(t *testing.T) { t.Errorf("Failed getting current working directory") } out := &bytes.Buffer{} - ran, err := Exec(nil, out, nil, "", "pwd") + ran, err := ExecAt(nil, out, nil, "", "pwd") if err != nil { t.Fatalf("unexpected error from runner: %#v", err) } @@ -119,7 +119,7 @@ func TestSettingNoPwd(t *testing.T) { func TestSettingInvalidPwd(t *testing.T) { pwd := "/i-am-expected-to-not-exist" out := &bytes.Buffer{} - _, err := Exec(nil, out, nil, pwd, "pwd") + _, err := ExecAt(nil, out, nil, pwd, "pwd") if err == nil { t.Fatalf("I am expected to fail because path %s does not exist", pwd) } From 09bc785e43631ba8e63a3596b3e227c52f7b54dc Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Wed, 11 Mar 2026 21:49:09 +0100 Subject: [PATCH 3/4] Fix some suggestions in error messages Co-authored-by: Michel Laterman <82832767+michel-laterman@users.noreply.github.com> --- sh/cmd_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sh/cmd_test.go b/sh/cmd_test.go index 3afa914a..52b284b6 100644 --- a/sh/cmd_test.go +++ b/sh/cmd_test.go @@ -91,7 +91,7 @@ func TestSettingPwd(t *testing.T) { t.Fatalf("unexpected error from runner: %#v", err) } if !ran { - t.Errorf("expected ran to be true but was false.") + t.Error("expected ran to be true but was false.") } if out.String() != fmt.Sprintf("%s\n", pwd) { t.Errorf("expected %s, got %q", fmt.Sprintf("%s\n", pwd), out) @@ -101,7 +101,7 @@ func TestSettingPwd(t *testing.T) { func TestSettingNoPwd(t *testing.T) { currentWd, err := os.Getwd() if err != nil { - t.Errorf("Failed getting current working directory") + t.Errorf("Failed getting current working directory: %v", err) } out := &bytes.Buffer{} ran, err := ExecAt(nil, out, nil, "", "pwd") @@ -121,6 +121,6 @@ func TestSettingInvalidPwd(t *testing.T) { out := &bytes.Buffer{} _, err := ExecAt(nil, out, nil, pwd, "pwd") if err == nil { - t.Fatalf("I am expected to fail because path %s does not exist", pwd) + t.Fatalf("Expected error because path %s does not exist", pwd) } } From eca9c1d8ed1594d93cb89aa05ac6e5aca16f4b61 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Wed, 11 Mar 2026 22:14:03 +0100 Subject: [PATCH 4/4] print working dir alongside exec when running verbose Signed-off-by: Atze de Vries --- sh/cmd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sh/cmd.go b/sh/cmd.go index b56559e9..6e65d527 100644 --- a/sh/cmd.go +++ b/sh/cmd.go @@ -212,7 +212,7 @@ func run(env map[string]string, stdout, stderr io.Writer, pwd string, cmd string } // To protect against logging from doing exec in global variables if mg.Verbose() { - log.Println("exec:", cmd, strings.Join(quoted, " ")) + log.Printf("exec: %s %s, pwd: %s\n", cmd, strings.Join(quoted, " "), pwd) } err = c.Run() return CmdRan(err), ExitStatus(err), err