diff --git a/graphify/__main__.py b/graphify/__main__.py index 638df197c..10bb2799f 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -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)") @@ -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: diff --git a/graphify/hooks.py b/graphify/hooks.py index eebd92ae0..07bdd29e0 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -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) @@ -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. @@ -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}" diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 9d1260c3f..c00c2b384 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -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