Skip to content

Commit df01023

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

File tree

9 files changed

+258
-23
lines changed

9 files changed

+258
-23
lines changed

internal/dryrun/dryrun.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package dryrun
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"sync"
7+
)
8+
9+
// RequestedEnv is the environment variable that indicates the user requested dryrun mode when running mage.
10+
const RequestedEnv = "MAGEFILE_DRYRUN"
11+
12+
// PossibleEnv is the environment variable that indicates we are in a context where a dry run is possible.
13+
const PossibleEnv = "MAGEFILE_DRYRUN_POSSIBLE"
14+
15+
var (
16+
// Once-protected variables for whether the user requested dryrun mode.
17+
dryRunRequestedValue bool
18+
dryRunRequestedEnvValue bool
19+
dryRunRequestedEnvOnce sync.Once
20+
21+
// Once-protected variables for whether dryrun mode is possible.
22+
dryRunPossible bool
23+
dryRunPossibleOnce sync.Once
24+
)
25+
26+
// SetRequested sets the dryrun requested state to the specified boolean value.
27+
func SetRequested(value bool) {
28+
dryRunRequestedValue = value
29+
}
30+
31+
// IsRequested checks if dry-run mode was requested, either explicitly or via an environment variable.
32+
func IsRequested() bool {
33+
dryRunRequestedEnvOnce.Do(func() {
34+
if os.Getenv(RequestedEnv) != "" {
35+
dryRunRequestedEnvValue = true
36+
}
37+
})
38+
39+
return dryRunRequestedEnvValue || dryRunRequestedValue
40+
}
41+
42+
// IsPossible checks if dry-run mode is supported in the current context.
43+
func IsPossible() bool {
44+
dryRunPossibleOnce.Do(func() {
45+
dryRunPossible = os.Getenv(PossibleEnv) != ""
46+
})
47+
48+
return dryRunPossible
49+
}
50+
51+
// Wrap 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 Wrap(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 IsPossible() && IsRequested()
66+
}

internal/dryrun/dryrun_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package dryrun
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(), RequestedEnv+"=1", PossibleEnv+"=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(), PossibleEnv+"=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(), RequestedEnv+"=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(), PossibleEnv+"=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(), RequestedEnv+"=1", PossibleEnv+"=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/dryrun/testmain_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package dryrun
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(IsRequested())
26+
return
27+
}
28+
if printIsDryRunPossible {
29+
fmt.Println(IsPossible())
30+
return
31+
}
32+
if printIsDryRun {
33+
fmt.Println(IsDryRun())
34+
return
35+
}
36+
os.Exit(m.Run())
37+
}

internal/run.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import (
66
"io/ioutil"
77
"log"
88
"os"
9-
"os/exec"
109
"runtime"
1110
"strings"
11+
12+
"github.com/magefile/mage/internal/dryrun"
1213
)
1314

