Skip to content
Closed
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
38 changes: 36 additions & 2 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2011,6 +2011,34 @@ def _get_script_timeout() -> int:
return _DEFAULT_SCRIPT_TIMEOUT



def _bash_friendly_path(path: Path) -> str:
"""Return a path string safe to pass as an argv entry to Git Bash.

On Windows, Git Bash re-interprets backslashes as escape sequences when
binding argv to the bash process (observed as ``C:Users...`` after
stripping backslashes — see #60857). Convert ``C:\\Users\\...`` forms
to ``/c/Users/...`` POSIX drive notation. Non-Windows returns
``str(path)`` unchanged.
"""
if sys.platform != "win32":
return str(path)
resolved = path if path.is_absolute() else path.resolve()
try:
drive = resolved.drive # e.g. 'C:'
if drive and len(drive) == 2 and drive[1] == ":":
rest = resolved.as_posix()
# as_posix keeps 'C:/Users/...' — strip drive letter colon.
if len(rest) >= 2 and rest[1] == ":":
rest = rest[2:]
if not rest.startswith("/"):
rest = "/" + rest
return f"/{drive[0].lower()}{rest}"
except Exception:
pass
return resolved.as_posix().replace("\\", "/")


def _run_job_script(script_path: str) -> tuple[bool, str]:
"""Execute a cron job's data-collection script and capture its output.

Expand Down Expand Up @@ -2089,9 +2117,15 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
"On Windows, install Git for Windows (which ships Git Bash) "
"or rewrite the script as Python (.py)."
)
argv = [_bash, str(path)]
# Git Bash on Windows treats backslashes in argv as shell escapes when
# it re-parses Windows paths (C:\\Users\\... becomes C:Users... and the
# script is not found — #60857). Hand bash a POSIX-style absolute path.
bash_script = _bash_friendly_path(path)
run_cwd = _bash_friendly_path(path.parent)
argv = [_bash, bash_script]
else:
argv = [sys.executable, str(path)]
run_cwd = str(path.parent)

try:
from tools.environments.local import _sanitize_subprocess_env
Expand All @@ -2102,7 +2136,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
capture_output=True,
text=True,
timeout=script_timeout,
cwd=str(path.parent),
cwd=run_cwd,
env=_sanitize_subprocess_env(os.environ.copy()),
**popen_kwargs,
)
Expand Down
83 changes: 83 additions & 0 deletions tests/cron/test_cron_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -579,3 +579,86 @@ def test_env_vars_cleaned_on_early_error(self, cron_env, monkeypatch):
assert os.environ.get("HERMES_SESSION_PLATFORM") is None
assert os.environ.get("HERMES_SESSION_CHAT_ID") is None
assert os.environ.get("HERMES_SESSION_CHAT_NAME") is None


class TestBashFriendlyPath:
"""Windows Git Bash path conversion for #60857."""

def test_non_windows_passthrough(self, monkeypatch):
from pathlib import Path
from cron.scheduler import _bash_friendly_path

monkeypatch.setattr("cron.scheduler.sys.platform", "darwin")
p = Path("/tmp/example.sh")
assert _bash_friendly_path(p) == str(p)

def test_windows_drive_to_posix(self, monkeypatch):
from cron.scheduler import _bash_friendly_path

monkeypatch.setattr("cron.scheduler.sys.platform", "win32")

class _WinPath:
def __init__(self, posix: str, drive: str = "C:"):
self._posix = posix
self.drive = drive

def is_absolute(self):
return True

def resolve(self):
return self

def as_posix(self):
return self._posix

got = _bash_friendly_path(
_WinPath("C:/Users/marce/AppData/Local/hermes/scripts/tareas_format.sh")
)
assert got == "/c/Users/marce/AppData/Local/hermes/scripts/tareas_format.sh"

def test_run_job_script_passes_posix_path_to_bash(self, cron_env, monkeypatch):
"""On win32, argv[1] for .sh jobs must be Git-Bash friendly."""
import cron.scheduler as sched

monkeypatch.setattr(sched.sys, "platform", "win32")
monkeypatch.setattr(sched.shutil, "which", lambda name: "C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe" if name == "bash" else None)

script = cron_env / "scripts" / "hello.sh"
script.write_text("#!/bin/bash\necho hi\n")

captured = {}

def fake_run(argv, **kwargs):
captured["argv"] = list(argv)
captured["cwd"] = kwargs.get("cwd")
class R:
returncode = 0
stdout = "hi\n"
stderr = ""
return R()

monkeypatch.setattr(sched.subprocess, "run", fake_run)
# Path.resolve on POSIX won't make Windows drives; force helper use via
# monkeypatch on _bash_friendly_path is overkill — on non-win host the
# path is POSIX and _bash_friendly_path under win32 without drive falls
# back to as_posix. Still asserts the conversion helper is invoked by
# ensuring we patch it.
calls = []
real = sched._bash_friendly_path

def spy(path):
out = real(path)
calls.append(out)
return out

monkeypatch.setattr(sched, "_bash_friendly_path", spy)

ok, out = sched._run_job_script("hello.sh")
assert ok is True
assert out == "hi"
assert "bash" in captured["argv"][0].lower()
# First conversion is the script path, second is cwd (path.parent)
assert len(calls) >= 2
assert captured["argv"][1] == calls[0]
assert captured["cwd"] == calls[1]
assert not any(ch == "\\" for ch in captured["argv"][1])
Loading