From 35e7c354b2e1b9cc9b390a2e61134ec589139e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Wed, 27 May 2026 10:01:35 +0200 Subject: [PATCH] Add `shctx` package that mirrors `sh` and adds a `context.Context` param Add a new package `shctx` that mirrors the `sh` API, with the addition that each function (where it makes sense) takes a `context.Context` parameter. This can then be used for cancellations, timeouts, etc. Unit tests that were in `sh` have been moved to `shctx`. The `sh` package now no longer has unit tests, because the only thing it does is call into the `shctx` package, and the real work happens there (and is tested). Ref: #228 Related: #444 also adds context support, but has a different approach. --- sh/cmd.go | 101 ++-------------- sh/cmd_test.go | 124 -------------------- sh/helpers.go | 33 +----- shctx/cmd.go | 189 ++++++++++++++++++++++++++++++ shctx/cmd_test.go | 203 +++++++++++++++++++++++++++++++++ shctx/helpers.go | 43 +++++++ {sh => shctx}/helpers_test.go | 2 +- {sh => shctx}/testmain_test.go | 21 ++-- 8 files changed, 463 insertions(+), 253 deletions(-) delete mode 100644 sh/cmd_test.go create mode 100644 shctx/cmd.go create mode 100644 shctx/cmd_test.go create mode 100644 shctx/helpers.go rename {sh => shctx}/helpers_test.go (99%) rename {sh => shctx}/testmain_test.go (68%) diff --git a/sh/cmd.go b/sh/cmd.go index c747c989..70757c4d 100644 --- a/sh/cmd.go +++ b/sh/cmd.go @@ -2,17 +2,10 @@ package sh import ( - "bytes" "context" - "errors" - "fmt" "io" - "log" - "os" - "os/exec" - "strings" - "github.com/magefile/mage/mg" + "github.com/magefile/mage/shctx" ) // RunCmd returns a function that will call Run with the given command. This is @@ -50,13 +43,12 @@ func OutCmd(cmd string, args ...string) func(args ...string) (string, error) { // Run is like RunWith, but doesn't specify any environment variables. func Run(cmd string, args ...string) error { - return RunWith(nil, cmd, args...) + return shctx.Run(context.Background(), 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 err + return shctx.RunV(context.Background(), cmd, args...) } // RunWith runs the given command, directing stderr to this program's stderr and @@ -64,32 +56,22 @@ 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 { - var output io.Writer - if mg.Verbose() { - output = os.Stdout - } - _, err := Exec(env, output, os.Stderr, cmd, args...) - return err + return shctx.RunWith(context.Background(), env, cmd, args...) } // 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 err + return shctx.RunWithV(context.Background(), env, cmd, args...) } // Output runs the command and returns the text from stdout. func Output(cmd string, args ...string) (string, error) { - buf := &bytes.Buffer{} - _, err := Exec(nil, buf, os.Stderr, cmd, args...) - return strings.TrimSuffix(buf.String(), "\n"), err + return shctx.Output(context.Background(), cmd, args...) } // OutputWith is like RunWith, but returns what is written to stdout. func OutputWith(env map[string]string, cmd string, args ...string) (string, error) { - buf := &bytes.Buffer{} - _, err := Exec(env, buf, os.Stderr, cmd, args...) - return strings.TrimSuffix(buf.String(), "\n"), err + return shctx.OutputWith(context.Background(), env, cmd, args...) } // Exec executes the command, piping its stdout and stderr to the given @@ -105,47 +87,7 @@ func OutputWith(env map[string]string, cmd string, args ...string) (string, erro // 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) { - expand := func(s string) string { - s2, ok := env[s] - if ok { - return s2 - } - return os.Getenv(s) - } - cmd = os.Expand(cmd, expand) - for i := range args { - args[i] = os.Expand(args[i], expand) - } - ran, code, err := doRun(env, stdout, stderr, cmd, args...) - if err == nil { - return true, nil - } - if ran { - return ran, mg.Fatalf(code, `running "%s %s" failed with exit code %d`, cmd, strings.Join(args, " "), code) - } - return ran, fmt.Errorf(`failed to run "%s %s: %w"`, cmd, strings.Join(args, " "), err) -} - -func doRun(env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, code int, err error) { - c := exec.CommandContext(context.Background(), cmd, args...) - c.Env = os.Environ() - for k, v := range env { - c.Env = append(c.Env, k+"="+v) - } - c.Stderr = stderr - c.Stdout = stdout - c.Stdin = os.Stdin - - var quoted []string - for i := range args { - quoted = append(quoted, fmt.Sprintf("%q", args[i])) - } - // To protect against logging from doing exec in global variables - if mg.Verbose() { - log.Println("exec:", cmd, strings.Join(quoted, " ")) - } - err = c.Run() - return CmdRan(err), ExitStatus(err), err + return shctx.Exec(context.Background(), env, stdout, stderr, cmd, args...) } // CmdRan examines the error to determine if it was generated as a result of a @@ -155,35 +97,12 @@ func doRun(env map[string]string, stdout, stderr io.Writer, cmd string, args ... // the command failed to run (usually due to the command not existing or not // being executable), it reports false. func CmdRan(err error) bool { - if err == nil { - return true - } - var ee *exec.ExitError - if errors.As(err, &ee) { - return ee.Exited() - } - return false -} - -type exitStatus interface { - ExitStatus() int + return shctx.CmdRan(err) } // ExitStatus returns the exit status of the error if it is an exec.ExitError // or if it implements ExitStatus() int. // 0 if it is nil or 1 if it is a different error. func ExitStatus(err error) int { - if err == nil { - return 0 - } - if e, ok := err.(exitStatus); ok { - return e.ExitStatus() - } - var e *exec.ExitError - if errors.As(err, &e) { - if ex, ok := e.Sys().(exitStatus); ok { - return ex.ExitStatus() - } - } - return 1 + return shctx.ExitStatus(err) } diff --git a/sh/cmd_test.go b/sh/cmd_test.go deleted file mode 100644 index 7e7d993f..00000000 --- a/sh/cmd_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package sh - -import ( - "bytes" - "errors" - "os" - "testing" -) - -func TestOutCmd(t *testing.T) { - cmd := OutCmd(os.Args[0], "-printArgs", "foo", "bar") - out, err := cmd("baz", "bat") - if err != nil { - t.Fatal(err) - } - expected := "[foo bar baz bat]" - 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") - if err == nil { - t.Fatal("unexpected nil error from run") - } - if !ran { - t.Error("ran returned as false, but should have been true") - } - code := ExitStatus(err) - if code != 99 { - t.Fatalf("expected exit status 99, but got %v", code) - } -} - -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) - if err != nil { - t.Fatalf("unexpected error from runner: %#v", err) - } - if !ran { - t.Error("expected ran to be true but was false.") - } - if out.String() != "foobar\n" { - t.Errorf("expected foobar, got %q", out) - } -} - -func TestNotRun(t *testing.T) { - ran, err := Exec(nil, nil, nil, "thiswontwork") - if err == nil { - t.Fatal("unexpected nil error") - } - if ran { - t.Fatal("expected ran to be false but was true") - } -} - -func TestAutoExpand(t *testing.T) { - t.Setenv("MAGE_FOOBAR", "baz") - s, err := Output("echo", "$MAGE_FOOBAR") - if err != nil { - t.Fatal(err) - } - if s != "baz" { - t.Fatalf(`Expected "baz" but got %q`, s) - } -} - -func TestCmdRanNilErr(t *testing.T) { - if !CmdRan(nil) { - t.Fatal("CmdRan(nil) should return true") - } -} - -func TestCmdRanNotFound(t *testing.T) { - _, err := Exec(nil, nil, nil, "thiswontwork") - if CmdRan(err) { - t.Fatal("CmdRan should return false for not-found command") - } -} - -func TestExitStatusNil(t *testing.T) { - code := ExitStatus(nil) - if code != 0 { - t.Fatalf("expected 0 for nil error, got %d", code) - } -} - -func TestExitStatusNonExecError(t *testing.T) { - code := ExitStatus(errors.New("generic error")) - if code != 1 { - t.Fatalf("expected 1 for generic error, got %d", code) - } -} - -func TestExitStatusFromExec(t *testing.T) { - _, err := Exec(nil, nil, nil, os.Args[0], "-helper", "-exit", "42") - code := ExitStatus(err) - if code != 42 { - t.Fatalf("expected exit status 42, got %d", code) - } -} - -func TestRunCmd(t *testing.T) { - echoHello := RunCmd("echo", "hello") - err := echoHello("world") - // RunWith directs output based on verbose, so just check no error - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestOutputWith(t *testing.T) { - out, err := OutputWith(map[string]string{"MY_TEST_VAR": "xyz"}, os.Args[0], "-printVar", "MY_TEST_VAR") - if err != nil { - t.Fatal(err) - } - if out != "xyz" { - t.Fatalf("expected 'xyz', got %q", out) - } -} diff --git a/sh/helpers.go b/sh/helpers.go index 28c96b76..fde078ae 100644 --- a/sh/helpers.go +++ b/sh/helpers.go @@ -1,43 +1,16 @@ package sh import ( - "fmt" - "io" - "os" + "github.com/magefile/mage/shctx" ) // 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 { - err := os.RemoveAll(path) - if err == nil || os.IsNotExist(err) { - return nil - } - return fmt.Errorf(`failed to remove %s: %w`, path, err) + return shctx.Rm(path) } // Copy robustly copies the source file to the destination, overwriting the destination if necessary. func Copy(dst, src string) error { - from, err := os.Open(src) - if err != nil { - return fmt.Errorf(`can't copy %s: %w`, src, err) - } - defer func() { _ = from.Close() }() - finfo, err := from.Stat() - if err != nil { - return fmt.Errorf(`can't stat %s: %w`, src, err) - } - to, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, finfo.Mode()) - if err != nil { - return fmt.Errorf(`can't copy to %s: %w`, dst, err) - } - _, err = io.Copy(to, from) - if err != nil { - _ = to.Close() - return fmt.Errorf(`error copying %s to %s: %w`, src, dst, err) - } - if err := to.Close(); err != nil { - return fmt.Errorf(`error closing %s: %w`, dst, err) - } - return nil + return shctx.Copy(dst, src) } diff --git a/shctx/cmd.go b/shctx/cmd.go new file mode 100644 index 00000000..e2df4f22 --- /dev/null +++ b/shctx/cmd.go @@ -0,0 +1,189 @@ +// Package sh provides helpers for running shell commands. +package shctx + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log" + "os" + "os/exec" + "strings" + + "github.com/magefile/mage/mg" +) + +// RunCmd returns a function that will call Run with the given command. This is +// 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 :( +// +// // 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") +// +// RunCmd uses Exec underneath, so see those docs for more details. +func RunCmd(cmd string, args ...string) func(ctx context.Context, args ...string) error { + return func(ctx context.Context, args2 ...string) error { + return Run(ctx, cmd, append(args, args2...)...) + } +} + +// OutCmd is like RunCmd except the command returns the output of the +// command. +func OutCmd(cmd string, args ...string) func(ctx context.Context, args ...string) (string, error) { + return func(ctx context.Context, args2 ...string) (string, error) { + return Output(ctx, cmd, append(args, args2...)...) + } +} + +// Run is like RunWith, but doesn't specify any environment variables. +func Run(ctx context.Context, cmd string, args ...string) error { + return RunWith(ctx, nil, cmd, args...) +} + +// RunV is like Run, but always sends the command's stdout to os.Stdout. +func RunV(ctx context.Context, cmd string, args ...string) error { + _, err := Exec(ctx, nil, os.Stdout, os.Stderr, cmd, args...) + return err +} + +// RunWith runs the given command, 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 RunWith(ctx context.Context, env map[string]string, cmd string, args ...string) error { + var output io.Writer + if mg.Verbose() { + output = os.Stdout + } + _, err := Exec(ctx, env, output, os.Stderr, cmd, args...) + return err +} + +// RunWithV is like RunWith, but always sends the command's stdout to os.Stdout. +func RunWithV(ctx context.Context, env map[string]string, cmd string, args ...string) error { + _, err := Exec(ctx, env, os.Stdout, os.Stderr, cmd, args...) + return err +} + +// Output runs the command and returns the text from stdout. +func Output(ctx context.Context, cmd string, args ...string) (string, error) { + buf := &bytes.Buffer{} + _, err := Exec(ctx, nil, buf, os.Stderr, cmd, args...) + return strings.TrimSuffix(buf.String(), "\n"), err +} + +// OutputWith is like RunWith, but returns what is written to stdout. +func OutputWith(ctx context.Context, env map[string]string, cmd string, args ...string) (string, error) { + buf := &bytes.Buffer{} + _, err := Exec(ctx, env, buf, os.Stderr, cmd, args...) + return strings.TrimSuffix(buf.String(), "\n"), err +} + +// Exec 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 +// running the command, these override the current environment variables set +// (which are also passed to the command). cmd and args may include references +// to environment variables in $FOO format, in which case these will be +// expanded before the command is run. +// +// 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(ctx context.Context, env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, err error) { + expand := func(s string) string { + s2, ok := env[s] + if ok { + return s2 + } + return os.Getenv(s) + } + cmd = os.Expand(cmd, expand) + for i := range args { + args[i] = os.Expand(args[i], expand) + } + ran, code, err := doRun(ctx, env, stdout, stderr, cmd, args...) + if err == nil { + return true, nil + } + if ran { + return ran, mg.Fatalf(code, `running "%s %s" failed with exit code %d`, cmd, strings.Join(args, " "), code) + } + return ran, fmt.Errorf(`failed to run "%s %s: %w"`, cmd, strings.Join(args, " "), err) +} + +func doRun(ctx context.Context, env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, code int, err error) { + c := exec.CommandContext(ctx, cmd, args...) + c.Env = os.Environ() + for k, v := range env { + c.Env = append(c.Env, k+"="+v) + } + c.Stderr = stderr + c.Stdout = stdout + c.Stdin = os.Stdin + + var quoted []string + for i := range args { + quoted = append(quoted, fmt.Sprintf("%q", args[i])) + } + // To protect against logging from doing exec in global variables + if mg.Verbose() { + log.Println("exec:", cmd, strings.Join(quoted, " ")) + } + 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 +// error is an unrecognized type, or it is an error from exec.Command that says +// the command failed to run (usually due to the command not existing or not +// being executable), it reports false. +func CmdRan(err error) bool { + if err == nil { + return true + } + var ee *exec.ExitError + if errors.As(err, &ee) { + return ee.Exited() + } + return false +} + +type exitStatus interface { + ExitStatus() int +} + +// ExitStatus returns the exit status of the error if it is an exec.ExitError +// or if it implements ExitStatus() int. +// 0 if it is nil or 1 if it is a different error. +func ExitStatus(err error) int { + if err == nil { + return 0 + } + if e, ok := err.(exitStatus); ok { + return e.ExitStatus() + } + var e *exec.ExitError + if errors.As(err, &e) { + if ex, ok := e.Sys().(exitStatus); ok { + return ex.ExitStatus() + } + } + return 1 +} diff --git a/shctx/cmd_test.go b/shctx/cmd_test.go new file mode 100644 index 00000000..0718ceca --- /dev/null +++ b/shctx/cmd_test.go @@ -0,0 +1,203 @@ +package shctx + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "testing" + "time" +) + +func TestOutCmd(t *testing.T) { + ctx, cancel := testCtx() + defer cancel() + + cmd := OutCmd(os.Args[0], "-printArgs", "foo", "bar") + out, err := cmd(ctx, "baz", "bat") + if err != nil { + t.Fatal(err) + } + expected := "[foo bar baz bat]" + if out != expected { + t.Fatalf("expected %q but got %q", expected, out) + } +} + +func TestExitCode(t *testing.T) { + ctx, cancel := testCtx() + defer cancel() + + ran, err := Exec(ctx, nil, nil, nil, os.Args[0], "-helper", "-exit", "99") + if err == nil { + t.Fatal("unexpected nil error from run") + } + if !ran { + t.Error("ran returned as false, but should have been true") + } + code := ExitStatus(err) + if code != 99 { + t.Fatalf("expected exit status 99, but got %v", code) + } +} + +func TestEnv(t *testing.T) { + ctx, cancel := testCtx() + defer cancel() + + env := "SOME_REALLY_LONG_MAGEFILE_SPECIFIC_THING" + out := &bytes.Buffer{} + ran, err := Exec( + ctx, + map[string]string{env: "foobar"}, + out, + nil, + os.Args[0], "-printVar", env, + ) + if err != nil { + t.Fatalf("unexpected error from runner: %#v", err) + } + if !ran { + t.Error("expected ran to be true but was false.") + } + if out.String() != "foobar\n" { + t.Errorf("expected foobar, got %q", out) + } +} + +func TestNotRun(t *testing.T) { + ctx, cancel := testCtx() + defer cancel() + + ran, err := Exec(ctx, nil, nil, nil, "thiswontwork") + if err == nil { + t.Fatal("unexpected nil error") + } + if ran { + t.Fatal("expected ran to be false but was true") + } +} + +func TestAutoExpand(t *testing.T) { + ctx, cancel := testCtx() + defer cancel() + + t.Setenv("MAGE_FOOBAR", "baz") + s, err := Output(ctx, "echo", "$MAGE_FOOBAR") + if err != nil { + t.Fatal(err) + } + if s != "baz" { + t.Fatalf(`Expected "baz" but got %q`, s) + } +} + +func TestContextTimeout(t *testing.T) { + deadline := time.Now().Add(100 * time.Millisecond) + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + + err := Run(ctx, os.Args[0], "-sleep", "1") + + // Check that the command was aborted roughly at the right time. This way it's likely it was + // caused by the context cancellation, and not some other cause. + timeSinceDeadline := time.Since(deadline) + switch { + case timeSinceDeadline < -10*time.Millisecond: + t.Fatalf("command exited unexpectedly early (%v before the deadline)", -timeSinceDeadline) + case timeSinceDeadline > 100*time.Millisecond: + t.Fatalf("command exited unexpectedly late (%v after the deadline)", timeSinceDeadline) + } + + // Check the exit status, the command shouldn't have run succesfully as it was cancelled. + var exitError *exec.ExitError + switch { + case errors.As(err, &exitError): + // Expected case. + case errors.Is(err, context.DeadlineExceeded): + // This shouldn't happen, as the timeout of the context should only happen after the + // subprocess is already running. But if it's returned, it's technically not wrong. + case err == nil: + t.Fatal("unexpected nil error from run") + default: + wrapped := errors.Unwrap(err) + t.Fatalf("unexpected error: %v of type %T wrapping %v %T", err, err, wrapped, wrapped) + } +} + +func TestCmdRanNilErr(t *testing.T) { + if !CmdRan(nil) { + t.Fatal("CmdRan(nil) should return true") + } +} + +func TestCmdRanNotFound(t *testing.T) { + ctx, cancel := testCtx() + defer cancel() + + _, err := Exec(ctx, nil, nil, nil, "thiswontwork") + if CmdRan(err) { + t.Fatal("CmdRan should return false for not-found command") + } +} + +func TestExitStatusNil(t *testing.T) { + code := ExitStatus(nil) + if code != 0 { + t.Fatalf("expected 0 for nil error, got %d", code) + } +} + +func TestExitStatusNonExecError(t *testing.T) { + code := ExitStatus(errors.New("generic error")) + if code != 1 { + t.Fatalf("expected 1 for generic error, got %d", code) + } +} + +func TestExitStatusFromExec(t *testing.T) { + ctx, cancel := testCtx() + defer cancel() + + _, err := Exec(ctx, nil, nil, nil, os.Args[0], "-helper", "-exit", "42") + code := ExitStatus(err) + if code != 42 { + t.Fatalf("expected exit status 42, got %d", code) + } +} + +func TestRunCmd(t *testing.T) { + ctx, cancel := testCtx() + defer cancel() + + echoHello := RunCmd("echo", "hello") + err := echoHello(ctx, "world") + // RunWith directs output based on verbose, so just check no error + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestOutputWith(t *testing.T) { + ctx, cancel := testCtx() + defer cancel() + + out, err := OutputWith( + ctx, + map[string]string{"MY_TEST_VAR": "xyz"}, + os.Args[0], "-printVar", "MY_TEST_VAR", + ) + if err != nil { + t.Fatal(err) + } + if out != "xyz" { + t.Fatalf("expected 'xyz', got %q", out) + } +} + +// testCtx returns a context and cancel function for general testing. +// The timeout is long enough for any command to run on a typical system. +func testCtx() (context.Context, func()) { + return context.WithTimeout(context.Background(), 5*time.Second) +} diff --git a/shctx/helpers.go b/shctx/helpers.go new file mode 100644 index 00000000..dd3bfe78 --- /dev/null +++ b/shctx/helpers.go @@ -0,0 +1,43 @@ +package shctx + +import ( + "fmt" + "io" + "os" +) + +// 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 { + err := os.RemoveAll(path) + if err == nil || os.IsNotExist(err) { + return nil + } + return fmt.Errorf(`failed to remove %s: %w`, path, err) +} + +// Copy robustly copies the source file to the destination, overwriting the destination if necessary. +func Copy(dst, src string) error { + from, err := os.Open(src) + if err != nil { + return fmt.Errorf(`can't copy %s: %w`, src, err) + } + defer func() { _ = from.Close() }() + finfo, err := from.Stat() + if err != nil { + return fmt.Errorf(`can't stat %s: %w`, src, err) + } + to, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, finfo.Mode()) + if err != nil { + return fmt.Errorf(`can't copy to %s: %w`, dst, err) + } + _, err = io.Copy(to, from) + if err != nil { + _ = to.Close() + return fmt.Errorf(`error copying %s to %s: %w`, src, dst, err) + } + if err := to.Close(); err != nil { + return fmt.Errorf(`error closing %s: %w`, dst, err) + } + return nil +} diff --git a/sh/helpers_test.go b/shctx/helpers_test.go similarity index 99% rename from sh/helpers_test.go rename to shctx/helpers_test.go index 4a12d4b0..8dfc448f 100644 --- a/sh/helpers_test.go +++ b/shctx/helpers_test.go @@ -1,4 +1,4 @@ -package sh_test +package shctx_test import ( "bytes" diff --git a/sh/testmain_test.go b/shctx/testmain_test.go similarity index 68% rename from sh/testmain_test.go rename to shctx/testmain_test.go index ced61096..950dc673 100644 --- a/sh/testmain_test.go +++ b/shctx/testmain_test.go @@ -1,19 +1,21 @@ -package sh +package shctx import ( "flag" "fmt" "os" "testing" + "time" ) var ( - helperCmd bool - printArgs bool - stderr string - stdout string - exitCode int - printVar string + helperCmd bool + printArgs bool + stderr string + stdout string + exitCode int + sleepTimeSeconds int + printVar string ) func init() { //nolint:gochecknoinits // required for test flag setup @@ -22,6 +24,7 @@ func init() { //nolint:gochecknoinits // required for test flag setup flag.StringVar(&stderr, "stderr", "", "") flag.StringVar(&stdout, "stdout", "", "") flag.IntVar(&exitCode, "exit", 0, "") + flag.IntVar(&sleepTimeSeconds, "sleep", 0, "") flag.StringVar(&printVar, "printVar", "", "") } @@ -37,6 +40,10 @@ func TestMain(m *testing.M) { return } + if sleepTimeSeconds > 0 { + time.Sleep(time.Duration(sleepTimeSeconds) * time.Second) + } + if helperCmd { _, _ = fmt.Fprintln(os.Stderr, stderr) _, _ = fmt.Fprintln(os.Stdout, stdout)