From c2fa84cb2e00a15405a1dfe93e8910148488fb41 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Sat, 13 Jun 2026 16:28:36 -0700 Subject: [PATCH 1/5] scriptrun: Add generic script-runner utility Introduces a small, single-responsibility package for executing user-configured shell scripts and capturing their output. Scripts may be either plain shell snippets (passed to 'sh -c') or self-contained executables starting with a shebang line (written to a temp file and executed directly). stdout and stderr are captured independently and returned alongside the exit code. The package does not interpret script output; that's the caller's responsibility. This keeps scriptrun reusable across different output protocols (commit messages, conflict resolutions, etc). A non-zero exit is reported in the RunResult rather than as an error, so callers can distinguish 'script ran and disagreed' from 'script could not be run at all.' Part of #1243 This commit intentionally narrows PR #1188 to only the core scriptrun functionality described in issue #1243: the generic script runner and its focused runner tests. It excludes resolver.go, env.go, parser errors, .spice storage, and script-resolve configuration. [skip changelog]: internal package extracted from PR #1188. --- internal/scriptrun/scriptrun.go | 202 +++++++++++++++++++++++++++ internal/scriptrun/scriptrun_test.go | 163 +++++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 internal/scriptrun/scriptrun.go create mode 100644 internal/scriptrun/scriptrun_test.go diff --git a/internal/scriptrun/scriptrun.go b/internal/scriptrun/scriptrun.go new file mode 100644 index 000000000..6bcf5036e --- /dev/null +++ b/internal/scriptrun/scriptrun.go @@ -0,0 +1,202 @@ +// Package scriptrun executes user-configured shell scripts and +// captures their output. +// +// Scripts are written by the user as small shell programs (typically +// wrapping an external tool like an editor, AI assistant, or test +// runner). git-spice invokes them from a known directory with a known +// environment, then inspects their stdout, stderr, and exit code. +// +// # Execution mode +// +// If a script starts with a shebang line (#!), it is written to a +// temporary file (with executable permission) and executed directly, +// letting the kernel use the interpreter from the shebang. +// +// Otherwise, the script is passed to 'sh -c'. The invoking process's +// argument vector is forwarded as positional parameters ($1, $2, ...). +// +// # Output handling +// +// stdout and stderr are captured independently. Neither is parsed by +// scriptrun itself — the caller decides what to do with them. +// +// A non-zero exit code is not an error from Run's perspective: it is +// reported in the returned RunResult. Errors are reserved for cases +// where the script could not be started, written, or waited on. +package scriptrun + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/xec" +) + +// Runner executes shell scripts. +// +// Runner is safe to reuse across calls but is not safe for concurrent +// use. +type Runner struct { + // Log receives debug messages and is forwarded to xec. + // Defaults to a no-op logger if nil. + Log *silog.Logger + + // Args is the invoking process's argument vector + // (typically os.Args). These are forwarded to the script + // as positional parameters. + Args []string +} + +// RunRequest is a single script invocation. +type RunRequest struct { + // Script is the script body to execute. + // If it starts with "#!", it is executed directly via the + // interpreter named in the shebang; otherwise it is passed to + // "sh -c". + Script string + + // Dir is the working directory for the script. + // Empty means the current working directory. + Dir string + + // Env lists additional environment variables to set, in the + // "KEY=value" form. These are appended to the parent process's + // environment. + Env []string + + // Stdin, if non-nil, is supplied to the script as its stdin. + Stdin io.Reader +} + +// RunResult is the outcome of a single script invocation. +type RunResult struct { + // ExitCode is the script's exit code. Zero indicates success. + ExitCode int + + // Stdout is the captured standard output of the script. + Stdout []byte + + // Stderr is the captured standard error of the script. + Stderr []byte +} + +// Run executes the script described by req and returns its outcome. +// +// A non-zero exit code is reported in RunResult.ExitCode, not as an +// error. Run returns an error only if the script could not be started +// or waited on (e.g., temp-file creation failed, or the executable +// could not be found). +func (r *Runner) Run(ctx context.Context, req *RunRequest) (*RunResult, error) { + if req == nil { + return nil, errors.New("scriptrun: nil request") + } + if req.Script == "" { + return nil, errors.New("scriptrun: empty script") + } + + log := r.Log + if log == nil { + log = silog.Nop() + } + + cmd, cleanup, err := r.buildCmd(ctx, log, req.Script) + if err != nil { + return nil, fmt.Errorf("build command: %w", err) + } + defer cleanup() + + if req.Dir != "" { + cmd.WithDir(req.Dir) + } + if len(req.Env) > 0 { + cmd.AppendEnv(req.Env...) + } + if req.Stdin != nil { + cmd.WithStdin(req.Stdin) + } + + var stdout, stderr bytes.Buffer + cmd.WithStdout(&stdout).WithStderr(&stderr) + + runErr := cmd.Run() + exitErr := new(xec.ExitError) + switch { + case runErr == nil: + return &RunResult{ + ExitCode: 0, + Stdout: stdout.Bytes(), + Stderr: stderr.Bytes(), + }, nil + case errors.As(runErr, &exitErr): + return &RunResult{ + ExitCode: exitErr.ExitCode(), + Stdout: stdout.Bytes(), + Stderr: stderr.Bytes(), + }, nil + default: + return nil, fmt.Errorf("run script: %w", runErr) + } +} + +// buildCmd creates an [xec.Cmd] for the given script. The returned +// cleanup function must be invoked once the command completes. +func (r *Runner) buildCmd( + ctx context.Context, + log *silog.Logger, + script string, +) (*xec.Cmd, func(), error) { + if strings.HasPrefix(script, "#!") { + return r.buildShebangCmd(ctx, log, script) + } + args := make([]string, 0, 2+len(r.Args)) + args = append(args, "-c", script) + args = append(args, r.Args...) + return xec.Command(ctx, log, "sh", args...), func() {}, nil +} + +// buildShebangCmd writes the script to a temporary file and returns a +// command that executes it directly. +// +// Positional parameters are aligned with the "sh -c" form: $1 is the +// first user argument (skipping the program name in r.Args[0]). +func (r *Runner) buildShebangCmd( + ctx context.Context, + log *silog.Logger, + script string, +) (*xec.Cmd, func(), error) { + f, err := os.CreateTemp("", "gs-scriptrun-*.sh") + if err != nil { + return nil, nil, fmt.Errorf("create temp file: %w", err) + } + cleanup := func() { + _ = os.Remove(f.Name()) + } + + if _, err := f.WriteString(script); err != nil { + cleanup() + return nil, nil, fmt.Errorf("write script: %w", err) + } + if err := f.Close(); err != nil { + cleanup() + return nil, nil, fmt.Errorf("close script: %w", err) + } + if err := os.Chmod(f.Name(), 0o700); err != nil { + cleanup() + return nil, nil, fmt.Errorf("chmod script: %w", err) + } + + // Skip Args[0] (program name) so $1 in the script is the first + // user argument, matching the sh -c behavior where $0 receives + // Args[0]. + var args []string + if len(r.Args) > 1 { + args = append(args, r.Args[1:]...) + } + return xec.Command(ctx, log, f.Name(), args...), cleanup, nil +} diff --git a/internal/scriptrun/scriptrun_test.go b/internal/scriptrun/scriptrun_test.go new file mode 100644 index 000000000..d0f216bb3 --- /dev/null +++ b/internal/scriptrun/scriptrun_test.go @@ -0,0 +1,163 @@ +package scriptrun + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/silog" +) + +func TestRunner_Run_shellScript(t *testing.T) { + r := &Runner{Log: silog.Nop()} + + res, err := r.Run(t.Context(), &RunRequest{ + Script: `echo "hello"; echo "world" >&2; exit 0`, + }) + require.NoError(t, err) + assert.Equal(t, 0, res.ExitCode) + assert.Equal(t, "hello\n", string(res.Stdout)) + assert.Equal(t, "world\n", string(res.Stderr)) +} + +func TestRunner_Run_envVars(t *testing.T) { + r := &Runner{Log: silog.Nop()} + + res, err := r.Run(t.Context(), &RunRequest{ + Script: `echo "$FOO:$BAR"`, + Env: []string{"FOO=foo-val", "BAR=bar-val"}, + }) + require.NoError(t, err) + assert.Equal(t, 0, res.ExitCode) + assert.Equal(t, "foo-val:bar-val\n", string(res.Stdout)) +} + +func TestRunner_Run_positionalArgs(t *testing.T) { + r := &Runner{ + Log: silog.Nop(), + Args: []string{"prog", "alpha", "beta"}, + } + + res, err := r.Run(t.Context(), &RunRequest{ + Script: `echo "$1-$2"`, + }) + require.NoError(t, err) + assert.Equal(t, "alpha-beta\n", string(res.Stdout)) +} + +func TestRunner_Run_workingDir(t *testing.T) { + dir := t.TempDir() + require.NoError(t, + os.WriteFile(filepath.Join(dir, "marker.txt"), []byte("here"), 0o600)) + + r := &Runner{Log: silog.Nop()} + res, err := r.Run(t.Context(), &RunRequest{ + Script: `cat marker.txt`, + Dir: dir, + }) + require.NoError(t, err) + assert.Equal(t, "here", string(res.Stdout)) +} + +func TestRunner_Run_stdin(t *testing.T) { + r := &Runner{Log: silog.Nop()} + + res, err := r.Run(t.Context(), &RunRequest{ + Script: `cat`, + Stdin: strings.NewReader("piped input"), + }) + require.NoError(t, err) + assert.Equal(t, "piped input", string(res.Stdout)) +} + +func TestRunner_Run_nonZeroExit(t *testing.T) { + r := &Runner{Log: silog.Nop()} + + res, err := r.Run(t.Context(), &RunRequest{ + Script: `echo "out"; echo "err" >&2; exit 7`, + }) + require.NoError(t, err, "non-zero exit must not be an error") + assert.Equal(t, 7, res.ExitCode) + assert.Equal(t, "out\n", string(res.Stdout)) + assert.Equal(t, "err\n", string(res.Stderr)) +} + +func TestRunner_Run_shebangScript(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shebang scripts are POSIX-only") + } + r := &Runner{Log: silog.Nop()} + + res, err := r.Run(t.Context(), &RunRequest{ + Script: "#!/bin/sh\necho \"shebang-output\"\nexit 0\n", + }) + require.NoError(t, err) + assert.Equal(t, 0, res.ExitCode) + assert.Equal(t, "shebang-output\n", string(res.Stdout)) +} + +func TestRunner_Run_shebangReceivesArgs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shebang scripts are POSIX-only") + } + r := &Runner{ + Log: silog.Nop(), + Args: []string{"prog", "first", "second"}, + } + + res, err := r.Run(t.Context(), &RunRequest{ + Script: "#!/bin/sh\necho \"$1=$2\"\n", + }) + require.NoError(t, err) + assert.Equal(t, "first=second\n", string(res.Stdout)) +} + +func TestRunner_Run_emptyScript(t *testing.T) { + r := &Runner{Log: silog.Nop()} + + _, err := r.Run(t.Context(), &RunRequest{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty script") +} + +func TestRunner_Run_nilRequest(t *testing.T) { + r := &Runner{Log: silog.Nop()} + + _, err := r.Run(t.Context(), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "nil request") +} + +func TestRunner_Run_contextCancellation(t *testing.T) { + r := &Runner{Log: silog.Nop()} + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := r.Run(ctx, &RunRequest{ + Script: `sleep 30`, + }) + require.Error(t, err) + // Cancelled or killed by context; either is acceptable. + assert.True(t, errors.Is(err, context.Canceled) || + strings.Contains(err.Error(), "signal:") || + strings.Contains(err.Error(), "killed") || + strings.Contains(err.Error(), "context canceled"), + "unexpected error: %v", err) +} + +func TestRunner_Run_nilLogger(t *testing.T) { + r := &Runner{} // no logger + + res, err := r.Run(t.Context(), &RunRequest{ + Script: `echo ok`, + }) + require.NoError(t, err) + assert.Equal(t, "ok\n", string(res.Stdout)) +} From 2f4cc0c8120c4e4c02689d7ec4cd7caf7b438c7f Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Sat, 13 Jun 2026 16:40:55 -0700 Subject: [PATCH 2/5] scriptrun: Assert non-nil run requests `Runner.Run` expects callers to provide a `RunRequest`. Treating a nil request as a runtime error made that programmer fault look like a recoverable script execution problem. Use the repository assertion helper for the nil request check, and mark `RunRequest.Script` as required so required-field linting can enforce the request contract at construction sites. --- internal/scriptrun/scriptrun.go | 7 +++---- internal/scriptrun/scriptrun_test.go | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/internal/scriptrun/scriptrun.go b/internal/scriptrun/scriptrun.go index 6bcf5036e..c5b1bec8c 100644 --- a/internal/scriptrun/scriptrun.go +++ b/internal/scriptrun/scriptrun.go @@ -34,6 +34,7 @@ import ( "os" "strings" + "go.abhg.dev/gs/internal/must" "go.abhg.dev/gs/internal/silog" "go.abhg.dev/gs/internal/xec" ) @@ -59,7 +60,7 @@ type RunRequest struct { // If it starts with "#!", it is executed directly via the // interpreter named in the shebang; otherwise it is passed to // "sh -c". - Script string + Script string // required // Dir is the working directory for the script. // Empty means the current working directory. @@ -93,9 +94,7 @@ type RunResult struct { // or waited on (e.g., temp-file creation failed, or the executable // could not be found). func (r *Runner) Run(ctx context.Context, req *RunRequest) (*RunResult, error) { - if req == nil { - return nil, errors.New("scriptrun: nil request") - } + must.NotBeNilf(req, "scriptrun: nil request") if req.Script == "" { return nil, errors.New("scriptrun: empty script") } diff --git a/internal/scriptrun/scriptrun_test.go b/internal/scriptrun/scriptrun_test.go index d0f216bb3..807add395 100644 --- a/internal/scriptrun/scriptrun_test.go +++ b/internal/scriptrun/scriptrun_test.go @@ -129,9 +129,9 @@ func TestRunner_Run_emptyScript(t *testing.T) { func TestRunner_Run_nilRequest(t *testing.T) { r := &Runner{Log: silog.Nop()} - _, err := r.Run(t.Context(), nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "nil request") + assert.Panics(t, func() { + _, _ = r.Run(t.Context(), nil) + }) } func TestRunner_Run_contextCancellation(t *testing.T) { From 17de418dc65f8d9004cb39c87760037d77a2adc1 Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Sat, 13 Jun 2026 16:41:57 -0700 Subject: [PATCH 3/5] scriptrun: Treat Args as script parameters `Runner.Args` represents the arguments passed to the script, not the invoking process argument vector. Including an argv0 value in that API shifted the first useful argument out of `$1` for direct execution paths, and forced shebang handling to special-case the first element. Pass a synthetic `$0` only when adapting through `sh -c`. Directly executed scripts now receive `Runner.Args` unchanged, so shell snippets and executable scripts expose the same positional parameters to user-configured script code. --- internal/scriptrun/scriptrun.go | 30 +++++++++++----------------- internal/scriptrun/scriptrun_test.go | 4 ++-- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/internal/scriptrun/scriptrun.go b/internal/scriptrun/scriptrun.go index c5b1bec8c..657e25db0 100644 --- a/internal/scriptrun/scriptrun.go +++ b/internal/scriptrun/scriptrun.go @@ -3,8 +3,9 @@ // // Scripts are written by the user as small shell programs (typically // wrapping an external tool like an editor, AI assistant, or test -// runner). git-spice invokes them from a known directory with a known -// environment, then inspects their stdout, stderr, and exit code. +// runner). git-spice invokes them from a known directory with known +// arguments and environment, then inspects their stdout, stderr, and +// exit code. // // # Execution mode // @@ -12,8 +13,8 @@ // temporary file (with executable permission) and executed directly, // letting the kernel use the interpreter from the shebang. // -// Otherwise, the script is passed to 'sh -c'. The invoking process's -// argument vector is forwarded as positional parameters ($1, $2, ...). +// Otherwise, the script is passed to 'sh -c'. Runner.Args are forwarded +// as positional parameters ($1, $2, ...). // // # Output handling // @@ -48,9 +49,9 @@ type Runner struct { // Defaults to a no-op logger if nil. Log *silog.Logger - // Args is the invoking process's argument vector - // (typically os.Args). These are forwarded to the script - // as positional parameters. + // Args is forwarded to the script as positional parameters. + // + // For example, Args[0] becomes $1 in shell scripts. Args []string } @@ -153,8 +154,8 @@ func (r *Runner) buildCmd( if strings.HasPrefix(script, "#!") { return r.buildShebangCmd(ctx, log, script) } - args := make([]string, 0, 2+len(r.Args)) - args = append(args, "-c", script) + args := make([]string, 0, 3+len(r.Args)) + args = append(args, "-c", script, "gs-scriptrun") args = append(args, r.Args...) return xec.Command(ctx, log, "sh", args...), func() {}, nil } @@ -163,7 +164,7 @@ func (r *Runner) buildCmd( // command that executes it directly. // // Positional parameters are aligned with the "sh -c" form: $1 is the -// first user argument (skipping the program name in r.Args[0]). +// first Runner argument. func (r *Runner) buildShebangCmd( ctx context.Context, log *silog.Logger, @@ -190,12 +191,5 @@ func (r *Runner) buildShebangCmd( return nil, nil, fmt.Errorf("chmod script: %w", err) } - // Skip Args[0] (program name) so $1 in the script is the first - // user argument, matching the sh -c behavior where $0 receives - // Args[0]. - var args []string - if len(r.Args) > 1 { - args = append(args, r.Args[1:]...) - } - return xec.Command(ctx, log, f.Name(), args...), cleanup, nil + return xec.Command(ctx, log, f.Name(), r.Args...), cleanup, nil } diff --git a/internal/scriptrun/scriptrun_test.go b/internal/scriptrun/scriptrun_test.go index 807add395..716eb530f 100644 --- a/internal/scriptrun/scriptrun_test.go +++ b/internal/scriptrun/scriptrun_test.go @@ -41,7 +41,7 @@ func TestRunner_Run_envVars(t *testing.T) { func TestRunner_Run_positionalArgs(t *testing.T) { r := &Runner{ Log: silog.Nop(), - Args: []string{"prog", "alpha", "beta"}, + Args: []string{"alpha", "beta"}, } res, err := r.Run(t.Context(), &RunRequest{ @@ -108,7 +108,7 @@ func TestRunner_Run_shebangReceivesArgs(t *testing.T) { } r := &Runner{ Log: silog.Nop(), - Args: []string{"prog", "first", "second"}, + Args: []string{"first", "second"}, } res, err := r.Run(t.Context(), &RunRequest{ From 4882c57a7ab425fd5d05a96c86078ba0b1285cd1 Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Sat, 13 Jun 2026 16:42:59 -0700 Subject: [PATCH 4/5] scriptrun: Execute script file paths directly User-configured script values can be shell snippets, shebang script bodies, or paths to executable scripts. The editor command path uses direct execution when the configured command resolves to an executable, then falls back to `sh -c` for shell command strings. Mirror that split for script execution with the narrower scriptrun rule: shebang bodies are still written to a temporary file, regular file paths are executed directly, and all other values are passed to `sh -c`. This keeps file paths with shell-significant characters from being parsed as shell source before the script gets a chance to run. --- internal/scriptrun/scriptrun.go | 8 +++++++- internal/scriptrun/scriptrun_test.go | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/internal/scriptrun/scriptrun.go b/internal/scriptrun/scriptrun.go index 657e25db0..8071ca5b5 100644 --- a/internal/scriptrun/scriptrun.go +++ b/internal/scriptrun/scriptrun.go @@ -13,6 +13,8 @@ // temporary file (with executable permission) and executed directly, // letting the kernel use the interpreter from the shebang. // +// If a script is the path to a regular file, it is executed directly. +// // Otherwise, the script is passed to 'sh -c'. Runner.Args are forwarded // as positional parameters ($1, $2, ...). // @@ -59,7 +61,8 @@ type Runner struct { type RunRequest struct { // Script is the script body to execute. // If it starts with "#!", it is executed directly via the - // interpreter named in the shebang; otherwise it is passed to + // interpreter named in the shebang. If it names a regular file, + // that file is executed directly. Otherwise, it is passed to // "sh -c". Script string // required @@ -154,6 +157,9 @@ func (r *Runner) buildCmd( if strings.HasPrefix(script, "#!") { return r.buildShebangCmd(ctx, log, script) } + if info, err := os.Stat(script); err == nil && info.Mode().IsRegular() { + return xec.Command(ctx, log, script, r.Args...), func() {}, nil + } args := make([]string, 0, 3+len(r.Args)) args = append(args, "-c", script, "gs-scriptrun") args = append(args, r.Args...) diff --git a/internal/scriptrun/scriptrun_test.go b/internal/scriptrun/scriptrun_test.go index 716eb530f..191f0ee48 100644 --- a/internal/scriptrun/scriptrun_test.go +++ b/internal/scriptrun/scriptrun_test.go @@ -118,6 +118,28 @@ func TestRunner_Run_shebangReceivesArgs(t *testing.T) { assert.Equal(t, "first=second\n", string(res.Stdout)) } +func TestRunner_Run_scriptFilePath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("script files are POSIX-only") + } + path := filepath.Join(t.TempDir(), "script with space.sh") + require.NoError(t, os.WriteFile( + path, + []byte("#!/bin/sh\necho \"$1:$2\"\n"), + 0o700, + )) + + r := &Runner{ + Log: silog.Nop(), + Args: []string{"one", "two"}, + } + res, err := r.Run(t.Context(), &RunRequest{ + Script: path, + }) + require.NoError(t, err) + assert.Equal(t, "one:two\n", string(res.Stdout)) +} + func TestRunner_Run_emptyScript(t *testing.T) { r := &Runner{Log: silog.Nop()} From 95b64a0a1b2e6bf6e16bf814109dc62d8e1c02e7 Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Sat, 13 Jun 2026 16:43:45 -0700 Subject: [PATCH 5/5] scriptrun: Centralize shebang temp cleanup Shebang scripts are written to a temporary file before execution. Setup failures while writing, closing, or chmodding that file all need the same cleanup behavior. Use named returns and one deferred cleanup check after temp-file creation. That keeps failure cleanup in one place while preserving the successful cleanup callback returned to the caller for command completion. --- internal/scriptrun/scriptrun.go | 13 ++++++++----- internal/scriptrun/scriptrun_test.go | 4 +++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/scriptrun/scriptrun.go b/internal/scriptrun/scriptrun.go index 8071ca5b5..36440eb08 100644 --- a/internal/scriptrun/scriptrun.go +++ b/internal/scriptrun/scriptrun.go @@ -175,25 +175,28 @@ func (r *Runner) buildShebangCmd( ctx context.Context, log *silog.Logger, script string, -) (*xec.Cmd, func(), error) { +) (cmd *xec.Cmd, cleanup func(), err error) { f, err := os.CreateTemp("", "gs-scriptrun-*.sh") if err != nil { return nil, nil, fmt.Errorf("create temp file: %w", err) } - cleanup := func() { + cleanup = func() { _ = os.Remove(f.Name()) } + defer func() { + if err != nil { + cleanup() + cleanup = nil + } + }() if _, err := f.WriteString(script); err != nil { - cleanup() return nil, nil, fmt.Errorf("write script: %w", err) } if err := f.Close(); err != nil { - cleanup() return nil, nil, fmt.Errorf("close script: %w", err) } if err := os.Chmod(f.Name(), 0o700); err != nil { - cleanup() return nil, nil, fmt.Errorf("chmod script: %w", err) } diff --git a/internal/scriptrun/scriptrun_test.go b/internal/scriptrun/scriptrun_test.go index 191f0ee48..cda85fe8c 100644 --- a/internal/scriptrun/scriptrun_test.go +++ b/internal/scriptrun/scriptrun_test.go @@ -143,7 +143,9 @@ func TestRunner_Run_scriptFilePath(t *testing.T) { func TestRunner_Run_emptyScript(t *testing.T) { r := &Runner{Log: silog.Nop()} - _, err := r.Run(t.Context(), &RunRequest{}) + _, err := r.Run(t.Context(), &RunRequest{ + Script: "", + }) require.Error(t, err) assert.Contains(t, err.Error(), "empty script") }