mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-26 16:26:42 +00:00
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:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
65b33b0e62
commit
dc895a2f7c
+88
-19
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user