From 60c4c5b2319a0b6c116d6f7997791d93cbf846d0 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Thu, 25 Jun 2026 14:49:21 -0400 Subject: [PATCH] fix: c3_shell uses Git Bash on Windows, not cmd.exe (v2.41.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c3_shell._run_sync used subprocess.Popen(shell=True), which resolves to cmd.exe via COMSPEC on Windows. The rest of the environment — the native Bash tool, CLAUDE.md conventions, agent command habits — speaks POSIX via Git Bash, so bash-flavored commands (ls/grep/cat, single quotes, $VAR, /dev/null, forward-slash flags, heredocs) silently failed under cmd.exe and forced a fall back to native Bash, defeating c3_shell as a structured drop-in. _run_sync now runs commands through Git Bash (bash -c) on Windows when a Git-for-Windows bash.exe is discoverable, matching the native Bash tool. Discovery prefers Git install locations and PATH and rejects WSL/Store bash (System32/WindowsApps) whose /mnt/c path semantics would break cwd. New C3_SHELL_BASH override: 0/cmd/off forces cmd.exe; a path forces that bash. POSIX platforms unchanged (shell=True -> /bin/sh). Adds TestShellSelection (discovery, env override, WSL guard, _run_sync routing). Full test_c3_shell suite green (26 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 25 ++++++++++++++ cli/c3.py | 2 +- cli/tools/shell.py | 72 +++++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- tests/test_c3_shell.py | 75 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 173 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67663f0..5b792e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.41.0] - 2026-06-25 + +### Fixed + +- **`c3_shell` ran commands through `cmd.exe` on Windows, mismatching the native + Bash tool.** `_run_sync` used `subprocess.Popen(shell=True)`, which resolves to + `cmd.exe` via `COMSPEC` on Windows — while the rest of the environment (the + native Bash tool, CLAUDE.md conventions, agent command habits) speaks POSIX via + Git Bash. Any bash-flavored command (`ls`, `grep`, `cat`, single quotes, + `$VAR`, `/dev/null`, forward-slash flags, heredocs) silently failed under + `cmd.exe` and forced a fall back to native Bash, defeating the point of + `c3_shell` as a structured drop-in. `c3_shell` now runs commands through Git + Bash (`bash -c`) on Windows when a Git-for-Windows `bash.exe` is available, so + it speaks the same dialect as the native Bash tool. + +### Added + +- **`C3_SHELL_BASH` environment override for `c3_shell` shell selection.** Set + `C3_SHELL_BASH=0` (or `cmd`/`off`/`false`) to force the legacy `cmd.exe` + behavior, or point it at a specific `bash.exe` path to override auto-discovery. + Discovery prefers Git-for-Windows install locations and PATH, and deliberately + rejects WSL/Store `bash.exe` (System32 / WindowsApps) because its Linux + `/mnt/c` path semantics would break `cwd` handling. POSIX platforms are + unchanged (`shell=True` → `/bin/sh`). + ## [2.40.0] - 2026-06-25 A Bitbucket Data Center / Server fix batch. A full PR-lifecycle evaluation against a diff --git a/cli/c3.py b/cli/c3.py index acd67e6..11c4d77 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -85,7 +85,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.40.0" +__version__ = "2.41.0" def _command_deps() -> CommandDeps: diff --git a/cli/tools/shell.py b/cli/tools/shell.py index dc65ea3..56c037a 100644 --- a/cli/tools/shell.py +++ b/cli/tools/shell.py @@ -4,12 +4,21 @@ services/edit_ledger.py::_git_combined (Popen + taskkill /F /T + stdin=DEVNULL). Auto-filters long stdout via handle_filter, auto-logs git mutations to the edit ledger, and accounts stdout tokens against session budget. + +Shell selection: on Windows, commands are run through Git Bash (bash.exe) when +it is available, so c3_shell speaks the same POSIX dialect as the native Bash +tool (forward-slash paths, single quotes, `$VAR`, ls/grep/cat, heredocs). This +avoids the cmd.exe/POSIX mismatch that forced callers to fall back to native +Bash for bash-flavored commands. Set C3_SHELL_BASH=0 to force cmd.exe, or point +C3_SHELL_BASH at a specific bash.exe to override discovery. POSIX platforms are +unchanged (shell=True → /bin/sh). """ from __future__ import annotations import asyncio import os import re +import shutil import subprocess import sys import time @@ -59,6 +68,58 @@ _FILTER_THRESHOLD_LINES = 30 +# Cache for discovered Git Bash path: [] = uncomputed, [None]/[path] = computed. +_bash_cache: list = [] + + +def _discover_git_bash() -> str | None: + """Locate a Git-for-Windows bash.exe, never WSL/System32 bash. + + WSL's bash runs in a Linux subsystem with /mnt/c paths, which would break + the Windows `cwd` semantics every caller relies on — so it is rejected. + """ + candidates: list[str] = [] + for base_env in ("ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"): + base = os.environ.get(base_env) + if base: + candidates.append(os.path.join(base, "Git", "bin", "bash.exe")) + candidates.append(os.path.join(base, "Git", "usr", "bin", "bash.exe")) + candidates.append(r"C:\Program Files\Git\bin\bash.exe") + candidates.append(r"C:\Program Files\Git\usr\bin\bash.exe") + for path in candidates: + if os.path.isfile(path): + return path + # Last resort: PATH lookup, but reject WSL/Store bash (System32/WindowsApps). + found = shutil.which("bash") + if found: + low = found.lower() + if "system32" not in low and "windowsapps" not in low: + return found + return None + + +def _select_bash() -> str | None: + """Return the bash.exe to run commands through on Windows, else None. + + None means "use the platform default shell" (cmd.exe on Windows via + shell=True, /bin/sh on POSIX). Honors the C3_SHELL_BASH override: + '0'/'cmd' forces the platform default; an existing file path forces that + bash. Discovery is cached after the first call. + """ + if sys.platform != "win32": + return None + override = os.environ.get("C3_SHELL_BASH") + if override is not None: + if override.strip().lower() in ("0", "", "cmd", "false", "off"): + return None + if os.path.isfile(override): + return override + # Unrecognized override → fall through to discovery. + if not _bash_cache: + _bash_cache.append(_discover_git_bash()) + return _bash_cache[0] + + def _popen_kwargs() -> dict: # Force UTF-8 in child processes so Unicode output (→, box-drawing, emoji) # doesn't crash on Windows' legacy cp1252 console encoding. setdefault so an @@ -89,8 +150,17 @@ def _kill_tree(proc: subprocess.Popen) -> None: def _run_sync(cmd: str, cwd: str, timeout: int) -> dict: """Blocking subprocess run with hard kill on timeout. Returns structured dict.""" start = time.time() + bash = _select_bash() + if bash: + # POSIX dialect via Git Bash — matches the native Bash tool. + popen_target: object = [bash, "-c", cmd] + use_shell = False + else: + # Platform default: cmd.exe on Windows, /bin/sh on POSIX. + popen_target = cmd + use_shell = True proc = subprocess.Popen( - cmd, shell=True, cwd=cwd, + popen_target, shell=use_shell, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", **_popen_kwargs(), diff --git a/pyproject.toml b/pyproject.toml index 9d581b3..23bd29b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "code-context-control" -version = "2.40.0" +version = "2.41.0" description = "Local code-intelligence layer for AI coding tools (Claude Code, Codex, Gemini, Copilot). Retrieve less, read less, edit safer." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_c3_shell.py b/tests/test_c3_shell.py index f979fcf..2e2f34b 100644 --- a/tests/test_c3_shell.py +++ b/tests/test_c3_shell.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio +import os import sys import unittest from pathlib import Path @@ -211,5 +212,79 @@ def test_real_echo_returns_exit_0_and_stdout(self): self.assertIn("c3_smoke", out) +class _FakePopen: + """Captures Popen construction args; emulates a clean exit.""" + + last_args: tuple = () + last_kwargs: dict = {} + + def __init__(self, *args, **kwargs): + _FakePopen.last_args = args + _FakePopen.last_kwargs = kwargs + self.returncode = 0 + + def communicate(self, timeout=None): + return ("out\n", "") + + +class TestShellSelection(unittest.TestCase): + """Windows Git Bash discovery/selection and _run_sync shell routing.""" + + def setUp(self): + shell_mod._bash_cache.clear() + os.environ.pop("C3_SHELL_BASH", None) + + def tearDown(self): + shell_mod._bash_cache.clear() + os.environ.pop("C3_SHELL_BASH", None) + + def test_non_win32_returns_none(self): + with patch.object(shell_mod.sys, "platform", "linux"): + self.assertIsNone(shell_mod._select_bash()) + + def test_env_override_zero_forces_default_shell(self): + os.environ["C3_SHELL_BASH"] = "0" + with patch.object(shell_mod.sys, "platform", "win32"): + self.assertIsNone(shell_mod._select_bash()) + + def test_env_override_explicit_path_honored(self): + os.environ["C3_SHELL_BASH"] = __file__ # an existing file stands in for bash + with patch.object(shell_mod.sys, "platform", "win32"): + self.assertEqual(shell_mod._select_bash(), __file__) + + def test_discovers_git_bash_from_program_files(self): + base = r"C:\PF" + expected = os.path.join(base, "Git", "bin", "bash.exe") + with patch.dict(os.environ, {"ProgramFiles": base}, clear=False), \ + patch.object(shell_mod.os.path, "isfile", lambda p: p == expected): + self.assertEqual(shell_mod._discover_git_bash(), expected) + + def test_rejects_wsl_system32_bash(self): + with patch.object(shell_mod.os.path, "isfile", lambda p: False), \ + patch.object(shell_mod.shutil, "which", + return_value=r"C:\Windows\System32\bash.exe"): + self.assertIsNone(shell_mod._discover_git_bash()) + + def test_accepts_non_system_path_bash_from_which(self): + with patch.object(shell_mod.os.path, "isfile", lambda p: False), \ + patch.object(shell_mod.shutil, "which", + return_value=r"C:\tools\msys\bash.exe"): + self.assertEqual(shell_mod._discover_git_bash(), r"C:\tools\msys\bash.exe") + + def test_run_sync_uses_bash_argv_when_selected(self): + with patch.object(shell_mod, "_select_bash", return_value="FAKEBASH"), \ + patch.object(shell_mod.subprocess, "Popen", _FakePopen): + shell_mod._run_sync("echo hi", ".", 5) + self.assertEqual(_FakePopen.last_args[0], ["FAKEBASH", "-c", "echo hi"]) + self.assertIs(_FakePopen.last_kwargs["shell"], False) + + def test_run_sync_falls_back_to_default_shell_string(self): + with patch.object(shell_mod, "_select_bash", return_value=None), \ + patch.object(shell_mod.subprocess, "Popen", _FakePopen): + shell_mod._run_sync("echo hi", ".", 5) + self.assertEqual(_FakePopen.last_args[0], "echo hi") + self.assertIs(_FakePopen.last_kwargs["shell"], True) + + if __name__ == "__main__": unittest.main()