Skip to content

Commit 7207687

Browse files
committed
feat: Add git post-commit hook to incrementally update the Neo4j graph and wiki for changed files, supported by new diffing and file parsing utilities.
1 parent 83c4767 commit 7207687

4 files changed

Lines changed: 701 additions & 0 deletions

File tree

packages/cli/commands/hooks.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"""
2+
`secrin post-commit` and `secrin install-hooks` commands.
3+
4+
post-commit:
5+
Detects files changed in HEAD commit, updates Neo4j incrementally,
6+
re-summarizes + re-embeds new nodes, then regenerates only the
7+
affected wiki pages (module pages + architecture.md).
8+
9+
install-hooks:
10+
Writes a git post-commit hook script to .git/hooks/post-commit
11+
and makes it executable.
12+
"""
13+
from __future__ import annotations
14+
15+
import os
16+
import stat
17+
import subprocess
18+
from pathlib import Path
19+
from typing import Any
20+
21+
import typer
22+
from rich.console import Console
23+
from rich.table import Table
24+
25+
from packages.cli.graph.neo4j_client import NeoClient
26+
from packages.cli.graph.diff import get_changed_files, update_changed_files
27+
from packages.cli.agents.wiki_writer import (
28+
_fetch_module,
29+
_render_module_md,
30+
_render_architecture_md,
31+
_render_readme,
32+
_slug,
33+
_Q_ALL_MODULES,
34+
_Q_ALL_DOMAINS,
35+
_Q_IMPORT_GRAPH,
36+
)
37+
from packages.config.settings import Settings
38+
39+
console = Console()
40+
41+
# ---------------------------------------------------------------------------
42+
# Hook script content
43+
# ---------------------------------------------------------------------------
44+
45+
_HOOK_SCRIPT = """\
46+
#!/bin/sh
47+
# Secrin post-commit hook
48+
# Auto-updates the Neo4j graph and wiki for files changed in this commit.
49+
# Installed by: secrin install-hooks
50+
# Remove to disable: rm .git/hooks/post-commit
51+
52+
cd "$(git rev-parse --show-toplevel)"
53+
secrin post-commit 2>&1 || true
54+
exit 0
55+
"""
56+
57+
58+
# ---------------------------------------------------------------------------
59+
# post-commit command
60+
# ---------------------------------------------------------------------------
61+
62+
def post_commit(
63+
output: str = typer.Option(
64+
"docs/wiki",
65+
"--output", "-o",
66+
help="Wiki output directory (must already exist)",
67+
),
68+
repo: str = typer.Option(
69+
".",
70+
"--repo",
71+
help="Path to the git repo root (defaults to cwd)",
72+
),
73+
skip_wiki: bool = typer.Option(
74+
False,
75+
"--skip-wiki",
76+
help="Update graph only; skip wiki regeneration",
77+
),
78+
) -> None:
79+
"""
80+
Incrementally update the graph + wiki for files changed in HEAD commit.
81+
82+
Detects changed files via `git diff-tree HEAD`, deletes stale Neo4j
83+
nodes, re-parses, re-summarizes, re-embeds, then regenerates only
84+
the affected module pages and architecture.md.
85+
86+
This command is designed to be called from a git post-commit hook.
87+
"""
88+
repo_path = Path(repo).resolve()
89+
output_dir = Path(output)
90+
settings = Settings()
91+
92+
# ── 1. Get changed files ──────────────────────────────────────────────────
93+
to_update, to_delete = get_changed_files(repo_path)
94+
95+
if not to_update and not to_delete:
96+
typer.echo("[secrin] No supported files changed — nothing to do.")
97+
return
98+
99+
typer.echo(
100+
f"\n[secrin] Post-commit: "
101+
f"{len(to_update)} updated, {len(to_delete)} deleted"
102+
)
103+
104+
# ── 2. Connect to Neo4j ───────────────────────────────────────────────────
105+
client = NeoClient()
106+
try:
107+
client.connect()
108+
except ConnectionError as exc:
109+
typer.echo(f"[secrin] Neo4j unavailable — skipping update: {exc}")
110+
return # don't raise; hooks must not block commits
111+
112+
with client:
113+
count = client.run("MATCH (n) RETURN count(n) AS c")[0]["c"]
114+
if count == 0:
115+
typer.echo("[secrin] Neo4j is empty — run `secrin graph build` first.")
116+
return
117+
118+
def _cb(step: str, detail: str) -> None:
119+
typer.echo(f"[secrin] {detail}")
120+
121+
# ── 3. Incremental graph update ───────────────────────────────────────
122+
try:
123+
result = update_changed_files(
124+
repo_path=repo_path,
125+
to_update=to_update,
126+
to_delete=to_delete,
127+
client=client,
128+
settings=settings,
129+
progress_cb=_cb,
130+
)
131+
except Exception as exc:
132+
typer.echo(f"[secrin] Graph update failed: {exc}")
133+
return
134+
135+
affected = result.get("affected_modules", [])
136+
typer.echo(
137+
f"[secrin] Graph updated — "
138+
f"summarized {result['summarized']}, "
139+
f"embedded {result['embedded']}, "
140+
f"modules affected: {', '.join(affected) or 'none'}"
141+
)
142+
143+
# ── 4. Regenerate wiki pages ──────────────────────────────────────────
144+
if skip_wiki or not output_dir.exists():
145+
if not skip_wiki:
146+
typer.echo(
147+
f"[secrin] Wiki dir '{output_dir}' not found — "
148+
"run `secrin generate` to create it."
149+
)
150+
return
151+
152+
typer.echo(f"[secrin] Regenerating affected wiki pages → {output_dir}/")
153+
154+
# Module pages for affected modules
155+
modules_dir = output_dir / "modules"
156+
for mn in affected:
157+
try:
158+
data = _fetch_module(client, mn)
159+
summary = (
160+
f"*The `{mn}` module — updated {_GEN_DATE_HOOK}.*"
161+
)
162+
md = _render_module_md(data, summary)
163+
page = modules_dir / f"{_slug(mn)}.md"
164+
if page.parent.exists():
165+
page.write_text(md, encoding="utf-8")
166+
typer.echo(f"[secrin] updated: modules/{_slug(mn)}.md")
167+
except Exception as exc:
168+
typer.echo(f"[secrin] [warn] module {mn}: {exc}")
169+
170+
# architecture.md — regenerate without LLM (fast)
171+
try:
172+
all_modules = [
173+
_fetch_module(client, row["name"])
174+
for row in client.run(_Q_ALL_MODULES)
175+
]
176+
domains = client.run(_Q_ALL_DOMAINS)
177+
import_graph = client.run(_Q_IMPORT_GRAPH)
178+
overview = (
179+
"*(Architecture overview — run `secrin generate` to refresh with LLM.)*"
180+
)
181+
arch_md = _render_architecture_md(
182+
all_modules, domains, import_graph, overview
183+
)
184+
(output_dir / "architecture.md").write_text(arch_md, encoding="utf-8")
185+
typer.echo("[secrin] updated: architecture.md")
186+
187+
# README gets a fresh module/domain listing too
188+
readme_md = _render_readme(all_modules, domains)
189+
(output_dir / "README.md").write_text(readme_md, encoding="utf-8")
190+
typer.echo("[secrin] updated: README.md")
191+
except Exception as exc:
192+
typer.echo(f"[secrin] [warn] architecture.md: {exc}")
193+
194+
typer.echo("[secrin] Done.\n")
195+
196+
197+
# lazy date so the module can be imported without side effects
198+
def _GEN_DATE_HOOK() -> str:
199+
from datetime import datetime, timezone
200+
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
201+
202+
203+
# ---------------------------------------------------------------------------
204+
# install-hooks command
205+
# ---------------------------------------------------------------------------
206+
207+
def install_hooks(
208+
force: bool = typer.Option(
209+
False,
210+
"--force", "-f",
211+
help="Overwrite existing post-commit hook without asking",
212+
),
213+
) -> None:
214+
"""
215+
Install a git post-commit hook that runs `secrin post-commit` automatically.
216+
217+
Writes .git/hooks/post-commit and makes it executable.
218+
Requires `secrin` to be available on PATH when commits are made
219+
(e.g. activate the project virtualenv or use `poetry shell`).
220+
"""
221+
# Find .git directory
222+
git_result = subprocess.run(
223+
["git", "rev-parse", "--git-dir"],
224+
capture_output=True, text=True,
225+
)
226+
if git_result.returncode != 0:
227+
typer.echo(
228+
"[error] Not inside a git repository "
229+
"(or git is not installed).",
230+
err=True,
231+
)
232+
raise typer.Exit(code=1)
233+
234+
hooks_dir = Path(git_result.stdout.strip()) / "hooks"
235+
hook_path = hooks_dir / "post-commit"
236+
hooks_dir.mkdir(exist_ok=True)
237+
238+
# Check for existing hook
239+
if hook_path.exists() and not force:
240+
typer.echo(
241+
f"A post-commit hook already exists at {hook_path}.\n"
242+
"Use --force to overwrite it."
243+
)
244+
raise typer.Exit(code=1)
245+
246+
# Write the hook script
247+
hook_path.write_text(_HOOK_SCRIPT, encoding="utf-8")
248+
249+
# Make it executable (chmod +x)
250+
current_mode = hook_path.stat().st_mode
251+
hook_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
252+
253+
typer.echo(f"\nHook installed: {hook_path}")
254+
typer.echo("\nAfter every `git commit`, Secrin will automatically:")
255+
typer.echo(" 1. Update Neo4j for changed files")
256+
typer.echo(" 2. Re-summarize + re-embed new nodes")
257+
typer.echo(" 3. Regenerate affected wiki pages in docs/wiki/")
258+
typer.echo(
259+
"\nNote: `secrin` must be on PATH when committing.\n"
260+
"Tip: activate your virtualenv or use `poetry shell` before committing.\n"
261+
"\nTo uninstall: rm .git/hooks/post-commit\n"
262+
)

