diff --git a/README.md b/README.md index 3a9f065a5..124cdf12e 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ When the user types `/graphify`, invoke the Skill tool with `skill: "graphify"` /graphify explain "SwinTransformer" /graphify ./raw --watch # auto-sync graph as files change (code: instant, docs: notifies you) + +graphify hook install # post-commit git hook - rebuilds graph on every commit automatically /graphify ./raw --wiki # build agent-crawlable wiki (index.md + article per community) /graphify ./raw --svg # export graph.svg /graphify ./raw --graphml # export graph.graphml (Gephi, yEd) @@ -98,6 +100,8 @@ Works with any mix of file types: **Auto-sync** (`--watch`) - run in a background terminal and the graph updates itself as your codebase changes. Code file saves trigger an instant rebuild (AST only, no LLM). Doc/image changes notify you to run `--update` for the LLM re-pass. Useful for agentic workflows where multiple agents are writing code in parallel - the graph stays current between waves automatically. +**Git commit hook** (`graphify hook install`) - installs a post-commit hook that rebuilds the graph after every commit. No background process needed. Triggers once per commit, works with any editor, safe to add alongside existing hooks. + **Wiki** (`--wiki`) - Wikipedia-style markdown articles per community and god node, with an `index.md` entry point. Point any agent at `index.md` and it can navigate the knowledge base by reading files instead of parsing JSON. Every edge is tagged `EXTRACTED`, `INFERRED`, or `AMBIGUOUS` - you always know what was found vs guessed. diff --git a/graphify/__main__.py b/graphify/__main__.py index f59b12771..d2fa063ad 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -59,12 +59,27 @@ def main() -> None: print("Commands:") print(" install copy skill to ~/.claude/skills/ and register in CLAUDE.md") print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach") + print(" hook install install post-commit git hook (auto-rebuilds graph on commit)") + print(" hook uninstall remove post-commit git hook") + print(" hook status check if hook is installed") print() return cmd = sys.argv[1] if cmd == "install": install() + elif cmd == "hook": + from graphify.hooks import install as hook_install, uninstall as hook_uninstall, status as hook_status + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + print(hook_install(Path("."))) + elif subcmd == "uninstall": + print(hook_uninstall(Path("."))) + elif subcmd == "status": + print(hook_status(Path("."))) + else: + print("Usage: graphify hook [install|uninstall|status]", file=sys.stderr) + sys.exit(1) elif cmd == "benchmark": from graphify.benchmark import run_benchmark, print_benchmark graph_path = sys.argv[2] if len(sys.argv) > 2 else "graphify-out/graph.json" diff --git a/graphify/hooks.py b/graphify/hooks.py new file mode 100644 index 000000000..ae01a23f6 --- /dev/null +++ b/graphify/hooks.py @@ -0,0 +1,118 @@ +# git hook integration - install/uninstall graphify post-commit hook +from __future__ import annotations +from pathlib import Path + +_HOOK_MARKER = "# graphify-hook" + +_HOOK_SCRIPT = """\ +#!/bin/bash +# graphify-hook +# Auto-rebuilds the knowledge graph after each commit (code files only, no LLM needed). +# Installed by: graphify hook install + +CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null) +if [ -z "$CHANGED" ]; then + exit 0 +fi + +export GRAPHIFY_CHANGED="$CHANGED" +python3 -c " +import os, sys +from pathlib import Path + +CODE_EXTS = { + '.py', '.ts', '.js', '.go', '.rs', '.java', '.cpp', '.c', '.rb', '.swift', + '.kt', '.cs', '.scala', '.php', '.cc', '.cxx', '.hpp', '.h', '.kts', +} + +changed_raw = os.environ.get('GRAPHIFY_CHANGED', '') +changed = [Path(f.strip()) for f in changed_raw.strip().splitlines() if f.strip()] +code_changed = [f for f in changed if f.suffix.lower() in CODE_EXTS and f.exists()] + +if not code_changed: + sys.exit(0) + +print(f'[graphify hook] {len(code_changed)} code file(s) changed - rebuilding graph...') + +try: + from graphify.watch import _rebuild_code + _rebuild_code(Path('.')) +except Exception as exc: + print(f'[graphify hook] Rebuild failed: {exc}') + sys.exit(0) +" +""" + + +def _git_root(path: Path) -> Path | None: + """Walk up to find .git directory.""" + current = path.resolve() + for parent in [current, *current.parents]: + if (parent / ".git").exists(): + return parent + return None + + +def install(path: Path = Path(".")) -> str: + """Install graphify post-commit hook in the nearest git repo. + + Returns a message describing what was done. + """ + root = _git_root(path) + if root is None: + raise RuntimeError(f"No git repository found at or above {path.resolve()}") + + hooks_dir = root / ".git" / "hooks" + hooks_dir.mkdir(exist_ok=True) + hook_path = hooks_dir / "post-commit" + + if hook_path.exists(): + content = hook_path.read_text() + if _HOOK_MARKER in content: + return f"graphify hook already installed at {hook_path}" + # Append to existing hook + hook_path.write_text(content.rstrip() + "\n\n" + _HOOK_SCRIPT) + return f"graphify hook appended to existing post-commit hook at {hook_path}" + + hook_path.write_text(_HOOK_SCRIPT) + hook_path.chmod(0o755) + return f"graphify hook installed at {hook_path}" + + +def uninstall(path: Path = Path(".")) -> str: + """Remove graphify post-commit hook.""" + root = _git_root(path) + if root is None: + raise RuntimeError(f"No git repository found at or above {path.resolve()}") + + hook_path = root / ".git" / "hooks" / "post-commit" + if not hook_path.exists(): + return "No post-commit hook found - nothing to remove." + + content = hook_path.read_text() + if _HOOK_MARKER not in content: + return "graphify hook not found in post-commit - nothing to remove." + + # Strip everything from our marker onwards + before = content.split(_HOOK_MARKER)[0].rstrip() + # 'before' is empty or just a shebang line if the whole file was ours + non_empty = [l for l in before.splitlines() if l.strip() and not l.startswith("#!")] + if not non_empty: + hook_path.unlink() + return f"Removed post-commit hook at {hook_path}" + else: + hook_path.write_text(before + "\n") + return f"graphify hook removed from {hook_path} (other hook content preserved)" + + +def status(path: Path = Path(".")) -> str: + """Check if graphify hook is installed.""" + root = _git_root(path) + if root is None: + return "Not in a git repository." + hook_path = root / ".git" / "hooks" / "post-commit" + if not hook_path.exists(): + return "graphify hook: not installed" + if _HOOK_MARKER in hook_path.read_text(): + return f"graphify hook: installed at {hook_path}" + return "graphify hook: not installed (post-commit exists but graphify hook not found)" diff --git a/graphify/skill.md b/graphify/skill.md index 9be7b435c..1b6779522 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -1119,6 +1119,22 @@ For agentic workflows: run `--watch` in a background terminal. Code changes from --- +## For git commit hook + +Install a post-commit hook that auto-rebuilds the graph after every commit. No background process needed - triggers once per commit, works with any editor. + +```bash +graphify hook install # install +graphify hook uninstall # remove +graphify hook status # check +``` + +After every `git commit`, the hook detects which code files changed (via `git diff HEAD~1`), re-runs AST extraction on those files, and rebuilds `graph.json` and `GRAPH_REPORT.md`. Doc/image changes are ignored by the hook - run `/graphify --update` manually for those. + +If a post-commit hook already exists, graphify appends to it rather than replacing it. + +--- + ## Honesty Rules - Never invent an edge. If unsure, use AMBIGUOUS. diff --git a/pyproject.toml b/pyproject.toml index ccefd7dda..ea9fa161a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.1.9" +version = "0.1.10" description = "Claude Code skill - turn any folder of code, docs, papers, images, or tweets into a queryable knowledge graph" readme = "README.md" license = { text = "MIT" } diff --git a/skills/graphify/skill.md b/skills/graphify/skill.md index 9be7b435c..1b6779522 100644 --- a/skills/graphify/skill.md +++ b/skills/graphify/skill.md @@ -1119,6 +1119,22 @@ For agentic workflows: run `--watch` in a background terminal. Code changes from --- +## For git commit hook + +Install a post-commit hook that auto-rebuilds the graph after every commit. No background process needed - triggers once per commit, works with any editor. + +```bash +graphify hook install # install +graphify hook uninstall # remove +graphify hook status # check +``` + +After every `git commit`, the hook detects which code files changed (via `git diff HEAD~1`), re-runs AST extraction on those files, and rebuilds `graph.json` and `GRAPH_REPORT.md`. Doc/image changes are ignored by the hook - run `/graphify --update` manually for those. + +If a post-commit hook already exists, graphify appends to it rather than replacing it. + +--- + ## Honesty Rules - Never invent an edge. If unsure, use AMBIGUOUS. diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 000000000..088c188f3 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,80 @@ +"""Tests for hooks.py - git hook install/uninstall.""" +import subprocess +from pathlib import Path +import pytest +from graphify.hooks import install, uninstall, status, _HOOK_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" + 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 + + +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")