fix(hooks): resolve the uv tool venv when pin and launcher probes miss (#2852)

Installed via `uv tool install`, the post-commit hook could not find graphify: the pin and
launcher/shebang probes miss the uv tool venv, so the hook silently exit-0'd. Add a fourth
fallback probe that scans $UV_TOOL_DIR and the conventional uv tool locations for a python
whose env has graphify importable, firing only after the earlier probes miss and verifying
importability before adopting. Also gate the launcher read on a real #! header so a binary
trampoline is not misparsed as a shebang.
This commit is contained in:
santhiprakash
2026-08-21 18:26:11 +01:00
committed by safishamsi
parent 037970159d
commit 9452ef216b
2 changed files with 175 additions and 3 deletions
+33 -3
View File
@@ -67,10 +67,17 @@ if [ -z "$GRAPHIFY_PYTHON" ]; then
# POSIX launcher: parse the shebang. head -c + tr strip NUL bytes first —
# when the launcher is a Windows binary reached without its .exe suffix,
# a raw `head -1` reads binary into the command substitution and the
# shell warns about ignored null bytes on every commit.
# shell warns about ignored null bytes on every commit. Gate on a
# leading '#!': a launcher can also be a binary trampoline with no
# shebang at all (uv tool installs on Windows), and its bytes must
# never reach the shebang parse (#2852).
case "$GRAPHIFY_BIN" in
*.exe) _SHEBANG="" ;;
*) _SHEBANG=$(head -c 256 "$GRAPHIFY_BIN" 2>/dev/null | tr -d '\\000' | head -n 1 | sed 's/^#![[:space:]]*//') ;;
*.exe) _GFY_HEAD="" ;;
*) _GFY_HEAD=$(head -c 256 "$GRAPHIFY_BIN" 2>/dev/null | tr -d '\\000') ;;
esac
case "$_GFY_HEAD" in
'#!'*) _SHEBANG=$(printf '%s\\n' "$_GFY_HEAD" | head -n 1 | sed 's/^#![[:space:]]*//') ;;
*) _SHEBANG="" ;;
esac
case "$_SHEBANG" in
*/env\\ *) GRAPHIFY_PYTHON="${_SHEBANG#*/env }" ;;
@@ -86,6 +93,29 @@ if [ -z "$GRAPHIFY_PYTHON" ]; then
fi
fi
fi
# Fourth probe: uv tool environments. `uv tool install` (the README's
# recommended method) puts graphify in an isolated venv that no ambient
# python can import, and on Windows its launcher on PATH is a binary
# trampoline with no shebang to parse — so the probes above can all miss a
# healthy install and the hook dies at the last-resort fallback (#2852).
# Scan the uv tool envs directly; UV_TOOL_DIR overrides the default
# location. A tool env is adopted only if its python passes the probe, so a
# co-installed tool without graphify never satisfies it.
if [ -z "$GRAPHIFY_PYTHON" ]; then
for _GFY_TOOLS in \
"${UV_TOOL_DIR:-}" \
"$HOME/.local/share/uv/tools" \
"$HOME/AppData/Roaming/uv/tools"; do
[ -n "$_GFY_TOOLS" ] || continue
for _GFY_CAND in "$_GFY_TOOLS"/*/bin/python "$_GFY_TOOLS"/*/Scripts/python.exe; do
[ -x "$_GFY_CAND" ] || continue
if "$_GFY_CAND" -c "$_GFY_PROBE" 2>/dev/null; then
GRAPHIFY_PYTHON="$_GFY_CAND"
break 2
fi
done
done
fi
# Last resort: try python3 / python (works for system/venv installs on PATH).
if [ -z "$GRAPHIFY_PYTHON" ]; then
if command -v python3 >/dev/null 2>&1 && python3 -c "$_GFY_PROBE" 2>/dev/null; then
+142
View File
@@ -450,6 +450,148 @@ def test_probe_prefers_sibling_python_exe_on_windows_layouts():
assert "/python.exe" in _PYTHON_DETECT
# ── #2852: interpreter resolution under uv tool installs ───────────────────
def _detect_run(tmp_path, home, stub_bin, env_extra=None):
"""Run the emitted _PYTHON_DETECT under a real sh in a controlled
environment — dead pin, no .graphify_python, an unparseable launcher first
on PATH, and a python3 that cannot import graphify (#2852's uv-tool
Windows machine reproduced on POSIX) — and report what GRAPHIFY_PYTHON
resolved to. Behavior of the emitted script, not the source string
(per the #2126/#2641 convention)."""
from graphify.hooks import _PYTHON_DETECT
script = tmp_path / "detect_run.sh"
script.write_text(
_PYTHON_DETECT + '\necho "RESOLVED=$GRAPHIFY_PYTHON"\n',
encoding="utf-8", newline="\n",
)
env = dict(os.environ)
env["HOME"] = str(home)
env.pop("UV_TOOL_DIR", None)
if env_extra:
env.update(env_extra)
env["PATH"] = str(stub_bin) + os.pathsep + env["PATH"]
return subprocess.run(
["sh", script.name], capture_output=True, text=True,
cwd=str(tmp_path), env=env,
)
def _broken_uv_machine(tmp_path):
"""#2852's machine: the only graphify-importable python lives in the uv
tool venv; the launcher on PATH is a binary trampoline reached WITHOUT its
.exe suffix (Git-Bash command -v); ambient python3 cannot import graphify.
Returns (home, stub_bin)."""
home = tmp_path / "home"
stub_bin = tmp_path / "stubbin"
stub_bin.mkdir(parents=True)
launcher = stub_bin / "graphify"
launcher.write_bytes(b"MZ\x90\x00\x03" + bytes(range(60)))
launcher.chmod(0o755)
# Ambient pythons answer the probe with "no module named graphify" —
# under uv tool install no system python can see the isolated venv (#2852).
for name in ("python3", "python"):
py = stub_bin / name
py.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8", newline="\n")
py.chmod(0o755)
return home, stub_bin
def _tool_venv(home, tool, rel, ok):
"""Create a fake uv tool env python under <home>/.local/share/uv/tools;
ok=False simulates a venv without graphify (the probe must reject it)."""
py = home / ".local" / "share" / "uv" / "tools" / tool / rel
py.parent.mkdir(parents=True, exist_ok=True)
py.write_text(
"#!/bin/sh\nexit 0\n" if ok else "#!/bin/sh\nexit 1\n",
encoding="utf-8", newline="\n",
)
py.chmod(0o755)
return py
@pytest.mark.skipif(shutil.which("sh") is None, reason="sh required to run emitted probe chain")
def test_uv_tool_env_rescues_hook_when_pin_and_launcher_fail(tmp_path):
"""#2852: `uv tool install` puts graphify in an isolated venv no ambient
python can import, and on Windows the launcher is a binary trampoline with
no shebang to parse. With the pin dead (git-template hooks, a pin rejected
by the allowlist, or a venv moved by an upgrade), every earlier probe
missed and the hook no-op'd with only a warning. The uv tool-env scan must
adopt the venv whose python passes the probe — and keep walking past
sibling tool envs that do not (glob order puts aaa-tool first)."""
home, stub_bin = _broken_uv_machine(tmp_path)
other = _tool_venv(home, "aaa-plain-tool", "bin/python", ok=False)
mine = _tool_venv(home, "graphifyy", "bin/python", ok=True)
res = _detect_run(tmp_path, home, stub_bin)
assert res.returncode == 0, res.stderr
assert f"RESOLVED={mine}" in res.stdout, res.stdout + res.stderr
assert f"RESOLVED={other}" not in res.stdout
assert "could not locate" not in res.stderr
@pytest.mark.skipif(shutil.which("sh") is None or os.name == "nt",
reason="sh required; a sh-script named python.exe only execs on POSIX")
def test_uv_tool_env_honors_uv_tool_dir_and_windows_layout(tmp_path):
"""UV_TOOL_DIR overrides the default location, and the Windows layout
(`<tool>\\Scripts\\python.exe`) is scanned too — the reporter's exact
machine (#2852)."""
home, stub_bin = _broken_uv_machine(tmp_path)
tools = tmp_path / "custom-tools"
py = tools / "graphifyy" / "Scripts" / "python.exe"
py.parent.mkdir(parents=True)
py.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8", newline="\n")
py.chmod(0o755)
res = _detect_run(tmp_path, home, stub_bin, env_extra={"UV_TOOL_DIR": str(tools)})
assert res.returncode == 0, res.stderr
assert f"RESOLVED={py}" in res.stdout, res.stdout + res.stderr
@pytest.mark.skipif(shutil.which("sh") is None, reason="sh required to run emitted probe chain")
def test_uv_tool_env_without_graphify_still_fails_loudly(tmp_path):
"""The scan must not adopt a tool env whose python lacks graphify; the
chain still ends in the loud 'could not locate' warning on stderr — never
a bare silent exit (#2852's diagnosis ask)."""
home, stub_bin = _broken_uv_machine(tmp_path)
_tool_venv(home, "aaa-tool", "bin/python", ok=False)
res = _detect_run(tmp_path, home, stub_bin)
assert "could not locate" in res.stderr
# the sentinel must NOT print: the chain exited at the warning
assert res.stdout == ""
@pytest.mark.skipif(shutil.which("sh") is None, reason="sh required to run emitted probe chain")
def test_shebang_parse_requires_leading_hash_bang(tmp_path):
"""The launcher read must gate on a leading '#!': a non-script launcher
whose first line merely NAMES a working python must not be adopted as the
interpreter. Pre-gate code parsed any first line, so trampoline bytes
(and here, a decoy path) reached the shebang parse (#2852)."""
home, stub_bin = _broken_uv_machine(tmp_path)
mine = _tool_venv(home, "graphifyy", "bin/python", ok=True)
decoy = stub_bin / "fakepy"
decoy.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8", newline="\n")
decoy.chmod(0o755)
launcher = stub_bin / "graphify"
launcher.write_text(
f"{decoy}\nnot a script — the first line just names a python\n",
encoding="utf-8", newline="\n",
)
launcher.chmod(0o755)
res = _detect_run(tmp_path, home, stub_bin)
assert res.returncode == 0, res.stderr
assert f"RESOLVED={mine}" in res.stdout, res.stdout + res.stderr
assert "fakepy" not in res.stdout
def test_uv_tool_probe_present_in_emitted_detect():
"""Static companion to the runtime tests above (same convention as the
NUL-safe read): the emitted chain must scan uv tool envs and honor
UV_TOOL_DIR."""
from graphify.hooks import _PYTHON_DETECT
assert "UV_TOOL_DIR" in _PYTHON_DETECT
assert '"$HOME/.local/share/uv/tools"' in _PYTHON_DETECT
assert '"$HOME/AppData/Roaming/uv/tools"' in _PYTHON_DETECT
def _extract_case_pattern(marker: str) -> str:
"""Pull the `*[!...]*` glob portion of a real case arm out of _PYTHON_DETECT
by a unique anchor, so tests run against the emitted text, not a copy."""