Skip to content

Commit 9603180

Browse files
committed
feat: add -dryrun mode
1 parent 78acbaf commit 9603180

File tree

8 files changed

+247
-23
lines changed

8 files changed

+247
-23
lines changed

internal/dryrun.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package internal
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"sync"
7+
)
8+
9+
// DryRunRequestedEnv is the environment variable that indicates the user requested dryrun mode when running mage.
10+
const DryRunRequestedEnv = "MAGEFILE_DRYRUN"
11+
12+
// DryRunPossibleEnv is the environment variable that indicates we are in a context where a dry run is possible.
13+
const DryRunPossibleEnv = "MAGEFILE_DRYRUN_POSSIBLE"
14+
15+
var (
16+
// Once-protected variables for whether the user requested dryrun mode.
17+
dryRunRequestedValue bool
18+
dryRunRequestedEnvValue bool
19+
dryRunRequestedOnce sync.Once
20+
21+
// Once-protected variables for whether dryrun mode is possible.
22+
dryRunPossible bool
23+
dryRunPossibleOnce sync.Once
24+
)
25+
26+
// SetDryRunRequested sets the dryrun requested state to the specified boolean value.
27+
func SetDryRunRequested(value bool) {
28+
dryRunRequestedValue = value
29+
}
30+
31+
// IsDryRunRequested checks if dry-run mode was requested, either explicitly or via an environment variable.
32+
func IsDryRunRequested() bool {
33+
dryRunRequestedOnce.Do(func() {
34+
if os.Getenv(DryRunRequestedEnv) != "" {
35+
SetDryRunRequested(true)
36+
}
37+
})
38+
39+
return dryRunRequestedEnvValue || dryRunRequestedValue
40+
}
41+
42+
// IsDryRunPossible checks if dry-run mode is supported in the current context.
43+
func IsDryRunPossible() bool {
44+
dryRunPossibleOnce.Do(func() {
45+
dryRunPossible = os.Getenv(DryRunPossibleEnv) != ""
46+
})
47+
48+
return dryRunPossible
49+
}
50+
51+
// WrapRun creates an *exec.Cmd to run a command or simulate it in dry-run mode.
52+
// If not in dry-run mode, it returns exec.Command(cmd, args...).
53+
// In dry-run mode, it returns a command that prints the simulated command.
54+
func WrapRun(cmd string, args ...string) *exec.Cmd {
55+
if !IsDryRun() {
56+
return exec.Command(cmd, args...)
57+
}
58+
59+
// Return an *exec.Cmd that just prints the command that would have been run.
60+
return exec.Command("echo", append([]string{"DRYRUN: " + cmd}, args...)...)
61+
}
62+
63+
// IsDryRun determines if dry-run mode is both possible and requested.
64+
func IsDryRun() bool {
65+
return IsDryRunPossible() && IsDryRunRequested()
66+
}

