Skip to content

Commit b953996

Browse files
fix: handle shell built-ins like export in command execution
Shell built-ins (export, cd, source, alias, etc.) have no binary in $PATH and fail with exec.Command. Wrap them with sh -c so they execute in a subshell instead. Closes #16
1 parent 602708d commit b953996

File tree

2 files changed

+83
-2
lines changed

2 files changed

+83
-2
lines changed

internal/engine/executor.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,44 @@ type Result struct {
1717
Duration time.Duration
1818
}
1919

20+
// shellBuiltins lists commands that are shell built-ins and cannot be
21+
// executed directly via exec.Command.
22+
var shellBuiltins = map[string]bool{
23+
"export": true,
24+
"unset": true,
25+
"source": true,
26+
"alias": true,
27+
"unalias": true,
28+
"cd": true,
29+
"eval": true,
30+
"set": true,
31+
"shopt": true,
32+
"declare": true,
33+
"local": true,
34+
"readonly": true,
35+
"typeset": true,
36+
"ulimit": true,
37+
"umask": true,
38+
}
39+
40+
// makeCommand creates an exec.Cmd, wrapping shell built-ins with sh -c
41+
// so they can be executed. Shell built-ins like "export" have no binary
42+
// in $PATH and would fail with exec.Command directly.
43+
func makeCommand(command string, args []string) *exec.Cmd {
44+
if shellBuiltins[command] {
45+
shArgs := make([]string, 0, len(args)+3)
46+
shArgs = append(shArgs, "-c", command+` "$@"`, "_")
47+
shArgs = append(shArgs, args...)
48+
return exec.Command("sh", shArgs...)
49+
}
50+
return exec.Command(command, args...)
51+
}
52+
2053
// Execute runs a command, capturing stdout and stderr concurrently via goroutines.
2154
func Execute(command string, args []string) (*Result, error) {
2255
start := time.Now()
2356

24-
cmd := exec.Command(command, args...)
57+
cmd := makeCommand(command, args)
2558
// Don't connect stdin for captured commands — prevents blocking on
2659
// commands that don't read stdin (most filtered commands).
2760
// Passthrough commands still get stdin via the Passthrough function.
@@ -74,7 +107,7 @@ func Execute(command string, args []string) (*Result, error) {
74107

75108
// Passthrough runs a command with inherited stdio (no capture).
76109
func Passthrough(command string, args []string) (int, error) {
77-
cmd := exec.Command(command, args...)
110+
cmd := makeCommand(command, args)
78111
cmd.Stdin = os.Stdin
79112
cmd.Stdout = os.Stdout
80113
cmd.Stderr = os.Stderr

internal/engine/executor_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,51 @@ func TestPassthroughExitCode(t *testing.T) {
7777
t.Errorf("exit code = %d, want 7", code)
7878
}
7979
}
80+
81+
func TestExecuteShellBuiltin(t *testing.T) {
82+
if runtime.GOOS == "windows" {
83+
t.Skip("skip on windows")
84+
}
85+
result, err := Execute("export", []string{"FOO=bar"})
86+
if err != nil {
87+
t.Fatalf("unexpected error executing shell builtin: %v", err)
88+
}
89+
if result.ExitCode != 0 {
90+
t.Errorf("exit code = %d, want 0", result.ExitCode)
91+
}
92+
}
93+
94+
func TestPassthroughShellBuiltin(t *testing.T) {
95+
if runtime.GOOS == "windows" {
96+
t.Skip("skip on windows")
97+
}
98+
code, err := Passthrough("export", []string{"FOO=bar"})
99+
if err != nil {
100+
t.Fatalf("unexpected error: %v", err)
101+
}
102+
if code != 0 {
103+
t.Errorf("exit code = %d, want 0", code)
104+
}
105+
}
106+
107+
func TestMakeCommandBuiltin(t *testing.T) {
108+
cmd := makeCommand("export", []string{"A=1", "B=2"})
109+
if cmd.Path == "" {
110+
t.Fatal("command path should not be empty")
111+
}
112+
// Should wrap with sh -c
113+
if cmd.Args[0] != "sh" {
114+
t.Errorf("expected sh wrapper, got %q", cmd.Args[0])
115+
}
116+
if cmd.Args[1] != "-c" {
117+
t.Errorf("expected -c flag, got %q", cmd.Args[1])
118+
}
119+
}
120+
121+
func TestMakeCommandRegular(t *testing.T) {
122+
cmd := makeCommand("git", []string{"status"})
123+
// Should NOT wrap with sh
124+
if len(cmd.Args) > 0 && cmd.Args[0] == "sh" {
125+
t.Error("regular commands should not be wrapped with sh")
126+
}
127+
}

0 commit comments

Comments
 (0)