packages/cli/core/parser.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,41 @@ def _parse_js_ts(rel_path: str, src: bytes, language: Language, lang_name: str)
372372
# Public API
373373
# ---------------------------------------------------------------------------
374374

375+
def parse_files(repo_path: Path, relative_paths: list[str]) -> list[ParsedFile]:
376+
"""
377+
Parse a specific set of files by their relative paths (no progress output).
378+
379+
Useful for incremental updates where only changed files need re-parsing.
380+
Files whose extension is not in SUPPORTED_EXTENSIONS are silently skipped.
381+
"""
382+
results: list[ParsedFile] = []
383+
for rel_path in relative_paths:
384+
abs_path = repo_path / rel_path
385+
suffix = abs_path.suffix
386+
if suffix not in SUPPORTED_EXTENSIONS:
387+
continue
388+
lang = SUPPORTED_EXTENSIONS[suffix]
389+
try:
390+
src = abs_path.read_bytes()
391+
except OSError:
392+
continue
393+
try:
394+
if lang == "python":
395+
pf = _parse_python(rel_path, src)
396+
elif lang == "typescript":
397+
pf = _parse_js_ts(rel_path, src, _TS_LANGUAGE, "typescript")
398+
elif lang == "tsx":
399+
pf = _parse_js_ts(rel_path, src, _TSX_LANGUAGE, "tsx")
400+
elif lang in ("javascript", "jsx"):
401+
pf = _parse_js_ts(rel_path, src, _JS_LANGUAGE, lang)
402+
else:
403+
continue
404+
except Exception:
405+
continue
406+
results.append(pf)
407+
return results
408+
409+
375410
def parse_repo(repo_path: Path) -> list[ParsedFile]:
376411
"""
377412
Walk repo_path, parse every supported source file with tree-sitter.

0 commit comments

Comments
 (0)