internal/dryrun_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package internal
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// These tests verify dry-run behavior by spawning a fresh process of the
11+
// current test binary with purpose-built helper flags defined in this package's
12+
// TestMain (see testmain_test.go). Spawning a new process ensures the
13+
// sync.Once guards inside dryrun.go evaluate environment variables afresh.
14+
15+
func TestIsDryRunRequestedEnv(t *testing.T) {
16+
cmd := exec.Command(os.Args[0], "-printIsDryRunRequested")
17+
cmd.Env = append(os.Environ(), DryRunRequestedEnv+"=1", DryRunPossibleEnv+"=1")
18+
out, err := cmd.CombinedOutput()
19+
if err != nil {
20+
t.Fatalf("subprocess failed: %v", err)
21+
}
22+
if strings.TrimSpace(string(out)) != "true" {
23+
t.Fatalf("expected true, got %q", strings.TrimSpace(string(out)))
24+
}
25+
}
26+
27+
func TestIsDryRunPossibleEnv(t *testing.T) {
28+
cmd := exec.Command(os.Args[0], "-printIsDryRunPossible")
29+
cmd.Env = append(os.Environ(), DryRunPossibleEnv+"=1")
30+
out, err := cmd.CombinedOutput()
31+
if err != nil {
32+
t.Fatalf("subprocess failed: %v", err)
33+
}
34+
if strings.TrimSpace(string(out)) != "true" {
35+
t.Fatalf("expected true, got %q", strings.TrimSpace(string(out)))
36+
}
37+
}
38+
39+
func TestIsDryRunRequiresBoth(t *testing.T) {
40+
// Only requested set => not possible, so overall false
41+
cmd := exec.Command(os.Args[0], "-printIsDryRun")
42+
cmd.Env = append(os.Environ(), DryRunRequestedEnv+"=1")
43+
out, err := cmd.CombinedOutput()
44+
if err != nil {
45+
t.Fatalf("subprocess failed: %v", err)
46+
}
47+
if strings.TrimSpace(string(out)) != "false" {
48+
t.Fatalf("expected false, got %q", strings.TrimSpace(string(out)))
49+
}
50+
51+
// Only possible set => not requested, so overall false
52+
cmd = exec.Command(os.Args[0], "-printIsDryRun")
53+
cmd.Env = append(os.Environ(), DryRunPossibleEnv+"=1")
54+
out, err = cmd.CombinedOutput()
55+
if err != nil {
56+
t.Fatalf("subprocess failed: %v", err)
57+
}
58+
if strings.TrimSpace(string(out)) != "false" {
59+
t.Fatalf("expected false, got %q", strings.TrimSpace(string(out)))
60+
}
61+
62+
// Both set => true
63+
cmd = exec.Command(os.Args[0], "-printIsDryRun")
64+
cmd.Env = append(os.Environ(), DryRunRequestedEnv+"=1", DryRunPossibleEnv+"=1")
65+
out, err = cmd.CombinedOutput()
66+
if err != nil {
67+
t.Fatalf("subprocess failed: %v", err)
68+
}
69+
if strings.TrimSpace(string(out)) != "true" {
70+
t.Fatalf("expected true, got %q", strings.TrimSpace(string(out)))
71+
}
72+
}

