From a78956424d84462af4ff10d43edcca98a9cf368a Mon Sep 17 00:00:00 2001 From: Safi Date: Fri, 19 Jun 2026 10:06:07 +0100 Subject: [PATCH] Reject Windows-style git hooks paths instead of creating a junk dir (#1385) On WSL/POSIX, Path("C:\\...").is_absolute() is False, so a Windows absolute core.hooksPath (or rev-parse --git-path output) was joined under the repo root and mkdir'd as a literal backslash-named junk directory while install reported success and the real .git/hooks got nothing. Both branches of _hooks_dir now reject drive-letter / backslash paths with a clear error so the failure is loud. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/hooks.py | 21 +++++++++++++++++++++ tests/test_hooks.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/graphify/hooks.py b/graphify/hooks.py index 958465da..f4cd3a90 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -284,6 +284,25 @@ def _git_root(path: Path) -> Path | None: return None +_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]") + + +def _reject_windows_path(value: str, source: str) -> None: + """Raise if a hooks path looks like a Windows absolute path (#1385). + + On POSIX/WSL ``Path("C:\\Users\\...").is_absolute()`` is False, so an absolute + Windows hooks path gets joined under the repo root and mkdir'd as a literal + junk directory (backslashes and all), while install reports success and the + real ``.git/hooks`` gets nothing. Fail loudly instead so the user can fix it. + """ + if _WINDOWS_DRIVE_RE.match(value) or "\\" in value: + raise RuntimeError( + f"git hooks path from {source} looks like a Windows path: {value!r}. " + f"On WSL/POSIX this can't resolve to a real directory. Unset it with " + f"`git config --local --unset core.hooksPath`, or set a POSIX path." + ) + + def _hooks_dir(root: Path) -> Path: """Return the git hooks directory, respecting core.hooksPath if set (e.g. Husky).""" try: @@ -292,6 +311,7 @@ def _hooks_dir(root: Path) -> Path: # configparser lowercases option names; git's hooksPath becomes hookspath custom = cfg.get("core", "hookspath", fallback="").strip() if custom: + _reject_windows_path(custom, "core.hooksPath") p = Path(custom).expanduser() if not p.is_absolute(): p = root / p @@ -331,6 +351,7 @@ def _hooks_dir(root: Path) -> Path: # A valid hooks path can never contain newlines or NUL. Their presence # means git echoed an unrecognised flag back (old git behaviour). if res.returncode == 0 and raw and not any(c in raw for c in ("\n", "\r", "\x00")): + _reject_windows_path(raw, "git rev-parse --git-path hooks") d = (root / raw).resolve() d.mkdir(parents=True, exist_ok=True) return d diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 30b958d5..1080d1ea 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -333,3 +333,44 @@ def test_installed_hooks_contain_no_nohup(tmp_path): text = (repo / ".git" / "hooks" / name).read_text(encoding="utf-8") assert "nohup" not in text, f"installed {name} still references nohup" assert "start_new_session=True" in text + + +# ── #1385: reject Windows-style hooks paths instead of creating a junk dir ─── + +def _set_hookspath(repo: Path, value: str) -> None: + subprocess.run(["git", "-C", str(repo), "config", "--local", "core.hooksPath", value], + check=True, capture_output=True) + + +@pytest.mark.parametrize("winpath", [ + r"C:\Users\u\repo\.git\hooks", + r"c:/Users/u/.git/hooks", + r"D:\hooks", + r"some\back\slashed\path", +]) +def test_windows_hookspath_rejected_no_junk_dir(tmp_path, winpath): + """A Windows-style core.hooksPath must raise (loud failure), not silently + create a backslash-named junk directory and report success (#1385).""" + repo = _make_git_repo(tmp_path) + _set_hookspath(repo, winpath) + with pytest.raises(RuntimeError, match="Windows path"): + install(repo) + # no junk directory got created anywhere under the repo + junk = [p for p in repo.rglob("*") if "\\" in p.name or p.name.startswith(("C:", "c:", "D:"))] + assert junk == [], f"junk dir created: {junk}" + + +def test_posix_custom_hookspath_still_works(tmp_path): + """A legitimate POSIX core.hooksPath (Husky-style) must still install.""" + repo = _make_git_repo(tmp_path) + _set_hookspath(repo, ".husky") + msg = install(repo) + assert "post-commit" in msg + assert (repo / ".husky" / "post-commit").exists() + + +def test_default_hooks_dir_unaffected(tmp_path): + """No core.hooksPath -> normal .git/hooks install, no rejection.""" + repo = _make_git_repo(tmp_path) + install(repo) + assert (repo / ".git" / "hooks" / "post-commit").exists()