Files
graphify/tests/test_hooks.py
Safi 6a549e42d5 fix: four bugs — affected direction, hook root, glob fish/zsh, manifest drift (#1174 #1173 #1172 #1163)
#1174: affected.py load_graph now forces directed=True before
node_link_graph, matching the identical fix in serve.py and __main__.py.
Undirected graphs (directed:false in graph.json) were causing in_edges
to fall back to a direction-blind scan, missing true callers and
reporting false positives. Regression test added.

#1173: post-commit and post-checkout hook bodies now read
graphify-out/.graphify_root before calling _rebuild_code, falling back
to Path('.') if absent. A scoped build (graphify src/) no longer gets
silently expanded to the full repo on the next commit. Tests added.

#1172: Step 9 cleanup split into rm -f for fixed files and
find -maxdepth 1 -delete for the chunk glob. Under fish/zsh an
unmatched glob aborts the entire rm -f line, leaving temp files on disk.
Fixed in the three skillgen source fragments and regenerated.

#1163: detect_incremental type guard on stored mtime — if the manifest
contains a dict-valued mtime (schema drift from older versions), coerce
to None rather than propagating a non-numeric into comparisons.
Regression test added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 12:40:44 +01:00

336 lines
12 KiB
Python

"""Tests for hooks.py - git hook install/uninstall."""
import os
import subprocess
from types import SimpleNamespace
from pathlib import Path
import pytest
from graphify.hooks import install, uninstall, status, _hooks_dir, _HOOK_MARKER, _CHECKOUT_MARKER
def _make_git_repo(tmp_path: Path) -> Path:
subprocess.run(["git", "init", str(tmp_path)], check=True, capture_output=True)
return tmp_path
def test_install_creates_hook(tmp_path):
repo = _make_git_repo(tmp_path)
result = install(repo)
hook = repo / ".git" / "hooks" / "post-commit"
assert hook.exists()
assert _HOOK_MARKER in hook.read_text()
assert "installed" in result
def test_install_is_executable(tmp_path):
repo = _make_git_repo(tmp_path)
install(repo)
hook = repo / ".git" / "hooks" / "post-commit"
if os.name == "nt":
assert hook.read_text(encoding="utf-8").startswith("#!/bin/sh\n")
else:
assert hook.stat().st_mode & 0o111 # executable bit set
def test_install_idempotent(tmp_path):
repo = _make_git_repo(tmp_path)
install(repo)
result = install(repo)
assert "already installed" in result
# marker appears only once
hook = repo / ".git" / "hooks" / "post-commit"
assert hook.read_text().count(_HOOK_MARKER) == 1
def test_install_appends_to_existing_hook(tmp_path):
repo = _make_git_repo(tmp_path)
hook = repo / ".git" / "hooks" / "post-commit"
hook.write_text("#!/bin/bash\necho existing\n")
hook.chmod(0o755)
install(repo)
content = hook.read_text()
assert "existing" in content
assert _HOOK_MARKER in content
def test_uninstall_removes_hook(tmp_path):
repo = _make_git_repo(tmp_path)
install(repo)
result = uninstall(repo)
hook = repo / ".git" / "hooks" / "post-commit"
assert not hook.exists()
assert "removed" in result.lower()
def test_uninstall_no_hook(tmp_path):
repo = _make_git_repo(tmp_path)
result = uninstall(repo)
assert "nothing to remove" in result
def test_status_installed(tmp_path):
repo = _make_git_repo(tmp_path)
install(repo)
result = status(repo)
assert "installed" in result
def test_status_not_installed(tmp_path):
repo = _make_git_repo(tmp_path)
result = status(repo)
assert "not installed" in result
def test_no_git_repo_raises(tmp_path):
with pytest.raises(RuntimeError, match="No git repository"):
install(tmp_path / "not_a_repo")
def test_install_creates_post_checkout_hook(tmp_path):
repo = _make_git_repo(tmp_path)
install(repo)
hook = repo / ".git" / "hooks" / "post-checkout"
assert hook.exists()
assert _CHECKOUT_MARKER in hook.read_text()
def test_install_post_checkout_is_executable(tmp_path):
repo = _make_git_repo(tmp_path)
install(repo)
hook = repo / ".git" / "hooks" / "post-checkout"
if os.name == "nt":
assert hook.read_text(encoding="utf-8").startswith("#!/bin/sh\n")
else:
assert hook.stat().st_mode & 0o111
def test_uninstall_removes_post_checkout_hook(tmp_path):
repo = _make_git_repo(tmp_path)
install(repo)
uninstall(repo)
hook = repo / ".git" / "hooks" / "post-checkout"
assert not hook.exists()
def test_status_shows_both_hooks(tmp_path):
repo = _make_git_repo(tmp_path)
install(repo)
result = status(repo)
assert "post-commit" in result
assert "post-checkout" in result
assert result.count("installed") >= 2
def test_hooks_dir_resolves_relative_git_hooks_path(tmp_path, monkeypatch):
repo = _make_git_repo(tmp_path)
def fake_run(*args, **kwargs):
return SimpleNamespace(returncode=0, stdout=".git/hooks\n")
monkeypatch.setattr("subprocess.run", fake_run)
assert _hooks_dir(repo) == (repo / ".git" / "hooks").resolve()
def test_hooks_dir_rejects_multiline_git_output(tmp_path, monkeypatch):
repo = _make_git_repo(tmp_path)
def fake_run(*args, **kwargs):
return SimpleNamespace(returncode=0, stdout="--path-format=absolute\n.git/hooks\n")
monkeypatch.setattr("subprocess.run", fake_run)
assert _hooks_dir(repo) == repo / ".git" / "hooks"
assert not (repo / "--path-format=absolute\n.git").exists()
def test_hooks_dir_accepts_absolute_git_hooks_path(tmp_path, monkeypatch):
repo = _make_git_repo(tmp_path)
hooks = tmp_path / "actual-hooks"
def fake_run(*args, **kwargs):
return SimpleNamespace(returncode=0, stdout=f"{hooks}\n")
monkeypatch.setattr("subprocess.run", fake_run)
assert _hooks_dir(repo) == hooks.resolve()
def test_hook_skips_head_on_exe():
"""Hook script must skip shebang extraction for .exe binaries (Windows)."""
from graphify.hooks import _PYTHON_DETECT
assert "*.exe) _SHEBANG=" in _PYTHON_DETECT or '*.exe)' in _PYTHON_DETECT
def test_install_embeds_pinned_interpreter(tmp_path):
"""Hook scripts must embed sys.executable so the hook works without the
graphify launcher on PATH (uv tool / pipx isolation, #1127).
When graphify is installed via `uv tool install graphifyy` or `pipx install
graphifyy`, the interpreter lives in an isolated venv and the launcher is in
~/.local/bin. GUI git clients and CI runners often run with a minimal PATH
that omits that directory, so `command -v graphify` fails, the python3/python
fallbacks cannot import graphify (wrong venv), and the hook silently exits 0.
Pinning sys.executable at install time makes the hook work regardless of PATH.
"""
import re, sys
repo = _make_git_repo(tmp_path)
install(repo)
commit_hook = (repo / ".git" / "hooks" / "post-commit").read_text()
checkout_hook = (repo / ".git" / "hooks" / "post-checkout").read_text()
# Compute the sanitized value the same way install() does.
expected = sys.executable if not re.search(r"[^a-zA-Z0-9/_.@:\\-]", sys.executable) else ""
if expected:
assert expected in commit_hook, "sanitized sys.executable missing from post-commit"
assert expected in checkout_hook, "sanitized sys.executable missing from post-checkout"
# The placeholder must be fully substituted -- no __PINNED_PYTHON__ left.
assert "__PINNED_PYTHON__" not in commit_hook, "placeholder not substituted in post-commit"
assert "__PINNED_PYTHON__" not in checkout_hook, "placeholder not substituted in post-checkout"
def test_install_fallback_is_loud_not_silent(tmp_path):
"""The detection fallback must emit a message to stderr rather than bare exit 0.
A silent no-op (the pre-fix behaviour) leaves the user with no indication
that the hook ran but found nothing, making the bug extremely hard to diagnose.
"""
from graphify.hooks import _PYTHON_DETECT
assert "could not locate" in _PYTHON_DETECT, (
"fallback branch must print a diagnostic message; bare 'exit 0' is silent and unhelpful"
)
def test_hook_check_no_additionalContext(tmp_path):
"""graphify hook-check must not emit additionalContext — Codex Desktop rejects it."""
import sys
out = tmp_path / "graphify-out"
out.mkdir()
(out / "graph.json").write_text("{}", encoding="utf-8")
result = subprocess.run(
[sys.executable, "-m", "graphify", "hook-check"],
cwd=tmp_path,
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout == ""
assert result.stderr == ""
# ── #1161: background rebuild must not rely on nohup (missing on Git for Windows) ──
import ast # noqa: E402
import re # noqa: E402
from graphify.hooks import ( # noqa: E402
_HOOK_SCRIPT,
_CHECKOUT_SCRIPT,
_REBUILD_BODY_COMMIT,
_REBUILD_BODY_CHECKOUT,
_detached_launch,
)
_HOOK_SCRIPTS = [("post-commit", _HOOK_SCRIPT), ("post-checkout", _CHECKOUT_SCRIPT)]
@pytest.mark.parametrize("name,script", _HOOK_SCRIPTS)
def test_hooks_do_not_use_nohup(name, script):
"""Git for Windows' bundled shell ships no `nohup`/`setsid`, so the old
`nohup ... &` launch died with 'nohup: command not found' and the rebuild
silently never ran (#1161). The generated hooks must not reference either."""
assert "nohup" not in script, f"{name} still references nohup (#1161)"
assert "setsid" not in script, f"{name} still references setsid (#1161)"
assert "disown" not in script, f"{name} still uses disown (#1161)"
@pytest.mark.parametrize("name,script", _HOOK_SCRIPTS)
def test_hooks_use_cross_platform_detach(name, script):
"""The replacement detaches via Python: start_new_session on POSIX and
DETACHED_PROCESS|CREATE_NEW_PROCESS_GROUP on Windows (#1161)."""
assert "subprocess.Popen" in script
assert "start_new_session=True" in script, f"{name} missing POSIX detach"
assert "0x00000008" in script, f"{name} missing Windows DETACHED_PROCESS flag"
assert "0x00000200" in script, f"{name} missing CREATE_NEW_PROCESS_GROUP flag"
def _launcher_payload(script: str) -> str:
"""Extract the `python -c "<payload>"` the hook hands to GRAPHIFY_PYTHON.
The launcher is the only `-c` invocation whose body begins with
`import os, subprocess, sys` (the interpreter-detection probes in
_PYTHON_DETECT use `-c "import graphify"`)."""
m = re.search(r'-c "(import os, subprocess, sys.*?)"\n', script, re.DOTALL)
assert m, "launcher payload not found"
return m.group(1)
@pytest.mark.parametrize("name,script", _HOOK_SCRIPTS)
def test_launcher_payload_is_shell_quote_safe(name, script):
"""The launcher is carried inside a shell double-quoted `-c "..."` argument,
so it must contain no characters the shell would interpret there: an
unescaped double-quote, $, backtick or backslash would corrupt the hook."""
payload = _launcher_payload(script)
for bad in ('"', "$", "`", "\\"):
assert bad not in payload, f"{name} launcher payload contains unsafe {bad!r}"
@pytest.mark.parametrize("name,script", _HOOK_SCRIPTS)
def test_launcher_and_rebuild_body_are_valid_python(name, script):
"""Both the launcher and the rebuild body it re-executes must parse, so a
quoting slip can't ship a hook that crashes the moment git fires it."""
payload = _launcher_payload(script)
ast.parse(payload) # launcher itself
inner = re.search(r"_src = '''(.*?)'''", payload, re.DOTALL)
assert inner, f"{name}: embedded rebuild body not found"
ast.parse(inner.group(1)) # the detached child's source
def test_rebuild_bodies_are_shell_quote_safe():
"""The shared rebuild bodies are embedded verbatim into the launcher, so they
too must avoid characters unsafe inside a shell double-quoted argument."""
for body in (_REBUILD_BODY_COMMIT, _REBUILD_BODY_CHECKOUT):
for bad in ('"', "$", "`", "\\"):
assert bad not in body
assert "'''" not in body # would terminate the launcher's _src literal
@pytest.mark.parametrize(
"name,body",
[("post-commit", _REBUILD_BODY_COMMIT), ("post-checkout", _REBUILD_BODY_CHECKOUT)],
)
def test_rebuild_bodies_read_graphify_root(name, body):
"""The rebuild must honour the persisted scan root rather than hardcoding the
repo top (#1173). Both bodies read graphify-out/.graphify_root and pass the
recovered root to _rebuild_code instead of the bare Path('.')."""
assert "graphify-out/.graphify_root" in body, f"{name} ignores .graphify_root (#1173)"
# The recovered root is what gets rebuilt, not a hardcoded cwd.
assert "_rebuild_code(_root" in body, f"{name} does not pass the recovered root"
# Quote-safe inside the shell-double-quoted launcher: single quotes only.
assert "read_text(encoding='utf-8')" in body, f"{name} root read is not single-quoted"
def test_rebuild_bodies_with_graphify_root_are_valid_python():
"""The .graphify_root snippet must parse so a quoting slip can't ship a hook
that crashes the moment git fires it (#1173)."""
for body in (_REBUILD_BODY_COMMIT, _REBUILD_BODY_CHECKOUT):
ast.parse(body)
def test_detached_launch_targets_graphify_python():
"""The launcher must run via the resolved $GRAPHIFY_PYTHON, not a bare
`python`, so it uses the same interpreter the detection block selected."""
snippet = _detached_launch(_REBUILD_BODY_COMMIT)
assert snippet.startswith('"$GRAPHIFY_PYTHON" -c "')
assert "nohup" not in snippet
def test_installed_hooks_contain_no_nohup(tmp_path):
"""End-to-end: the files written to .git/hooks must be nohup-free (#1161)."""
repo = _make_git_repo(tmp_path)
install(repo)
for name in ("post-commit", "post-checkout"):
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