From 30bf33573b46da6fb0f3f7149bf7a72d7e507029 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 22 Sep 2026 14:36:17 -0700 Subject: [PATCH 1/3] fix(process_host): forward-slash Windows hook paths for Git Bash (#773) Claude Code runs shell-form hook commands under Git Bash on Windows, where an unquoted backslash is an escape: the registered relay C:\Users\me\.local\bin\bmad-loop.exe ran as C:Usersme.localbinbmad-loop.exe, no events were written, and every session stalled to session_timeout_min. WindowsProcessHost.shell_quote now normalizes separators to forward slashes before list2cmdline, so a path with spaces is still double-quoted. Every caller passes a path. Pre-#773 backslash registrations are still recognized as managed, so init and worktree provisioning replace them; the Codex trust diagnostic reads them as untrusted until re-init. Paths with spaces remain unsupported on the PowerShell fallback. --- CHANGELOG.md | 4 ++ src/bmad_loop/process_host.py | 17 ++++- tests/conftest.py | 46 +++++++++++++ tests/test_codex_trust.py | 28 +++++++- tests/test_install.py | 120 +++++++++++++++++++++++++++++++++- tests/test_process_host.py | 83 +++++++++++++++++++++-- 6 files changed, 287 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1fda8abb..30a740258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,10 @@ breaking changes may land in a minor release. - Count `plugin-hook-error` entries in `diagnose`'s plugin-errors total (#779). +- Register Windows hook commands with forward-slash paths so Git Bash no longer strips + their separators and stalls every session (#773); re-run `bmad-loop init` to migrate. + Paths with spaces remain unsupported under the PowerShell fallback. + - Replace stale installed relay hooks when a project moves between Windows and POSIX. - Report stale or unverifiable Codex hook trust in `validate` and `probe-adapter` diff --git a/src/bmad_loop/process_host.py b/src/bmad_loop/process_host.py index aecdb34f0..ccc6b8240 100644 --- a/src/bmad_loop/process_host.py +++ b/src/bmad_loop/process_host.py @@ -223,9 +223,20 @@ def hook_interpreter(self) -> str: return self.shell_quote(str(Path(sys.executable).absolute())) def shell_quote(self, arg: str) -> str: - # POSIX single-quoting breaks Windows paths; list2cmdline is the stdlib's - # Windows argument quoter (the inverse of how CreateProcess parses argv). - return subprocess.list2cmdline([arg]) + # Hook runners hand these commands to a shell, not CreateProcess: Claude + # Code uses Git Bash on Windows (PowerShell without it), and Git Bash eats + # an unquoted backslash — `C:\Users\me\bmad-loop.exe` runs as + # `C:Usersmebmad-loop.exe` and every session stalls to timeout (#773). So + # separators become forward slashes, which Windows and Python accept and + # sh, PowerShell and cmd.exe pass through unchanged; list2cmdline then + # double-quotes a path with spaces as before. Every caller passes a path + # (interpreter, relay executable, hook script), so no backslash here is an + # escape. Known gap: on the PowerShell fallback a double-quoted executable + # followed by arguments is a parse error without `&` (which would break + # sh), so paths WITH spaces stay unsupported there. Claude's exec-form + # `args` would avoid shells entirely but is Claude-only and changes the + # registered JSON shape older versions mis-run. + return subprocess.list2cmdline([arg.replace("\\", "/")]) def _proc_starttime(pid: int) -> float | None: diff --git a/tests/conftest.py b/tests/conftest.py index 2f92089ba..645bb9537 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -427,6 +427,52 @@ def write_script_launcher(directory: Path, name: str, body: str) -> Path: return launcher +class _WindowsLauncher: + """``Path(sys.argv[0]).absolute()`` as a Windows install presents it, on any + test host: ``str`` is the Windows path the relay registration quotes, while + ``is_file``/``os.access`` consult a real launcher file.""" + + def __init__(self, windows_path: str, real: Path) -> None: + self._windows, self._real = windows_path, real + self.name = "bmad-loop.exe" if os.name == "nt" else "bmad-loop" + + def absolute(self) -> _WindowsLauncher: + return self + + def is_file(self) -> bool: + return self._real.is_file() + + def __fspath__(self) -> str: + return os.fspath(self._real) + + def __str__(self) -> str: + return self._windows + + +def windows_relay_builder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, windows_path: str): + """The real `install._hook_command`, run as a Windows install at + ``windows_path`` would run it (WindowsProcessHost quoting), on any test host. + The Windows identity is patched in only for the duration of each call, so the + result can stand in for `_hook_command` wherever a caller imported it.""" + import bmad_loop.install as install_mod + from bmad_loop.process_host import WindowsProcessHost + + launcher = tmp_path / "windows-launcher" / "bmad-loop" + launcher.parent.mkdir(exist_ok=True) + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + launcher.chmod(0o755) + real = install_mod._hook_command + + def build(project, profile, canonical_event): + with monkeypatch.context() as m: + m.setattr(install_mod.sys, "argv", [windows_path]) + m.setattr(install_mod, "Path", lambda raw: _WindowsLauncher(raw, launcher)) + m.setattr(install_mod, "get_process_host", WindowsProcessHost) + return real(project, profile, canonical_event) + + return build + + # ---- host-shell verify/lifecycle stub commands (single platform-detection spot) ---- # The engine runs verify/plugin-lifecycle commands via the host shell (`sh -c` on # POSIX, `cmd /c` on Windows), so tests that assert on that machinery need commands diff --git a/tests/test_codex_trust.py b/tests/test_codex_trust.py index eb390bb6b..832d04564 100644 --- a/tests/test_codex_trust.py +++ b/tests/test_codex_trust.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest -from conftest import install_bmad_config, write_script_launcher +from conftest import install_bmad_config, windows_relay_builder, write_script_launcher from bmad_loop import cli, codex_trust, probe from bmad_loop.adapters.profile import ProfileError, get_profile @@ -77,6 +77,32 @@ def test_trust_rejects_installed_relay_at_wrong_path(tmp_path, monkeypatch): assert codex_trust.project_hook_trust(tmp_path, get_profile("codex")).status == "untrusted" +def test_trust_reads_backslash_windows_relay_as_stale_and_forward_slash_as_current( + tmp_path, monkeypatch +): + """After #773 the expected Windows relay names its executable with forward + slashes. A pre-#773 backslash registration (broken under Git Bash anyway) is + still recognized as the relay but differs from the command init writes now: + it reads untrusted — re-run init — without querying Codex or raising.""" + windows_exe = r"C:\Users\me\.local\bin\bmad-loop.exe" + build = windows_relay_builder(monkeypatch, tmp_path, windows_exe) + monkeypatch.setattr(codex_trust, "_hook_command", build) + old = _config(tmp_path, {e: rf"{windows_exe} relay {e}" for e in ("SessionStart", "Stop")}) + monkeypatch.setattr(codex_trust, "_hooks_list", lambda *_: pytest.fail("stale relay")) + result = codex_trust.project_hook_trust(tmp_path, get_profile("codex")) + assert result.status == "untrusted" and "not usable" in result.reason + + profile = get_profile("codex") + current = _config(tmp_path, {e: build(tmp_path, profile, e) for e in ("SessionStart", "Stop")}) + assert current != old + assert current["hooks"]["Stop"][0]["hooks"][0]["command"] == ( + "C:/Users/me/.local/bin/bmad-loop.exe relay Stop" + ) + monkeypatch.setattr(codex_trust, "resolved_codex_binary", lambda *_: "codex-stub") + monkeypatch.setattr(codex_trust, "_hooks_list", lambda *_: _rpc(tmp_path, current)) + assert codex_trust.project_hook_trust(tmp_path, get_profile("codex")).status == "trusted" + + def test_trust_degrades_when_installed_relay_disappears(tmp_path, monkeypatch): _config(tmp_path) diff --git a/tests/test_install.py b/tests/test_install.py index b9ba4ff2c..ca444631c 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -11,7 +11,7 @@ import subprocess import sys import zipfile -from pathlib import Path, PurePosixPath +from pathlib import Path, PurePosixPath, PureWindowsPath import pytest from conftest import ( @@ -22,9 +22,11 @@ install_build_auto_skill, install_dev_shim, refuse_to_resolve, + windows_relay_builder, ) import bmad_loop.install as install_mod +import bmad_loop.worktree_flow as worktree_flow_mod from bmad_loop import verify from bmad_loop.adapters.profile import ProfileError, get_profile from bmad_loop.install import ( @@ -417,6 +419,122 @@ def access(path, mode): install_mod._hook_command(tmp_path, get_profile("claude"), "Stop") +WINDOWS_RELAY = r"C:\Users\me\.local\bin\bmad-loop.exe" + + +@pytest.mark.skipif( + sys.platform == "win32" or shutil.which("bash") is None, + reason="POSIX bash stands in for Git Bash; the win32 rows execute the real shells", +) +@pytest.mark.parametrize( + "windows_path", [WINDOWS_RELAY, r"C:\Program Files\x y\bmad-loop.exe"], ids=["plain", "spaces"] +) +def test_windows_relay_command_survives_bash(tmp_path, monkeypatch, windows_path): + """Claude Code runs hook commands under Git Bash on Windows (#773), where an + unquoted backslash is an escape: the registered executable must reach argv[0] + with every separator. `printf` echoes argv in place of the relay.""" + build = windows_relay_builder(monkeypatch, tmp_path, windows_path) + command = build(tmp_path, get_profile("claude"), "Stop") + ran = subprocess.run( + ["bash", "-c", "printf '%s\\n' " + command], capture_output=True, text=True, check=True + ) + argv = ran.stdout.splitlines() + assert argv[1:] == ["relay", "Stop"] + assert PureWindowsPath(argv[0]) == PureWindowsPath(windows_path) + + +def test_relay_executable_recognizes_backslash_and_forward_slash_windows_forms( + tmp_path, monkeypatch +): + build = windows_relay_builder(monkeypatch, tmp_path, WINDOWS_RELAY) + current = build(tmp_path, get_profile("claude"), "Stop") + assert current == "C:/Users/me/.local/bin/bmad-loop.exe relay Stop" + for command in (current, rf"{WINDOWS_RELAY} relay Stop"): + executable = relay_executable(command) + assert executable is not None + assert PureWindowsPath(str(executable)) == PureWindowsPath(WINDOWS_RELAY) + + +def _stop_commands(config: dict) -> list[str]: + return [hook["command"] for group in config["hooks"]["Stop"] for hook in group["hooks"]] + + +def test_merge_hooks_replaces_backslash_windows_relay_with_forward_slash_form( + tmp_path, monkeypatch +): + """An install registered before #773 names the same executable with + backslashes: re-registration replaces it rather than adding a second relay, + and a second pass over the forward-slash form changes nothing.""" + profile = get_profile("claude") + build = windows_relay_builder(monkeypatch, tmp_path, WINDOWS_RELAY) + registrations = { + native: build(tmp_path, profile, canonical) + for native, canonical in profile.hooks.events.items() + } + old = rf"{WINDOWS_RELAY} relay Stop" + config = { + "hooks": { + "Stop": [ + { + "hooks": [ + {"type": "command", "command": old}, + {"type": "command", "command": "make lint"}, + ] + } + ] + } + } + config, changed = merge_hooks(config, registrations, profile.hooks.dialect) + assert changed + assert sorted(_stop_commands(config)) == sorted([registrations["Stop"], "make lint"]) + again, changed = merge_hooks( + json.loads(json.dumps(config)), registrations, profile.hooks.dialect + ) + assert not changed and again == config + + +def test_init_and_provision_replace_backslash_windows_relay(tmp_path, monkeypatch): + """Both writers — `init` and worktree provisioning — go through the Windows + builder here and replace the pre-#773 backslash registration exactly once.""" + build = windows_relay_builder(monkeypatch, tmp_path, WINDOWS_RELAY) + monkeypatch.setattr(install_mod, "_hook_command", build) + monkeypatch.setattr(worktree_flow_mod, "_hook_command", build) + profile = get_profile("claude") + old = rf"{WINDOWS_RELAY} relay Stop" + current = "C:/Users/me/.local/bin/bmad-loop.exe relay Stop" + seeded = json.dumps( + { + "hooks": { + "Stop": [ + { + "hooks": [ + {"type": "command", "command": old}, + {"type": "command", "command": "make lint"}, + ] + } + ] + } + } + ) + + project = tmp_path / "project" + config = project / profile.hooks.config_path + config.parent.mkdir(parents=True) + config.write_text(seeded) + assert install_into(project, skills=False) == 0 + migrated = config.read_bytes() + assert sorted(_stop_commands(json.loads(migrated))) == [current, "make lint"] + assert install_into(project, skills=False) == 0 + assert config.read_bytes() == migrated + + wt, repo = tmp_path / "wt", tmp_path / "repo" + (repo / profile.hooks.config_path).parent.mkdir(parents=True) + (repo / profile.hooks.config_path).write_text(seeded) + provision_worktree(wt, [profile], repo, seed_files=[profile.hooks.config_path]) + provisioned = json.loads((wt / profile.hooks.config_path).read_text()) + assert sorted(_stop_commands(provisioned)) == [current, "make lint"] + + def test_provision_worktree_refuses_missing_installed_command(tmp_path, monkeypatch): monkeypatch.setattr(install_mod.sys, "executable", str(tmp_path / "missing" / "python")) with pytest.raises(verify.GitError, match="cannot register worktree relay"): diff --git a/tests/test_process_host.py b/tests/test_process_host.py index 0536e5683..90105fcb2 100644 --- a/tests/test_process_host.py +++ b/tests/test_process_host.py @@ -3,11 +3,12 @@ from __future__ import annotations import os +import shutil import signal import subprocess import sys import time -from pathlib import Path +from pathlib import Path, PureWindowsPath import pytest @@ -358,11 +359,19 @@ def test_shell_quote_posix_uses_shlex(): assert PosixProcessHost().shell_quote("/a b/c.py") == "'/a b/c.py'" -def test_shell_quote_windows_uses_list2cmdline(): - # Windows quoting double-quotes a path with spaces (subprocess.list2cmdline), - # never single-quoting — POSIX single-quotes would mangle a Windows command. - quoted = WindowsProcessHost().shell_quote(r"C:\a b\c.py") - assert quoted == '"C:\\a b\\c.py"' +@pytest.mark.parametrize( + "path,quoted", + [ + (r"C:\Users\me\.local\bin\bmad-loop.exe", "C:/Users/me/.local/bin/bmad-loop.exe"), + (r"C:\Program Files\x y\bmad-loop.exe", '"C:/Program Files/x y/bmad-loop.exe"'), + ], + ids=["no-spaces", "spaces"], +) +def test_shell_quote_windows_forward_slashes_then_list2cmdline(path, quoted): + # Hook runners run these under Git Bash, which eats unquoted backslashes + # (#773): separators become forward slashes, and a path with spaces is still + # double-quoted (subprocess.list2cmdline), never POSIX single-quoted. + assert WindowsProcessHost().shell_quote(path) == quoted def test_hook_interpreter_is_absolute_on_posix(host): @@ -381,6 +390,68 @@ def test_hook_interpreter_windows_uses_absolute_path(): ) +def test_hook_interpreter_windows_uses_forward_slashes(monkeypatch): + class _Absolute: + def __init__(self, raw): + self._raw = raw + + def absolute(self): + return PureWindowsPath(self._raw) + + monkeypatch.setattr(process_host, "Path", _Absolute) + monkeypatch.setattr(process_host.sys, "executable", r"C:\Program Files\Py 3\python.exe") + assert WindowsProcessHost().hook_interpreter() == '"C:/Program Files/Py 3/python.exe"' + monkeypatch.setattr(process_host.sys, "executable", r"C:\Py\python.exe") + assert WindowsProcessHost().hook_interpreter() == "C:/Py/python.exe" + + +def _git_bash() -> str | None: + """Git for Windows' bash — never System32's WSL launcher, which PATH may + list first.""" + git = shutil.which("git") + if git: + bash = Path(git).resolve().parent.parent / "bin" / "bash.exe" + if bash.is_file(): + return str(bash) + bash = shutil.which("bash") + return bash if bash and "system32" not in bash.lower() else None + + +def _windows_relay_shaped_command() -> str: + # A real .exe at a backslash path standing where the relay executable goes. + if " " in sys.executable: + pytest.skip("paths with spaces are unsupported on the PowerShell fallback") + quoted = WindowsProcessHost().shell_quote(sys.executable) + return f'{quoted} -c "import sys; print(sys.argv[1:])" relay Stop' + + +@pytest.mark.skipif(sys.platform != "win32", reason="executes a Windows hook command") +def test_windows_hook_command_runs_under_git_bash(): + bash = _git_bash() + if bash is None: + pytest.skip("Git Bash is not installed") + command = _windows_relay_shaped_command() + ran = subprocess.run([bash, "-c", command], capture_output=True, text=True, timeout=60) + assert ran.returncode == 0, ran.stderr + assert "['relay', 'Stop']" in ran.stdout + + +@pytest.mark.skipif(sys.platform != "win32", reason="executes a Windows hook command") +def test_windows_hook_command_runs_under_powershell(): + shell = shutil.which("powershell") or shutil.which("pwsh") + if shell is None: + pytest.skip("PowerShell is not installed") + command = _windows_relay_shaped_command() + ran = subprocess.run( + [shell, "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + text=True, + timeout=60, + ) + assert ran.returncode == 0, ran.stderr + assert "['relay', 'Stop']" in ran.stdout + + def test_hook_interpreter_routed_through_selected_host(monkeypatch): # The env override drives the prefix end-to-end, so a Windows host changes the # registered hook command with no `sys.platform` branch at the call site. From c681fc7f32343f875932dd99fe84b6d32a186785 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 22 Sep 2026 17:26:20 -0700 Subject: [PATCH 2/3] fix(validate): flag relay registrations whose spelling differs from init's (#773) hooks.relay-stale compared Path objects. On Windows, Path equality and str(Path) both normalize separators, so a pre-#773 backslash registration, which Git Bash mangles and which stalls every in-place session, was never flagged, although init's merge_hooks (a raw-string comparison) rewrites it. relay_executable_text now returns the executable exactly as registered, and registered_relay_paths pairs each Path with that spelling. validate keeps its presence checks on the Path but compares the registered text with the one init would write now, so the warning means "re-running init changes something". It stays advisory (rc 0). The existing another-installation test now reads the expected spelling with relay_executable_text. It is unchanged on POSIX, and on Windows str(Path) would respell init's forward slashes. --- CHANGELOG.md | 3 +- src/bmad_loop/cli.py | 24 +++++++------ src/bmad_loop/install.py | 31 ++++++++++++---- tests/test_cli.py | 77 ++++++++++++++++++++++++++++++++++++++-- tests/test_install.py | 4 ++- 5 files changed, 117 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30a740258..28c9c570d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,8 @@ breaking changes may land in a minor release. - Register Windows hook commands with forward-slash paths so Git Bash no longer strips their separators and stalls every session (#773); re-run `bmad-loop init` to migrate. - Paths with spaces remain unsupported under the PowerShell fallback. + `validate` warns (`hooks.relay-stale`) while a backslash registration remains. Paths + with spaces remain unsupported under the PowerShell fallback. - Replace stale installed relay hooks when a project moves between Windows and POSIX. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index a324630de..a654d54b6 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -679,7 +679,7 @@ def cmd_validate(args: argparse.Namespace) -> int: {"binary": tool, "path": resolved, "returncode": rc}, ) - registered_relay_paths: set[Path] = set() + registered_relays: set[tuple[Path, str]] = set() for profile in profiles: # Keyed on the adapter KIND, not on `hookless`. httpx is the bundled # opencode family's optional extra — a fact about one adapter class, which @@ -742,7 +742,7 @@ def cmd_validate(args: argparse.Namespace) -> int: {"profile": profile.name, "config_path": str(hook_config)}, ) if isinstance(parsed, dict): - registered_relay_paths.update( + registered_relays.update( install.registered_relay_paths( parsed, profile.hooks.dialect, profile.hooks.events, project ) @@ -808,18 +808,22 @@ def cmd_validate(args: argparse.Namespace) -> int: # installation in this process cannot repair an older path in a hook config. # Compare with the command init would write now: an old executable can remain # usable after switching installations, while still running an outdated relay. - expected_relay = None - if registered_relay_paths: + # The comparison is on the registered TEXT, the same test init's merge_hooks + # applies: on Windows both `Path` equality and `str(Path)` normalize + # separators, so a pre-#773 backslash registration (which Git Bash mangles, + # stalling every session) would otherwise never be flagged. + expected_text = None + if registered_relays: hook_profile = next(profile for profile in profiles if not profile.hookless) try: - expected_relay = install.relay_executable( + expected_text = install.relay_executable_text( install._hook_command(project, hook_profile, "Stop") ) except ProfileError: # No current executable to compare. The registered path still gets # its own presence check below; do not call it stale by inference. pass - for relay in sorted(registered_relay_paths): + for relay, spelling in sorted(registered_relays): if not relay.is_file(): report.fail( "hooks.relay-present", @@ -840,13 +844,13 @@ def cmd_validate(args: argparse.Namespace) -> int: f"registered hook executable available: {relay}", {"path": str(relay)}, ) - if expected_relay is not None and relay != expected_relay: + if expected_text is not None and spelling != expected_text: report.warn( "hooks.relay-stale", - f"registered hook executable {relay} differs from this " - f"installation's {expected_relay} — re-run `bmad-loop init` " + f"registered hook executable {spelling} differs from this " + f"installation's {expected_text} — re-run `bmad-loop init` " "to update the hook registration", - {"path": str(relay), "expected_path": str(expected_relay)}, + {"path": spelling, "expected_path": expected_text}, ) # Adapter-kind validity is enforced against the LIVE registry, never a diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 68a86ca12..bf6c575f7 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -1027,6 +1027,16 @@ def relay_executable(command: str) -> Path | None: A foreign path remains unusable on this host, but must still be recognized as managed so init and worktree provisioning replace its stale hook. """ + text = relay_executable_text(command) + return Path(text) if text is not None else None + + +def relay_executable_text(command: str) -> str | None: + """Return a relay command's executable exactly as registered. + + `Path` normalizes the spelling (on Windows it treats `\\` and `/` alike), so + callers asking "would init write something different" compare this text. + """ for posix in (os.name != "nt", os.name == "nt"): try: parts = shlex.split(command, posix=posix) @@ -1042,7 +1052,7 @@ def relay_executable(command: str) -> Path | None: for flavor in (PurePosixPath, PureWindowsPath): executable = flavor(raw) if executable.name in {"bmad-loop", "bmad-loop.exe"} and executable.is_absolute(): - return Path(raw) + return raw return None @@ -1197,23 +1207,30 @@ def relay_registered(config: dict, dialect: str, events: Iterable[str]) -> bool: def registered_relay_paths( config: dict, dialect: str, events: Iterable[str], project: Path -) -> list[Path]: - """Paths invoked by the actual managed commands in a hook config.""" +) -> list[tuple[Path, str]]: + """Paths invoked by the actual managed commands in a hook config. + + Each path is paired with its registered spelling: the console relay's + executable text as written in the command, or the legacy script path with + the project directory substituted. The `Path` answers presence questions; + the spelling answers whether init would now write something different. + """ container = hook_event_container(config, dialect) - paths: list[Path] = [] + paths: list[tuple[Path, str]] = [] for event in events: handlers = container.get(event) if not isinstance(handlers, list): continue for handler in handlers: for command in _commands_in_handler(handler): - executable = relay_executable(command) + executable = relay_executable_text(command) if executable is not None: - paths.append(executable) + paths.append((Path(executable), executable)) else: script = _legacy_relay_script(command) if script is not None: - paths.append(Path(script.replace("$CLAUDE_PROJECT_DIR", str(project)))) + path = Path(script.replace("$CLAUDE_PROJECT_DIR", str(project))) + paths.append((path, str(path))) return paths diff --git a/tests/test_cli.py b/tests/test_cli.py index ed7070552..141382c03 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,7 +10,7 @@ import subprocess import sys import types -from pathlib import Path +from pathlib import Path, PureWindowsPath import pytest import yaml @@ -9713,7 +9713,8 @@ def test_validate_warns_when_registered_relay_uses_another_installation( config = project.project / ".claude/settings.json" data = json.loads(config.read_text()) current_command = data["hooks"]["Stop"][0]["hooks"][0]["command"] - current = install_mod.relay_executable(current_command) + # The registered text: on Windows str(Path) would respell init's forward slashes. + current = install_mod.relay_executable_text(current_command) assert current is not None previous = project.project / "previous-install" / "bmad-loop" @@ -9737,7 +9738,7 @@ def test_validate_warns_when_registered_relay_uses_another_installation( f"installation's {current} — re-run `bmad-loop init` " "to update the hook registration" ), - "detail": {"path": str(previous), "expected_path": str(current)}, + "detail": {"path": str(previous), "expected_path": current}, } ] assert any( @@ -9748,6 +9749,76 @@ def test_validate_warns_when_registered_relay_uses_another_installation( ) +def _respell_registered_relay(project, respell) -> tuple[str, str]: + """Rewrite the committed Stop relay's executable text; return (old, new) spellings.""" + from bmad_loop import install as install_mod + + config = project.project / ".claude/settings.json" + data = json.loads(config.read_text()) + command = data["hooks"]["Stop"][0]["hooks"][0]["command"] + current = install_mod.relay_executable_text(command) + assert current is not None + respelled = respell(current) + assert respelled != current + data["hooks"]["Stop"][0]["hooks"][0]["command"] = command.replace(current, respelled, 1) + config.write_text(json.dumps(data), encoding="utf-8") + git(project.project, "add", "-A") + git(project.project, "commit", "-q", "-m", "respell hook registration") + return current, respelled + + +def _single_relay_stale(doc) -> dict: + findings = [f for f in doc["findings"] if f["check"] == "hooks.relay-stale"] + assert len(findings) == 1, findings + return findings[0] + + +def test_validate_warns_when_relay_spelling_differs_but_path_is_equal(project, capsys, monkeypatch): + """`Path` equality normalizes the spelling; init's merge_hooks compares text and + would rewrite this registration, so validate must call it stale (#773).""" + _make_validate_pass(project, monkeypatch, capsys) + + def respell(current: str) -> str: + head, _, name = current.rpartition("/") + return f"{head}/./{name}" + + current, respelled = _respell_registered_relay(project, respell) + assert Path(respelled) == Path(current) # the premise: a Path comparison sees no change + + rc, doc = _validate_json(project.project, capsys) + assert rc == 0 # advisory: the relay still runs + assert _single_relay_stale(doc) == { + "check": "hooks.relay-stale", + "severity": "warning", + "message": ( + f"registered hook executable {respelled} differs from this " + f"installation's {current} — re-run `bmad-loop init` " + "to update the hook registration" + ), + "detail": {"path": respelled, "expected_path": current}, + } + + +@pytest.mark.skipif( + sys.platform != "win32", reason="Windows path spelling; runs on the Windows leg" +) +def test_validate_warns_on_backslash_windows_relay_registration(project, capsys, monkeypatch): + """A pre-#773 backslash registration stalls sessions under Git Bash; `Path` and + `str(Path)` both hide it on Windows, so only the registered text flags it.""" + _make_validate_pass(project, monkeypatch, capsys) + current, backslashed = _respell_registered_relay( + project, lambda current: str(PureWindowsPath(current)) + ) + assert "\\" in backslashed + + rc, doc = _validate_json(project.project, capsys) + assert rc == 0 + finding = _single_relay_stale(doc) + assert finding["severity"] == "warning" + assert finding["detail"] == {"path": backslashed, "expected_path": current} + assert backslashed in finding["message"] and current in finding["message"] + + def test_validate_json_reports_null_hook_handlers_without_crashing(project, capsys): from bmad_loop.install import install_into diff --git a/tests/test_install.py b/tests/test_install.py index ca444631c..482b4921c 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -325,7 +325,9 @@ def test_registered_relay_paths_reads_installed_relay_from_either_os( } } paths = registered_relay_paths(config, "claude-settings-json", ["Stop"], tmp_path) - assert [str(path).replace("\\", "/") for path in paths] == [expected_path.replace("\\", "/")] + assert [str(path).replace("\\", "/") for path, _ in paths] == [expected_path.replace("\\", "/")] + # The spelling is the registered text, which `Path` would normalize on Windows. + assert [spelling for _, spelling in paths] == [expected_path] def test_init_preserves_different_script_with_same_basename(tmp_path): From a30b9eaec50af2f6b06695e2a87981d3ea5057a5 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 22 Sep 2026 18:40:58 -0700 Subject: [PATCH 3/3] test(install): expect forward-slash launcher on Windows hook commands (#773) --- tests/test_install.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_install.py b/tests/test_install.py index 482b4921c..e9406849c 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -401,7 +401,8 @@ def test_hook_command_uses_invoked_non_sibling_launcher(tmp_path, monkeypatch): launcher.chmod(0o755) monkeypatch.setattr(install_mod.sys, "argv", [str(launcher)]) command = install_mod._hook_command(tmp_path, get_profile("claude"), "Stop") - assert shlex.split(command)[0] == str(launcher) + # Forward slashes on Windows, where Git Bash would eat backslashes (#773). + assert shlex.split(command)[0] == launcher.as_posix() def test_hook_command_refuses_unreadable_executable(tmp_path, monkeypatch):