mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 19:07:10 +00:00
0fdfdedcaa
Per adversarial review of the #1127 fix: - Apply the filesystem-path allowlist to sys.executable before embedding it in the generated hook script. Paths with metacharacters outside [a-zA-Z0-9/_.@:\-] (spaces, dollar signs, backticks, semicolons) are replaced with an empty string so the pinned probe is safely skipped rather than injecting shell commands that execute on every git commit. - Change _PINNED='...' to single-quote assignment so no shell expansion can occur even if a character slipped through the allowlist (belt and suspenders). - Quote $GRAPHIFY_PYTHON in both nohup exec lines so paths with spaces work (common in Windows C:\Program Files\... installs). - Update test to assert the sanitized value, not raw sys.executable, so the test stays correct after any future sanitization changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
219 lines
7.2 KiB
Python
219 lines
7.2 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 == ""
|