From df01023706e59f8b8812c7d6a4eddfcdb0570fe5 Mon Sep 17 00:00:00 2001 From: Omer Preminger Date: Sat, 29 Nov 2025 12:49:51 -0500 Subject: [PATCH 1/3] feat: add `-dryrun` mode --- internal/dryrun/dryrun.go | 66 +++++++++++++++++++++++++++++ internal/dryrun/dryrun_test.go | 72 ++++++++++++++++++++++++++++++++ internal/dryrun/testmain_test.go | 37 ++++++++++++++++ internal/run.go | 7 ++-- mage/main.go | 22 ++++++++-- mg/runtime.go | 8 ++++ sh/cmd.go | 25 ++++++----- sh/cmd_test.go | 16 +++++++ sh/testmain_test.go | 28 ++++++++++--- 9 files changed, 258 insertions(+), 23 deletions(-) create mode 100644 internal/dryrun/dryrun.go create mode 100644 internal/dryrun/dryrun_test.go create mode 100644 internal/dryrun/testmain_test.go diff --git a/internal/dryrun/dryrun.go b/internal/dryrun/dryrun.go new file mode 100644 index 00000000..458472e5 --- /dev/null +++ b/internal/dryrun/dryrun.go @@ -0,0 +1,66 @@ +package dryrun + +import ( + "os" + "os/exec" + "sync" +) + +// RequestedEnv is the environment variable that indicates the user requested dryrun mode when running mage. +const RequestedEnv = "MAGEFILE_DRYRUN" + +// PossibleEnv is the environment variable that indicates we are in a context where a dry run is possible. +const PossibleEnv = "MAGEFILE_DRYRUN_POSSIBLE" + +var ( + // Once-protected variables for whether the user requested dryrun mode. + dryRunRequestedValue bool + dryRunRequestedEnvValue bool + dryRunRequestedEnvOnce sync.Once + + // Once-protected variables for whether dryrun mode is possible. + dryRunPossible bool + dryRunPossibleOnce sync.Once +) + +// SetRequested sets the dryrun requested state to the specified boolean value. +func SetRequested(value bool) { + dryRunRequestedValue = value +} + +// IsRequested checks if dry-run mode was requested, either explicitly or via an environment variable. +func IsRequested() bool { + dryRunRequestedEnvOnce.Do(func() { + if os.Getenv(RequestedEnv) != "" { + dryRunRequestedEnvValue = true + } + }) + + return dryRunRequestedEnvValue || dryRunRequestedValue +} + +// IsPossible checks if dry-run mode is supported in the current context. +func IsPossible() bool { + dryRunPossibleOnce.Do(func() { + dryRunPossible = os.Getenv(PossibleEnv) != "" + }) + + return dryRunPossible +} + +// Wrap creates an *exec.Cmd to run a command or simulate it in dry-run mode. +// If not in dry-run mode, it returns exec.Command(cmd, args...). +// In dry-run mode, it returns a command that prints the simulated command. +func Wrap(cmd string, args ...string) *exec.Cmd { + if !IsDryRun() { + return exec.Command(cmd, args...) + } + + // Return an *exec.Cmd that just prints the command that would have been run. + return exec.Command("echo", append([]string{"DRYRUN: " + cmd}, args...)...) +} + +// IsDryRun determines if dry-run mode is both possible and requested. +func IsDryRun() bool { + return IsPossible() && IsRequested() +} diff --git a/internal/dryrun/dryrun_test.go b/internal/dryrun/dryrun_test.go new file mode 100644 index 00000000..264260e1 --- /dev/null +++ b/internal/dryrun/dryrun_test.go @@ -0,0 +1,72 @@ +package dryrun + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +// These tests verify dry-run behavior by spawning a fresh process of the +// current test binary with purpose-built helper flags defined in this package's +// TestMain (see testmain_test.go). Spawning a new process ensures the +// sync.Once guards inside dryrun.go evaluate environment variables afresh. + +func TestIsDryRunRequestedEnv(t *testing.T) { + cmd := exec.Command(os.Args[0], "-printIsDryRunRequested") + cmd.Env = append(os.Environ(), RequestedEnv+"=1", PossibleEnv+"=1") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("subprocess failed: %v", err) + } + if strings.TrimSpace(string(out)) != "true" { + t.Fatalf("expected true, got %q", strings.TrimSpace(string(out))) + } +} + +func TestIsDryRunPossibleEnv(t *testing.T) { + cmd := exec.Command(os.Args[0], "-printIsDryRunPossible") + cmd.Env = append(os.Environ(), PossibleEnv+"=1") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("subprocess failed: %v", err) + } + if strings.TrimSpace(string(out)) != "true" { + t.Fatalf("expected true, got %q", strings.TrimSpace(string(out))) + } +} + +func TestIsDryRunRequiresBoth(t *testing.T) { + // Only requested set => not possible, so overall false + cmd := exec.Command(os.Args[0], "-printIsDryRun") + cmd.Env = append(os.Environ(), RequestedEnv+"=1") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("subprocess failed: %v", err) + } + if strings.TrimSpace(string(out)) != "false" { + t.Fatalf("expected false, got %q", strings.TrimSpace(string(out))) + } + + // Only possible set => not requested, so overall false + cmd = exec.Command(os.Args[0], "-printIsDryRun") + cmd.Env = append(os.Environ(), PossibleEnv+"=1") + out, err = cmd.CombinedOutput() + if err != nil { + t.Fatalf("subprocess failed: %v", err) + } + if strings.TrimSpace(string(out)) != "false" { + t.Fatalf("expected false, got %q", strings.TrimSpace(string(out))) + } + + // Both set => true + cmd = exec.Command(os.Args[0], "-printIsDryRun") + cmd.Env = append(os.Environ(), RequestedEnv+"=1", PossibleEnv+"=1") + out, err = cmd.CombinedOutput() + if err != nil { + t.Fatalf("subprocess failed: %v", err) + } + if strings.TrimSpace(string(out)) != "true" { + t.Fatalf("expected true, got %q", strings.TrimSpace(string(out))) + } +} diff --git a/internal/dryrun/testmain_test.go b/internal/dryrun/testmain_test.go new file mode 100644 index 00000000..e2b7f2c8 --- /dev/null +++ b/internal/dryrun/testmain_test.go @@ -0,0 +1,37 @@ +package dryrun + +import ( + "flag" + "fmt" + "os" + "testing" +) + +var ( + printIsDryRunRequested bool + printIsDryRunPossible bool + printIsDryRun bool +) + +func init() { + flag.BoolVar(&printIsDryRunRequested, "printIsDryRunRequested", false, "") + flag.BoolVar(&printIsDryRunPossible, "printIsDryRunPossible", false, "") + flag.BoolVar(&printIsDryRun, "printIsDryRun", false, "") +} + +func TestMain(m *testing.M) { + flag.Parse() + if printIsDryRunRequested { + fmt.Println(IsRequested()) + return + } + if printIsDryRunPossible { + fmt.Println(IsPossible()) + return + } + if printIsDryRun { + fmt.Println(IsDryRun()) + return + } + os.Exit(m.Run()) +} diff --git a/internal/run.go b/internal/run.go index 79b4f049..cb94e72e 100644 --- a/internal/run.go +++ b/internal/run.go @@ -6,9 +6,10 @@ import ( "io/ioutil" "log" "os" - "os/exec" "runtime" "strings" + + "github.com/magefile/mage/internal/dryrun" ) var debug *log.Logger = log.New(ioutil.Discard, "", 0) @@ -25,7 +26,7 @@ func RunDebug(cmd string, args ...string) error { buf := &bytes.Buffer{} errbuf := &bytes.Buffer{} debug.Println("running", cmd, strings.Join(args, " ")) - c := exec.Command(cmd, args...) + c := dryrun.Wrap(cmd, args...) c.Env = env c.Stderr = errbuf c.Stdout = buf @@ -45,7 +46,7 @@ func OutputDebug(cmd string, args ...string) (string, error) { buf := &bytes.Buffer{} errbuf := &bytes.Buffer{} debug.Println("running", cmd, strings.Join(args, " ")) - c := exec.Command(cmd, args...) + c := dryrun.Wrap(cmd, args...) c.Env = env c.Stderr = errbuf c.Stdout = buf diff --git a/mage/main.go b/mage/main.go index 0062bd35..67df7f99 100644 --- a/mage/main.go +++ b/mage/main.go @@ -11,7 +11,6 @@ import ( "io/ioutil" "log" "os" - "os/exec" "os/signal" "path/filepath" "regexp" @@ -23,6 +22,7 @@ import ( "time" "github.com/magefile/mage/internal" + "github.com/magefile/mage/internal/dryrun" "github.com/magefile/mage/mg" "github.com/magefile/mage/parse" "github.com/magefile/mage/sh" @@ -107,6 +107,7 @@ type Invocation struct { List bool // tells the magefile to print out a list of targets Help bool // tells the magefile to print out help for a specific target Keep bool // tells mage to keep the generated main file after compiling + DryRun bool // tells mage that all sh.Run* commands should print, but not execute Timeout time.Duration // tells mage to set a timeout to running the targets CompileOut string // tells mage to compile a static binary to this path, but not execute 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 fs.BoolVar(&inv.Help, "h", false, "show this help") fs.DurationVar(&inv.Timeout, "t", 0, "timeout in duration parsable format (e.g. 5m30s)") fs.BoolVar(&inv.Keep, "keep", false, "keep intermediate mage files around after running") + fs.BoolVar(&inv.DryRun, "dryrun", false, "print commands instead of executing them") fs.StringVar(&inv.Dir, "d", "", "directory to read magefiles from") fs.StringVar(&inv.WorkDir, "w", "", "working directory where magefiles will run") fs.StringVar(&inv.GoCmd, "gocmd", mg.GoCmd(), "use the given go binary to compile the output") @@ -230,6 +232,7 @@ Options: -d directory to read magefiles from (default "." or "magefiles" if exists) -debug turn on debug messages + -dryrun print commands instead of executing them -f force recreation of compiled magefile -goarch sets the GOARCH for the binary created by -compile (default: current arch) -gocmd @@ -285,6 +288,10 @@ Options: debug.SetOutput(stderr) } + if inv.DryRun { + dryrun.SetRequested(true) + } + inv.CacheDir = mg.CacheDir() if numCommands > 1 { @@ -592,7 +599,7 @@ func Compile(goos, goarch, ldflags, magePath, goCmd, compileTo string, gofiles [ args := append(buildArgs, gofiles...) debug.Printf("running %s %s", goCmd, strings.Join(args, " ")) - c := exec.Command(goCmd, args...) + c := dryrun.Wrap(goCmd, args...) c.Env = environ c.Stderr = stderr c.Stdout = stdout @@ -704,7 +711,7 @@ func generateInit(dir string) error { // RunCompiled runs an already-compiled mage command with the given args, func RunCompiled(inv Invocation, exePath string, errlog *log.Logger) int { debug.Println("running binary", exePath) - c := exec.Command(exePath, inv.Args...) + c := dryrun.Wrap(exePath, inv.Args...) c.Stderr = inv.Stderr c.Stdout = inv.Stdout c.Stdin = inv.Stdin @@ -712,9 +719,15 @@ func RunCompiled(inv Invocation, exePath string, errlog *log.Logger) int { if inv.WorkDir != inv.Dir { c.Dir = inv.WorkDir } + // intentionally pass through unaltered os.Environ here.. your magefile has // to deal with it. c.Env = os.Environ() + + // 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. + // 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. + c.Env = append(c.Env, "MAGEFILE_DRYRUN_POSSIBLE=1") + if inv.Verbose { c.Env = append(c.Env, "MAGEFILE_VERBOSE=1") } @@ -733,6 +746,9 @@ func RunCompiled(inv Invocation, exePath string, errlog *log.Logger) int { if inv.Timeout > 0 { c.Env = append(c.Env, fmt.Sprintf("MAGEFILE_TIMEOUT=%s", inv.Timeout.String())) } + if inv.DryRun { + c.Env = append(c.Env, "MAGEFILE_DRYRUN=1") + } debug.Print("running magefile with mage vars:\n", strings.Join(filter(c.Env, "MAGEFILE"), "\n")) // catch SIGINT to allow magefile to handle them sigCh := make(chan os.Signal, 1) diff --git a/mg/runtime.go b/mg/runtime.go index 9a8de12c..a10bd29d 100644 --- a/mg/runtime.go +++ b/mg/runtime.go @@ -5,6 +5,8 @@ import ( "path/filepath" "runtime" "strconv" + + "github.com/magefile/mage/internal/dryrun" ) // CacheEnv is the environment variable that users may set to change the @@ -19,6 +21,12 @@ const VerboseEnv = "MAGEFILE_VERBOSE" // debug mode when running mage. const DebugEnv = "MAGEFILE_DEBUG" +// DryRunRequestedEnv is the environment variable that indicates the user requested dryrun mode when running mage. +const DryRunRequestedEnv = dryrun.RequestedEnv + +// DryRunPossibleEnv is the environment variable that indicates we are in a context where a dry run is possible. +const DryRunPossibleEnv = dryrun.PossibleEnv + // GoCmdEnv is the environment variable that indicates the go binary the user // desires to utilize for Magefile compilation. const GoCmdEnv = "MAGEFILE_GOCMD" diff --git a/sh/cmd.go b/sh/cmd.go index 312de65a..8952067b 100644 --- a/sh/cmd.go +++ b/sh/cmd.go @@ -9,6 +9,7 @@ import ( "os/exec" "strings" + "github.com/magefile/mage/internal/dryrun" "github.com/magefile/mage/mg" ) @@ -16,19 +17,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 { @@ -62,7 +63,8 @@ func RunV(cmd string, args ...string) error { // be in the format name=value. func RunWith(env map[string]string, cmd string, args ...string) error { var output io.Writer - if mg.Verbose() { + // 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. + if mg.Verbose() || dryrun.IsDryRun() { output = os.Stdout } _, 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 } 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...) + c := dryrun.Wrap(cmd, args...) c.Env = os.Environ() for k, v := range env { 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 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 +146,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..0d18bfab 100644 --- a/sh/cmd_test.go +++ b/sh/cmd_test.go @@ -3,6 +3,8 @@ package sh import ( "bytes" "os" + "os/exec" + "strings" "testing" ) @@ -70,3 +72,17 @@ func TestAutoExpand(t *testing.T) { } } + +func TestDryRunOutput(t *testing.T) { + // Invoke test binary with helper flag to exercise dry-run Output path. + cmd := exec.Command(os.Args[0], "-dryRunOutput") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("dry-run helper failed: %v, out=%s", err, string(out)) + } + got := strings.TrimSpace(string(out)) + want := "DRYRUN: somecmd arg1 arg two" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} diff --git a/sh/testmain_test.go b/sh/testmain_test.go index 5869c547..eec47678 100644 --- a/sh/testmain_test.go +++ b/sh/testmain_test.go @@ -8,12 +8,13 @@ import ( ) var ( - helperCmd bool - printArgs bool - stderr string - stdout string - exitCode int - printVar string + helperCmd bool + printArgs bool + stderr string + stdout string + exitCode int + printVar string + dryRunOutput bool ) func init() { @@ -23,6 +24,7 @@ func init() { flag.StringVar(&stdout, "stdout", "", "") flag.IntVar(&exitCode, "exit", 0, "") flag.StringVar(&printVar, "printVar", "", "") + flag.BoolVar(&dryRunOutput, "dryRunOutput", false, "") } func TestMain(m *testing.M) { @@ -37,6 +39,20 @@ func TestMain(m *testing.M) { return } + if dryRunOutput { + // Simulate dry-run mode and print the output of a command that would have been run. + // We use a non-echo command to make the "DRYRUN: " prefix deterministic. + _ = os.Setenv("MAGEFILE_DRYRUN_POSSIBLE", "1") + _ = os.Setenv("MAGEFILE_DRYRUN", "1") + s, err := Output("somecmd", "arg1", "arg two") + if err != nil { + fmt.Println("ERR:", err) + return + } + fmt.Println(s) + return + } + if helperCmd { fmt.Fprintln(os.Stderr, stderr) fmt.Fprintln(os.Stdout, stdout) From a8279aecc73b87f8d21e7f3cc970e0ce46a2bd2d Mon Sep 17 00:00:00 2001 From: Omer Preminger Date: Sat, 29 Nov 2025 14:36:55 -0500 Subject: [PATCH 2/3] chore: add package-level docstring to `internal/dryrun/dryrun.go` --- internal/dryrun/dryrun.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/dryrun/dryrun.go b/internal/dryrun/dryrun.go index 458472e5..0a1a9b6a 100644 --- a/internal/dryrun/dryrun.go +++ b/internal/dryrun/dryrun.go @@ -1,3 +1,15 @@ +// Package dryrun implements the conditional checks for Mage's dryrun mode. +// +// For IsDryRun() to be true, two things have to be true: +// 1. IsPossible() must be true +// - This can only happen if the env var `MAGEFILE_DRYRUN_POSSIBLE` was set at the point of the first call to IsPossible() +// +// 2. IsRequested() must be true +// - This can happen under one of two conditions: +// i. The env var `MAGEFILE_DRYRUN` was set at the point of the first call to IsRequested() +// ii. SetRequested(true) was called at some point prior to the IsPossible() call. +// +// This enables the "top-level" Mage run, which compiles the magefile into a binary, to always be carried out regardless of `-dryrun` (because `MAGEFILE_DRYRUN_POSSIBLE` will not be set in that situation), while still enabling true dryrun functionality for "inner" Mage runs (i.e., runs of the compiled magefile binary). package dryrun import ( From b63fd28e9adf10cb3672d552c6f458aca64bef10 Mon Sep 17 00:00:00 2001 From: Omer Preminger Date: Sat, 29 Nov 2025 14:37:37 -0500 Subject: [PATCH 3/3] feat: add dryrun-mode handling to helpers in `sh/helpers.go` --- sh/helpers.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/sh/helpers.go b/sh/helpers.go index f5d20a27..2327794a 100644 --- a/sh/helpers.go +++ b/sh/helpers.go @@ -4,11 +4,18 @@ import ( "fmt" "io" "os" + + "github.com/magefile/mage/internal/dryrun" ) // Rm removes the given file or directory even if non-empty. It will not return // an error if the target doesn't exist, only if the target cannot be removed. func Rm(path string) error { + if dryrun.IsDryRun() { + fmt.Println("DRYRUN: rm", path) + return nil + } + err := os.RemoveAll(path) if err == nil || os.IsNotExist(err) { return nil @@ -18,6 +25,11 @@ func Rm(path string) error { // Copy robustly copies the source file to the destination, overwriting the destination if necessary. func Copy(dst string, src string) error { + if dryrun.IsDryRun() { + fmt.Println("DRYRUN: cp", src, dst) + return nil + } + from, err := os.Open(src) if err != nil { return fmt.Errorf(`can't copy %s: %v`, src, err)