1415
var debug *log.Logger = log.New(ioutil.Discard, "", 0)
@@ -25,7 +26,7 @@ func RunDebug(cmd string, args ...string) error {
2526
buf := &bytes.Buffer{}
2627
errbuf := &bytes.Buffer{}
2728
debug.Println("running", cmd, strings.Join(args, " "))
28-
c := exec.Command(cmd, args...)
29+
c := dryrun.Wrap(cmd, args...)
2930
c.Env = env
3031
c.Stderr = errbuf
3132
c.Stdout = buf
@@ -45,7 +46,7 @@ func OutputDebug(cmd string, args ...string) (string, error) {
4546
buf := &bytes.Buffer{}
4647
errbuf := &bytes.Buffer{}
4748
debug.Println("running", cmd, strings.Join(args, " "))
48-
c := exec.Command(cmd, args...)
49+
c := dryrun.Wrap(cmd, args...)
4950
c.Env = env
5051
c.Stderr = errbuf
5152
c.Stdout = buf

mage/main.go

Lines changed: 19 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"
@@ -23,6 +22,7 @@ import (
2322
"time"
2423

2524
"github.com/magefile/mage/internal"
25+
"github.com/magefile/mage/internal/dryrun"
2626
"github.com/magefile/mage/mg"
2727
"github.com/magefile/mage/parse"
2828
"github.com/magefile/mage/sh"
@@ -107,6 +107,7 @@ type Invocation struct {
107107
List bool // tells the magefile to print out a list of targets
108108
Help bool // tells the magefile to print out help for a specific target
109109
Keep bool // tells mage to keep the generated main file after compiling
110+
DryRun bool // tells mage that all sh.Run* commands should print, but not execute
110111
Timeout time.Duration // tells mage to set a timeout to running the targets
111112
CompileOut string // tells mage to compile a static binary to this path, but not execute
112113
GOOS string // sets the GOOS when producing a binary with -compileout
@@ -192,6 +193,7 @@ func Parse(stderr, stdout io.Writer, args []string) (inv Invocation, cmd Command
192193
fs.BoolVar(&inv.Help, "h", false, "show this help")
193194
fs.DurationVar(&inv.Timeout, "t", 0, "timeout in duration parsable format (e.g. 5m30s)")
194195
fs.BoolVar(&inv.Keep, "keep", false, "keep intermediate mage files around after running")
196+
fs.BoolVar(&inv.DryRun, "dryrun", false, "print commands instead of executing them")
195197
fs.StringVar(&inv.Dir, "d", "", "directory to read magefiles from")
196198
fs.StringVar(&inv.WorkDir, "w", "", "working directory where magefiles will run")
197199
fs.StringVar(&inv.GoCmd, "gocmd", mg.GoCmd(), "use the given go binary to compile the output")
@@ -230,6 +232,7 @@ Options:
230232
-d <string>
231233
directory to read magefiles from (default "." or "magefiles" if exists)
232234
-debug turn on debug messages
235+
-dryrun print commands instead of executing them
233236
-f force recreation of compiled magefile
234237
-goarch sets the GOARCH for the binary created by -compile (default: current arch)
235238
-gocmd <string>
@@ -285,6 +288,10 @@ Options:
285288
debug.SetOutput(stderr)
286289
}
287290

291+
if inv.DryRun {
292+
dryrun.SetRequested(true)
293+
}
294+
288295
inv.CacheDir = mg.CacheDir()
289296

290297
if numCommands > 1 {
@@ -592,7 +599,7 @@ func Compile(goos, goarch, ldflags, magePath, goCmd, compileTo string, gofiles [
592599
args := append(buildArgs, gofiles...)
593600

594601
debug.Printf("running %s %s", goCmd, strings.Join(args, " "))
595-
c := exec.Command(goCmd, args...)
602+
c := dryrun.Wrap(goCmd, args...)
596603
c.Env = environ
597604
c.Stderr = stderr
598605
c.Stdout = stdout
@@ -704,17 +711,23 @@ func generateInit(dir string) error {
704711
// RunCompiled runs an already-compiled mage command with the given args,
705712
func RunCompiled(inv Invocation, exePath string, errlog *log.Logger) int {
706713
debug.Println("running binary", exePath)
707-
c := exec.Command(exePath, inv.Args...)
714+
c := dryrun.Wrap(exePath, inv.Args...)
708715
c.Stderr = inv.Stderr
709716
c.Stdout = inv.Stdout
710717
c.Stdin = inv.Stdin
711718
c.Dir = inv.Dir
712719
if inv.WorkDir != inv.Dir {
713720
c.Dir = inv.WorkDir
714721
}
722+
715723
// intentionally pass through unaltered os.Environ here.. your magefile has
716724
// to deal with it.
717725
c.Env = os.Environ()
726+
727+
// 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.
728+
// 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.
729+
c.Env = append(c.Env, "MAGEFILE_DRYRUN_POSSIBLE=1")
730+
718731
if inv.Verbose {
719732
c.Env = append(c.Env, "MAGEFILE_VERBOSE=1")
720733
}
@@ -733,6 +746,9 @@ func RunCompiled(inv Invocation, exePath string, errlog *log.Logger) int {
733746
if inv.Timeout > 0 {
734747
c.Env = append(c.Env, fmt.Sprintf("MAGEFILE_TIMEOUT=%s", inv.Timeout.String()))
735748
}
749+
if inv.DryRun {
750+
c.Env = append(c.Env, "MAGEFILE_DRYRUN=1")
751+
}
736752
debug.Print("running magefile with mage vars:\n", strings.Join(filter(c.Env, "MAGEFILE"), "\n"))
737753
// catch SIGINT to allow magefile to handle them
738754
sigCh := make(chan os.Signal, 1)

mg/runtime.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"path/filepath"
66
"runtime"
77
"strconv"
8+
9+
"github.com/magefile/mage/internal/dryrun"
810
)
911

1012
// CacheEnv is the environment variable that users may set to change the
@@ -19,6 +21,12 @@ const VerboseEnv = "MAGEFILE_VERBOSE"
1921
// debug mode when running mage.
2022
const DebugEnv = "MAGEFILE_DEBUG"
2123

24+
// DryRunRequestedEnv is the environment variable that indicates the user requested dryrun mode when running mage.
25+
const DryRunRequestedEnv = dryrun.RequestedEnv
26+
27+
// DryRunPossibleEnv is the environment variable that indicates we are in a context where a dry run is possible.
28+
const DryRunPossibleEnv = dryrun.PossibleEnv
29+
2230
// GoCmdEnv is the environment variable that indicates the go binary the user
2331
// desires to utilize for Magefile compilation.
2432
const GoCmdEnv = "MAGEFILE_GOCMD"

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/dryrun"
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() || dryrun.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 := dryrun.Wrap(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

0 commit comments

Comments
 (0)