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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cli/c3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
72 changes: 71 additions & 1 deletion cli/tools/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
75 changes: 75 additions & 0 deletions tests/test_c3_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from __future__ import annotations

import asyncio
import os
import sys
import unittest
from pathlib import Path
Expand Down Expand Up @@ -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()
Loading