diff --git a/graphify/hooks.py b/graphify/hooks.py index 84d3e9b8..47c34530 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -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 /.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 diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 103f800b..4bcf1cd8 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -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