Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions internal/scriptrun/scriptrun.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// 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 known
// arguments and 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.
//
// 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, ...).
//
// # 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/must"
"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 forwarded to the script as positional parameters.
//
// For example, Args[0] becomes $1 in shell scripts.
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. If it names a regular file,
// that file is executed directly. Otherwise, it is passed to
// "sh -c".
Script string // required

// 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) {
must.NotBeNilf(req, "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)
}
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...)
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 Runner argument.
func (r *Runner) buildShebangCmd(
ctx context.Context,
log *silog.Logger,
script string,
) (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() {
_ = os.Remove(f.Name())
}
defer func() {
if err != nil {
cleanup()
cleanup = nil
}
}()

if _, err := f.WriteString(script); err != nil {
return nil, nil, fmt.Errorf("write script: %w", err)
}
if err := f.Close(); err != nil {
return nil, nil, fmt.Errorf("close script: %w", err)
}
if err := os.Chmod(f.Name(), 0o700); err != nil {
return nil, nil, fmt.Errorf("chmod script: %w", err)
}

return xec.Command(ctx, log, f.Name(), r.Args...), cleanup, nil
}
187 changes: 187 additions & 0 deletions internal/scriptrun/scriptrun_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
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{"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{"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_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()}

_, err := r.Run(t.Context(), &RunRequest{
Script: "",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "empty script")
}

func TestRunner_Run_nilRequest(t *testing.T) {
r := &Runner{Log: silog.Nop()}

assert.Panics(t, func() {
_, _ = r.Run(t.Context(), nil)
})
}

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))
}
Loading