fix(hooks): persist viz node limit in project config (#2760)

Adds a committed .graphifyrc (viz_node_limit=<int>) that `graphify hook install`
reads and bakes into the generated post-commit/post-checkout hooks, so a
project-wide viz node limit is shared via version control and survives hook
regeneration instead of needing a hand-edit that the next --force clobbers.
`hook status` reports the value and flags drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hopstreax
2026-08-15 21:55:47 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 65b33b0e62
commit dc895a2f7c
2 changed files with 193 additions and 19 deletions
+88 -19
View File
@@ -283,7 +283,7 @@ _HOOK_SCRIPT = """\
# order is randomized per-process by PYTHONHASHSEED, so community assignments
# churn run-to-run. Pinning it makes graphify-out reproducible.
export PYTHONHASHSEED=0
__VIZ_LIMIT_EXPORT__
# Git for Windows/MSYS hooks can inherit fragile pipe handles from GUI clients
# and agent shells. Keep hook-triggered rebuilds sequential by default there;
# explicit GRAPHIFY_MAX_WORKERS still wins for users who want parallelism.
@@ -338,7 +338,7 @@ _CHECKOUT_SCRIPT = """\
# order is randomized per-process by PYTHONHASHSEED, so community assignments
# churn run-to-run. Pinning it makes graphify-out reproducible.
export PYTHONHASHSEED=0
__VIZ_LIMIT_EXPORT__
# Git for Windows/MSYS hooks can inherit fragile pipe handles from GUI clients
# and agent shells. Keep hook-triggered rebuilds sequential by default there;
# explicit GRAPHIFY_MAX_WORKERS still wins for users who want parallelism.
@@ -382,6 +382,41 @@ echo "[graphify] Branch switched - launching background rebuild (log: $_GRAPHIFY
"""
def _load_graphifyrc(root: Path) -> dict[str, str | int]:
"""Load key/value options from <root>/.graphifyrc if present.
Supported options:
viz_node_limit: integer >= 0 (e.g. viz_node_limit=0)
"""
rc_path = root / ".graphifyrc"
if not rc_path.is_file():
return {}
cfg: dict[str, str | int] = {}
content = rc_path.read_text(encoding="utf-8")
for line_num, raw in enumerate(content.splitlines(), 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
raise ValueError(f"Invalid line {line_num} in {rc_path}: {raw!r} (expected key=value)")
key, val = line.split("=", 1)
key = key.strip()
val = val.strip()
if key == "viz_node_limit":
try:
parsed_val = int(val)
if parsed_val < 0:
raise ValueError("must be a non-negative integer")
cfg["viz_node_limit"] = parsed_val
except ValueError as exc:
raise ValueError(
f"Invalid viz_node_limit in {rc_path} at line {line_num}: {val!r}. "
f"Must be a non-negative integer."
) from exc
return cfg
def _git_root(path: Path) -> Path | None:
"""Walk up to find .git directory."""
current = path.resolve()
@@ -461,12 +496,29 @@ def _hooks_dir(root: Path) -> Path:
return d
def _install_hook(hooks_dir: Path, name: str, script: str, marker: str) -> str:
"""Install a single git hook, appending if an existing hook is present."""
def _install_hook(
hooks_dir: Path,
name: str,
script: str,
marker: str,
marker_end: str = "",
) -> str:
"""Install a single git hook, appending if an existing hook is present, or updating
an existing graphify block in-place."""
hook_path = hooks_dir / name
if hook_path.exists():
content = hook_path.read_text(encoding="utf-8")
if marker in content:
if marker_end and marker_end in content:
start_idx = content.find(marker)
end_idx = content.find(marker_end)
if start_idx != -1 and end_idx != -1 and end_idx >= start_idx:
end_idx += len(marker_end)
new_content = content[:start_idx] + script.rstrip() + content[end_idx:]
if new_content == content:
return f"already installed at {hook_path}"
hook_path.write_text(new_content, encoding="utf-8", newline="\n")
return f"updated existing {name} hook at {hook_path}"
return f"already installed at {hook_path}"
hook_path.write_text(content.rstrip() + "\n\n" + script, encoding="utf-8", newline="\n")
return f"appended to existing {name} hook at {hook_path}"
@@ -666,20 +718,19 @@ def install(path: Path = Path(".")) -> str:
hooks_dir = _user_hooks_dir(_hooks_dir(root))
# Pin the current interpreter so the hook works even when the graphify
# launcher is not on PATH at git-trigger time (uv tool / pipx isolation).
# sys.executable is the Python running this very install command, so it is
# always the correct isolated-venv interpreter. The placeholder is replaced
# in both scripts before writing; the allowlist in _pinned_python() strips
# any characters unsafe in a shell path (empty result -> the pinned probe is
# skipped), and import-verification catches a stale pinned path so it safely
# falls through to the dynamic detection.
pinned = _pinned_python()
hook = _HOOK_SCRIPT.replace("__PINNED_PYTHON__", pinned)
checkout = _CHECKOUT_SCRIPT.replace("__PINNED_PYTHON__", pinned)
cfg = _load_graphifyrc(root)
viz_limit = cfg.get("viz_node_limit")
if viz_limit is not None:
viz_export = f'export GRAPHIFY_VIZ_NODE_LIMIT="{viz_limit}"\n'
else:
viz_export = ""
commit_msg = _install_hook(hooks_dir, "post-commit", hook, _HOOK_MARKER)
checkout_msg = _install_hook(hooks_dir, "post-checkout", checkout, _CHECKOUT_MARKER)
pinned = _pinned_python()
hook = _HOOK_SCRIPT.replace("__PINNED_PYTHON__", pinned).replace("__VIZ_LIMIT_EXPORT__", viz_export)
checkout = _CHECKOUT_SCRIPT.replace("__PINNED_PYTHON__", pinned).replace("__VIZ_LIMIT_EXPORT__", viz_export)
commit_msg = _install_hook(hooks_dir, "post-commit", hook, _HOOK_MARKER, _HOOK_MARKER_END)
checkout_msg = _install_hook(hooks_dir, "post-checkout", checkout, _CHECKOUT_MARKER, _CHECKOUT_MARKER_END)
merge_msg = _register_merge_driver(root)
return f"post-commit: {commit_msg}\npost-checkout: {checkout_msg}\nmerge driver: {merge_msg}"
@@ -705,14 +756,32 @@ def status(path: Path = Path(".")) -> str:
if root is None:
return "Not in a git repository."
hooks_dir = _user_hooks_dir(_hooks_dir(root))
cfg = _load_graphifyrc(root)
cfg_limit = cfg.get("viz_node_limit")
def _check(name: str, marker: str) -> str:
p = hooks_dir / name
if not p.exists():
return "not installed"
return "installed" if marker in p.read_text(encoding="utf-8") else "not installed (hook exists but graphify not found)"
text = p.read_text(encoding="utf-8")
if marker not in text:
return "not installed (hook exists but graphify not found)"
if cfg_limit is not None:
m = re.search(r'export GRAPHIFY_VIZ_NODE_LIMIT="(\d+)"', text)
installed_limit = int(m.group(1)) if m else None
if installed_limit != cfg_limit:
return (
f"installed (out of date: hook has limit "
f"{installed_limit if installed_limit is not None else 'unset'}, "
f".graphifyrc has {cfg_limit})"
)
return "installed"
commit = _check("post-commit", _HOOK_MARKER)
checkout = _check("post-checkout", _CHECKOUT_MARKER)
merge = _merge_driver_status(root)
return f"post-commit: {commit}\npost-checkout: {checkout}\nmerge driver: {merge}"
res = f"post-commit: {commit}\npost-checkout: {checkout}\nmerge driver: {merge}"
if cfg_limit is not None:
res += f"\nviz node limit: {cfg_limit}"
return res
+105
View File
@@ -839,3 +839,108 @@ def test_install_pins_interpreter_path_with_spaces(tmp_path, monkeypatch):
script = (repo / ".git" / "hooks" / name).read_text()
assert f"_PINNED='{exe}'" in script, f"{name} did not pin the spaced interpreter"
assert "_PINNED=''" not in script, f"{name} pinned an empty interpreter (#2166)"
def test_graphifyrc_parsing(tmp_path):
"""Test 1: .graphifyrc parsing for valid and invalid values."""
from graphify.hooks import _load_graphifyrc
rc = tmp_path / ".graphifyrc"
rc.write_text("# comment\nviz_node_limit=0\n", encoding="utf-8")
cfg = _load_graphifyrc(tmp_path)
assert cfg.get("viz_node_limit") == 0
rc.write_text("viz_node_limit=invalid\n", encoding="utf-8")
with pytest.raises(ValueError, match="Invalid viz_node_limit"):
_load_graphifyrc(tmp_path)
def test_no_config_preserves_existing_hook(tmp_path):
"""Test 2: Without .graphifyrc, generated hooks omit GRAPHIFY_VIZ_NODE_LIMIT export."""
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()
assert "GRAPHIFY_VIZ_NODE_LIMIT" not in commit_hook
assert "GRAPHIFY_VIZ_NODE_LIMIT" not in checkout_hook
def test_config_baked_into_generated_hook(tmp_path):
"""Test 3: viz_node_limit from .graphifyrc is baked into both hooks."""
repo = _make_git_repo(tmp_path)
(repo / ".graphifyrc").write_text("viz_node_limit=0\n", encoding="utf-8")
install(repo)
commit_hook = (repo / ".git" / "hooks" / "post-commit").read_text()
checkout_hook = (repo / ".git" / "hooks" / "post-checkout").read_text()
assert 'export GRAPHIFY_VIZ_NODE_LIMIT="0"' in commit_hook
assert 'export GRAPHIFY_VIZ_NODE_LIMIT="0"' in checkout_hook
def test_changing_config_updates_existing_hook(tmp_path):
"""Test 4: Re-running install updates existing Graphify hook block with new config."""
repo = _make_git_repo(tmp_path)
rc = repo / ".graphifyrc"
rc.write_text("viz_node_limit=5000\n", encoding="utf-8")
install(repo)
rc.write_text("viz_node_limit=0\n", encoding="utf-8")
result = install(repo)
assert "updated existing" in result
commit_hook = (repo / ".git" / "hooks" / "post-commit").read_text()
assert 'export GRAPHIFY_VIZ_NODE_LIMIT="0"' in commit_hook
assert 'export GRAPHIFY_VIZ_NODE_LIMIT="5000"' not in commit_hook
assert commit_hook.count("# graphify-hook-start") == 1
def test_user_hook_content_survives_update(tmp_path):
"""Test 5: User hook content outside graphify markers survives hook update."""
repo = _make_git_repo(tmp_path)
hooks_dir = repo / ".git" / "hooks"
hooks_dir.mkdir(parents=True, exist_ok=True)
post_commit = hooks_dir / "post-commit"
post_commit.write_text(
"#!/bin/sh\necho 'user content before'\n"
"# graphify-hook-start\nold block\n# graphify-hook-end\n"
"echo 'user content after'\n",
encoding="utf-8",
)
(repo / ".graphifyrc").write_text("viz_node_limit=0\n", encoding="utf-8")
install(repo)
content = post_commit.read_text()
assert "echo 'user content before'" in content
assert "echo 'user content after'" in content
assert 'export GRAPHIFY_VIZ_NODE_LIMIT="0"' in content
assert "old block" not in content
def test_status_reports_configuration(tmp_path):
"""Test 6: graphify hook status exposes configured viz node limit and detects out-of-date hooks."""
repo = _make_git_repo(tmp_path)
rc = repo / ".graphifyrc"
rc.write_text("viz_node_limit=0\n", encoding="utf-8")
install(repo)
res = status(repo)
assert "viz node limit: 0" in res
assert "(out of date" not in res
rc.write_text("viz_node_limit=100\n", encoding="utf-8")
res_outdated = status(repo)
assert "out of date" in res_outdated
assert "viz node limit: 100" in res_outdated
def test_both_hooks_configured(tmp_path):
"""Test 7: Verify both post-commit and post-checkout hooks receive the setting."""
repo = _make_git_repo(tmp_path)
(repo / ".graphifyrc").write_text("viz_node_limit=42\n", encoding="utf-8")
install(repo)
for name in ("post-commit", "post-checkout"):
hook_text = (repo / ".git" / "hooks" / name).read_text()
assert 'export GRAPHIFY_VIZ_NODE_LIMIT="42"' in hook_text