internal/run.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"io/ioutil"
77
"log"
88
"os"
9-
"os/exec"
109
"runtime"
1110
"strings"
1211
)
@@ -25,7 +24,7 @@ func RunDebug(cmd string, args ...string) error {
2524
buf := &bytes.Buffer{}
2625
errbuf := &bytes.Buffer{}
2726
debug.Println("running", cmd, strings.Join(args, " "))
28-
c := exec.Command(cmd, args...)
27+
c := WrapRun(cmd, args...)
2928
c.Env = env
3029
c.Stderr = errbuf
3130
c.Stdout = buf
@@ -45,7 +44,7 @@ func OutputDebug(cmd string, args ...string) (string, error) {
4544
buf := &bytes.Buffer{}
4645
errbuf := &bytes.Buffer{}
4746
debug.Println("running", cmd, strings.Join(args, " "))
48-
c := exec.Command(cmd, args...)
47+
c := WrapRun(cmd, args...)
4948
c.Env = env
5049
c.Stderr = errbuf
5150
c.Stdout = buf

internal/testmain_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package internal
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
"os"
7+
"testing"
8+
)
9+
10+
var (
11+
printIsDryRunRequested bool
12+
printIsDryRunPossible bool
13+
printIsDryRun bool
14+
)
15+
16+
func init() {
17+
flag.BoolVar(&printIsDryRunRequested, "printIsDryRunRequested", false, "")
18+
flag.BoolVar(&printIsDryRunPossible, "printIsDryRunPossible", false, "")
19+
flag.BoolVar(&printIsDryRun, "printIsDryRun", false, "")
20+
}
21+
22+
func TestMain(m *testing.M) {
23+
flag.Parse()
24+
if printIsDryRunRequested {
25+
fmt.Println(IsDryRunRequested())
26+
return
27+
}
28+
if printIsDryRunPossible {
29+
fmt.Println(IsDryRunPossible())
30+
return
31+
}
32+
if printIsDryRun {
33+
fmt.Println(IsDryRun())
34+
return
35+
}
36+
os.Exit(m.Run())
37+
}

mage/main.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"io/ioutil"
1212
"log"
1313
"os"
14-
"os/exec"
1514
"os/signal"
1615
"path/filepath"
1716
"regexp"
@@ -107,6 +106,7 @@ type Invocation struct {
107106
List bool // tells the magefile to print out a list of targets
108107
Help bool // tells the magefile to print out help for a specific target
109108
Keep bool // tells mage to keep the generated main file after compiling
109+
DryRun bool // tells mage that all sh.Run* commands should print, but not execute
110110
Timeout time.Duration // tells mage to set a timeout to running the targets
111111
CompileOut string // tells mage to compile a static binary to this path, but not execute
112112
GOOS string // sets the GOOS when producing a binary with -compileout
@@ -192,6 +192,7 @@ func Parse(stderr, stdout io.Writer, args []string) (inv Invocation, cmd Command
192192
fs.BoolVar(&inv.Help, "h", false, "show this help")
193193
fs.DurationVar(&inv.Timeout, "t", 0, "timeout in duration parsable format (e.g. 5m30s)")
194194
fs.BoolVar(&inv.Keep, "keep", false, "keep intermediate mage files around after running")
195+
fs.BoolVar(&inv.DryRun, "dryrun", false, "print commands instead of executing them")
195196
fs.StringVar(&inv.Dir, "d", "", "directory to read magefiles from")
196197
fs.StringVar(&inv.WorkDir, "w", "", "working directory where magefiles will run")
197198
fs.StringVar(&inv.GoCmd, "gocmd", mg.GoCmd(), "use the given go binary to compile the output")
@@ -230,6 +231,7 @@ Options:
230231
-d <string>
231232
directory to read magefiles from (default "." or "magefiles" if exists)
232233
-debug turn on debug messages
234+
-dryrun print commands instead of executing them
233235
-f force recreation of compiled magefile
234236
-goarch sets the GOARCH for the binary created by -compile (default: current arch)
235237
-gocmd <string>
@@ -285,6 +287,10 @@ Options:
285287
debug.SetOutput(stderr)
286288
}
287289

290+
if inv.DryRun {
291+
internal.SetDryRunRequested(true)
292+
}
293+
288294
inv.CacheDir = mg.CacheDir()
289295

290296
if numCommands > 1 {
@@ -592,7 +598,7 @@ func Compile(goos, goarch, ldflags, magePath, goCmd, compileTo string, gofiles [
592598
args := append(buildArgs, gofiles...)
593599

594600
debug.Printf("running %s %s", goCmd, strings.Join(args, " "))
595-
c := exec.Command(goCmd, args...)
601+
c := internal.WrapRun(goCmd, args...)
596602
c.Env = environ
597603
c.Stderr = stderr
598604
c.Stdout = stdout
@@ -704,17 +710,23 @@ func generateInit(dir string) error {
704710
// RunCompiled runs an already-compiled mage command with the given args,
705711
func RunCompiled(inv Invocation, exePath string, errlog *log.Logger) int {
706712
debug.Println("running binary", exePath)
707-
c := exec.Command(exePath, inv.Args...)
713+
c := internal.WrapRun(exePath, inv.Args...)
708714
c.Stderr = inv.Stderr
709715
c.Stdout = inv.Stdout
710716
c.Stdin = inv.Stdin
711717
c.Dir = inv.Dir
712718
if inv.WorkDir != inv.Dir {
713719
c.Dir = inv.WorkDir
714720
}
721+
715722
// intentionally pass through unaltered os.Environ here.. your magefile has
716723
// to deal with it.
717724
c.Env = os.Environ()
725+
726+
// We don't want to actually allow dryrun in the outermost invocation of mage, since that will inhibit the very compilation of the magefile & the use of the resulting binary.
727+
// But every situation that's within such an execution is one in which dryrun is supported, so we set this environment variable which will be carried over throughout all such situations.
728+
c.Env = append(c.Env, "MAGEFILE_DRYRUN_POSSIBLE=1")
729+
718730
if inv.Verbose {
719731
c.Env = append(c.Env, "MAGEFILE_VERBOSE=1")
720732
}
@@ -733,6 +745,9 @@ func RunCompiled(inv Invocation, exePath string, errlog *log.Logger) int {
733745
if inv.Timeout > 0 {
734746
c.Env = append(c.Env, fmt.Sprintf("MAGEFILE_TIMEOUT=%s", inv.Timeout.String()))
735747
}
748+
if inv.DryRun {
749+
c.Env = append(c.Env, "MAGEFILE_DRYRUN=1")
750+
}
736751
debug.Print("running magefile with mage vars:\n", strings.Join(filter(c.Env, "MAGEFILE"), "\n"))
737752
// catch SIGINT to allow magefile to handle them
738753
sigCh := make(chan os.Signal, 1)

sh/cmd.go

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,27 @@ import (
99
"os/exec"
1010
"strings"
1111

12+
"github.com/magefile/mage/internal"
1213
"github.com/magefile/mage/mg"
1314
)
1415

1516
// RunCmd returns a function that will call Run with the given command. This is
1617
// useful for creating command aliases to make your scripts easier to read, like
1718
// this:
1819
//
19-
// // in a helper file somewhere
20-
// var g0 = sh.RunCmd("go") // go is a keyword :(
20+
// // in a helper file somewhere
21+
// var g0 = sh.RunCmd("go") // go is a keyword :(
2122
//
22-
// // somewhere in your main code
23-
// if err := g0("install", "github.com/gohugo/hugo"); err != nil {
24-
// return err
25-
// }
23+
// // somewhere in your main code
24+
// if err := g0("install", "github.com/gohugo/hugo"); err != nil {
25+
// return err
26+
// }
2627
//
2728
// Args passed to command get baked in as args to the command when you run it.
2829
// Any args passed in when you run the returned function will be appended to the
2930
// original args. For example, this is equivalent to the above:
3031
//
31-
// var goInstall = sh.RunCmd("go", "install") goInstall("github.com/gohugo/hugo")
32+
// var goInstall = sh.RunCmd("go", "install") goInstall("github.com/gohugo/hugo")
3233
//
3334
// RunCmd uses Exec underneath, so see those docs for more details.
3435
func RunCmd(cmd string, args ...string) func(args ...string) error {
@@ -62,7 +63,8 @@ func RunV(cmd string, args ...string) error {
6263
// be in the format name=value.
6364
func RunWith(env map[string]string, cmd string, args ...string) error {
6465
var output io.Writer
65-
if mg.Verbose() {
66+
// In dryrun mode, the actual "command" will just print the cmd and args to stdout; so we want to make sure we're outputting that regardless of verbosity settings.
67+
if mg.Verbose() || internal.IsDryRun() {
6668
output = os.Stdout
6769
}
6870
_, err := Exec(env, output, os.Stderr, cmd, args...)
@@ -124,7 +126,7 @@ func Exec(env map[string]string, stdout, stderr io.Writer, cmd string, args ...s
124126
}
125127

126128
func run(env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, code int, err error) {
127-
c := exec.Command(cmd, args...)
129+
c := internal.WrapRun(cmd, args...)
128130
c.Env = os.Environ()
129131
for k, v := range env {
130132
c.Env = append(c.Env, k+"="+v)
@@ -133,9 +135,9 @@ func run(env map[string]string, stdout, stderr io.Writer, cmd string, args ...st
133135
c.Stdout = stdout
134136
c.Stdin = os.Stdin
135137

136-
var quoted []string
138+
var quoted []string
137139
for i := range args {
138-
quoted = append(quoted, fmt.Sprintf("%q", args[i]));
140+
quoted = append(quoted, fmt.Sprintf("%q", args[i]))
139141
}
140142
// To protect against logging from doing exec in global variables
141143
if mg.Verbose() {
@@ -144,6 +146,7 @@ func run(env map[string]string, stdout, stderr io.Writer, cmd string, args ...st
144146
err = c.Run()
145147
return CmdRan(err), ExitStatus(err), err
146148
}
149+
147150
// CmdRan examines the error to determine if it was generated as a result of a
148151
// command running via os/exec.Command. If the error is nil, or the command ran
149152
// (even if it exited with a non-zero exit code), CmdRan reports true. If the

sh/cmd_test.go

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

@@ -70,3 +72,17 @@ func TestAutoExpand(t *testing.T) {
7072
}
7173

7274
}
75+
76+
func TestDryRunOutput(t *testing.T) {
77+
// Invoke test binary with helper flag to exercise dry-run Output path.
78+
cmd := exec.Command(os.Args[0], "-dryRunOutput")
79+
out, err := cmd.CombinedOutput()
80+
if err != nil {
81+
t.Fatalf("dry-run helper failed: %v, out=%s", err, string(out))
82+
}
83+
got := strings.TrimSpace(string(out))
84+
want := "DRYRUN: somecmd arg1 arg two"
85+
if got != want {
86+
t.Fatalf("expected %q, got %q", want, got)
87+
}
88+
}

0 commit comments

Comments
 (0)