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) <noreply@anthropic.com>
This commit is contained in:
Safi
2026-06-19 10:06:07 +01:00
co-authored by Claude Opus 4.8
parent e6eaad3d7e
commit a78956424d
2 changed files with 62 additions and 0 deletions
+21
View File
@@ -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
+41
View File
@@ -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()