Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1067,6 +1067,7 @@ def main() -> None:
print(" --label NAME project label in header")
print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach")
print(" hook install install post-commit/post-checkout git hooks (all platforms)")
print(" --with-wiki also rebuild the wiki on each commit (#549)")
print(" hook uninstall remove git hooks")
print(" hook status check if git hooks are installed")
print(" gemini install write GEMINI.md section + BeforeTool hook (Gemini CLI)")
Expand Down Expand Up @@ -1232,13 +1233,14 @@ def main() -> None:
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(".")))
with_wiki = "--with-wiki" in sys.argv[3:]
print(hook_install(Path("."), with_wiki=with_wiki))
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)
print("Usage: graphify hook [install [--with-wiki] | uninstall | status]", file=sys.stderr)
sys.exit(1)
elif cmd == "query":
if len(sys.argv) < 3:
Expand Down
56 changes: 51 additions & 5 deletions graphify/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,43 @@
fi
"""

_HOOK_SCRIPT = """\
_WIKI_REBUILD_PYTHON = """
# Optional wiki rebuild (#549 - 'graphify hook install --with-wiki')
try:
import json as _json
out_dir = Path('graphify-out')
graph_json = out_dir / 'graph.json'
if graph_json.exists():
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes
from graphify.wiki import to_wiki
graph_data = _json.loads(graph_json.read_text(encoding='utf-8'))
G2 = build_from_json(graph_data)
comms = cluster(G2)
cohesion = score_all(G2, comms)
gods = god_nodes(G2)
labels_path = out_dir / '.graphify_labels.json'
labels = {}
if labels_path.exists():
labels = {int(k): v for k, v in _json.loads(labels_path.read_text(encoding='utf-8')).items()}
n_articles = to_wiki(G2, comms, str(out_dir / 'wiki'),
community_labels=labels or None,
cohesion=cohesion, god_nodes_data=gods)
print(f'[graphify hook] Wiki rebuilt: {n_articles} article(s) in {out_dir}/wiki/')
except Exception as _wiki_exc:
print(f'[graphify hook] Wiki rebuild skipped: {_wiki_exc}')
"""


def _build_hook_script(*, with_wiki: bool = False) -> str:
"""Build the post-commit hook body. Optionally inject a wiki rebuild step
after the AST rebuild (#549)."""
wiki_block = _WIKI_REBUILD_PYTHON if with_wiki else ""
return """\
# graphify-hook-start
# Auto-rebuilds the knowledge graph after each commit (code files only, no LLM needed).
# Installed by: graphify hook install
# Installed by: graphify hook install""" + (" --with-wiki" if with_wiki else "") + """

# Skip during rebase/merge/cherry-pick to avoid blocking --continue with unstaged changes
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
Expand Down Expand Up @@ -87,12 +120,17 @@
except Exception as exc:
print(f'[graphify hook] Rebuild failed: {exc}')
sys.exit(1)
""" + wiki_block + """
" > "$_GRAPHIFY_LOG" 2>&1 < /dev/null &
disown 2>/dev/null || true
# graphify-hook-end
"""


# Backwards-compat: legacy callers and tests may still reference _HOOK_SCRIPT
_HOOK_SCRIPT = _build_hook_script(with_wiki=False)


_CHECKOUT_SCRIPT = """\
# graphify-checkout-hook-start
# Auto-rebuilds the knowledge graph (code only) when switching branches.
Expand Down Expand Up @@ -205,15 +243,23 @@ def _uninstall_hook(hooks_dir: Path, name: str, marker: str, marker_end: str) ->
return f"graphify removed from {name} at {hook_path} (other hook content preserved)"


def install(path: Path = Path(".")) -> str:
"""Install graphify post-commit and post-checkout hooks in the nearest git repo."""
def install(path: Path = Path("."), *, with_wiki: bool = False) -> str:
"""Install graphify post-commit and post-checkout hooks in the nearest git repo.

Args:
with_wiki: If True, the post-commit hook also rebuilds the wiki
(graphify-out/wiki/) after the AST rebuild. Useful for users who
keep agent-crawlable docs in sync with code (#549). No LLM cost —
pure clustering + community-article generation.
"""
root = _git_root(path)
if root is None:
raise RuntimeError(f"No git repository found at or above {path.resolve()}")

hooks_dir = _hooks_dir(root)

commit_msg = _install_hook(hooks_dir, "post-commit", _HOOK_SCRIPT, _HOOK_MARKER)
commit_script = _build_hook_script(with_wiki=with_wiki)
commit_msg = _install_hook(hooks_dir, "post-commit", commit_script, _HOOK_MARKER)
checkout_msg = _install_hook(hooks_dir, "post-checkout", _CHECKOUT_SCRIPT, _CHECKOUT_MARKER)

return f"post-commit: {commit_msg}\npost-checkout: {checkout_msg}"
Expand Down
44 changes: 44 additions & 0 deletions tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,47 @@ def test_hook_check_no_additionalContext(tmp_path):
assert result.returncode == 0
assert result.stdout == ""
assert result.stderr == ""


def test_install_default_omits_wiki_rebuild(tmp_path):
"""Without --with-wiki, the post-commit hook must not import or call to_wiki (#549)."""
repo = _make_git_repo(tmp_path)
install(repo)
hook_text = (repo / ".git" / "hooks" / "post-commit").read_text(encoding="utf-8")
assert "from graphify.wiki import to_wiki" not in hook_text
assert "Wiki rebuilt" not in hook_text


def test_install_with_wiki_injects_wiki_rebuild(tmp_path):
"""With with_wiki=True, the post-commit hook contains the wiki rebuild block (#549)."""
repo = _make_git_repo(tmp_path)
install(repo, with_wiki=True)
hook_text = (repo / ".git" / "hooks" / "post-commit").read_text(encoding="utf-8")
assert "from graphify.wiki import to_wiki" in hook_text
assert "Wiki rebuilt" in hook_text
# The hook should still rebuild the AST graph first - wiki is additive
assert "from graphify.watch import _rebuild_code" in hook_text
# And the install header should record the flag for later debugging
assert "--with-wiki" in hook_text


def test_install_with_wiki_then_uninstall_clean(tmp_path):
"""A --with-wiki install followed by uninstall must leave no graphify section behind."""
repo = _make_git_repo(tmp_path)
install(repo, with_wiki=True)
uninstall(repo)
hook = repo / ".git" / "hooks" / "post-commit"
if hook.exists():
text = hook.read_text(encoding="utf-8")
assert _HOOK_MARKER not in text
assert "from graphify.wiki import to_wiki" not in text


def test_build_hook_script_wiki_block_only_when_requested():
"""_build_hook_script(with_wiki=False) must produce identical script to legacy _HOOK_SCRIPT."""
from graphify.hooks import _build_hook_script, _HOOK_SCRIPT
assert _build_hook_script(with_wiki=False) == _HOOK_SCRIPT
# And with_wiki=True must be a strict superset (wiki block added)
with_wiki = _build_hook_script(with_wiki=True)
assert len(with_wiki) > len(_HOOK_SCRIPT)
assert "to_wiki" in with_wiki and "to_wiki" not in _HOOK_SCRIPT