add git commit hook - auto-rebuilds graph after every commit

This commit is contained in:
Safi
2026-04-05 22:40:34 +01:00
parent 2a8b6c16ad
commit 0a31c0862b
7 changed files with 250 additions and 1 deletions
+4
View File
@@ -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.
+15
View File
@@ -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"
+118
View File
@@ -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)"
+16
View File
@@ -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.
+1 -1
View File
@@ -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" }
+16
View File
@@ -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.
+80
View File
@@ -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")