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
41 changes: 38 additions & 3 deletions gcode/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ def execute_bash(command: str) -> str:
Returns combined stdout and stderr, and reports a non-zero exit code if the
command fails.
"""
# Windows: shell=True invokes cmd.exe, not bash. If bash is unavailable,
# fail with a clear, actionable message rather than a confusing subprocess
# error. When bash exists, run it explicitly so bash syntax works (shell=True
# on Windows would still route through cmd.exe). See docs/windows.md and
# issue #54.
bash = shutil.which("bash")
if os.name == "nt" and bash is None:
return (
"execute_bash: bash not found on native Windows. GCode's bash tool "
"requires bash (use WSL2 or Git Bash). See docs/windows.md for Windows "
f"setup. Command was: {command}"
)
if not AUTO_APPROVE:
try:
confirm = input(f"GCode wants to run: {command}\nApprove? (y/n): ")
Expand All @@ -63,9 +75,27 @@ def execute_bash(command: str) -> str:
if confirm.strip().lower() != "y":
return "Command execution cancelled by user."
try:
result = subprocess.run( # nosec B602 — execute_bash is the tool's purpose; gated by y/n approval
command, shell=True, capture_output=True, text=True, timeout=BASH_TIMEOUT, check=False
)
# nosec B602 — execute_bash is the tool's purpose; gated by y/n approval.
# On Windows, shell=True would invoke cmd.exe; run bash explicitly.
if os.name == "nt":
if bash is None:
return "execute_bash: bash not found on native Windows."
result = subprocess.run(
[bash, "-c", command],
capture_output=True,
text=True,
timeout=BASH_TIMEOUT,
check=False,
)
else:
result = subprocess.run( # nosec B602 — gated by y/n approval
command,
shell=True,
capture_output=True,
text=True,
timeout=BASH_TIMEOUT,
check=False,
)
except subprocess.TimeoutExpired:
return f"Command timed out after {BASH_TIMEOUT}s: {command}"
except KeyboardInterrupt:
Expand Down Expand Up @@ -338,6 +368,11 @@ def git_commit(message: str) -> str:


def _git(args: list) -> str:
if os.name == "nt" and not shutil.which("git"):
return (
"git not found on native Windows. Install Git for Windows and ensure "
"git is on PATH, or use WSL2/Git Bash. See docs/windows.md."
)
cmd = ["git"] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, check=False)
Expand Down
39 changes: 39 additions & 0 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,45 @@ def test_execute_bash_auto_approve_skips_prompt(tmp_path):
set_auto_approve(AUTO_APPROVE)


def test_execute_bash_windows_missing_bash_returns_clear_error():
from gcode.tools import AUTO_APPROVE, execute_bash, set_auto_approve

set_auto_approve(True)
try:
with (
patch("gcode.tools.os.name", "nt"),
patch("gcode.tools.shutil.which", return_value=None),
):
out = execute_bash.invoke({"command": "echo hi"})
finally:
set_auto_approve(AUTO_APPROVE)
assert "bash not found on native Windows" in out


def test_execute_bash_windows_runs_bash_explicitly(tmp_path, monkeypatch):
"""On Windows, shell=True would invoke cmd.exe; bash must run explicitly."""
from gcode.tools import AUTO_APPROVE, execute_bash, set_auto_approve

monkeypatch.chdir(tmp_path)
set_auto_approve(True)
try:
with (
patch("gcode.tools.os.name", "nt"),
patch("gcode.tools.shutil.which", return_value="C:/Program Files/Git/bin/bash.exe"),
patch("gcode.tools.subprocess.run") as run,
):
run.return_value.returncode = 0
run.return_value.stdout = "hi from bash\n"
run.return_value.stderr = ""
out = execute_bash.invoke({"command": "echo hi"})
finally:
set_auto_approve(AUTO_APPROVE)
assert "hi from bash" in out
cmd, kwargs = run.call_args
assert cmd[0] == ["C:/Program Files/Git/bin/bash.exe", "-c", "echo hi"]
assert kwargs.get("shell") is not True


def test_grep_passes_include_as_one_argument():
"""The glob must stay attached to --include, as --include=<glob>.

Expand Down