diff --git a/copier.yml b/copier.yml index 2d1d192..46992d9 100644 --- a/copier.yml +++ b/copier.yml @@ -10,6 +10,8 @@ _exclude: _tasks: - "git init" + # Best effort: offline or without uv the project must still be generated. + - "sh -c 'uv lock || echo \"uv lock failed; run it later to enable the code graph\"'" - "git checkout -b main" # Questions diff --git a/template/.gitignore b/template/.gitignore index 158760b..e029ff6 100644 --- a/template/.gitignore +++ b/template/.gitignore @@ -153,6 +153,9 @@ data/** poetry.lock .isort.cfg +# graphify code graph (rebuilt by git hooks) +graphify-out/ + # Claude code artifacts **/.claude/settings.local.json diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index ba8bfcb..884b0e2 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -1,4 +1,6 @@ -minimum_pre_commit_version: "3.0" +minimum_pre_commit_version: "3.2.0" +default_stages: [pre-commit] +default_install_hook_types: [pre-commit, post-commit, post-checkout, post-merge, post-rewrite] repos: - repo: https://github.com/charliermarsh/ruff-pre-commit # Ruff version. @@ -29,6 +31,13 @@ repos: language: system pass_filenames: false {%- endif %} + - id: graphify + name: graphify update + entry: sh scripts/graphify-update.sh + language: system + stages: [post-commit, post-checkout, post-merge, post-rewrite] + always_run: true + pass_filenames: false - repo: https://github.com/gitleaks/gitleaks rev: v8.30.1 hooks: diff --git a/template/AGENTS.md.jinja b/template/AGENTS.md.jinja index f5efe4e..eec4add 100644 --- a/template/AGENTS.md.jinja +++ b/template/AGENTS.md.jinja @@ -71,6 +71,21 @@ those blocks once you start modelling your own domain. - `make lint` runs ruff and the `ty` type checker. {%- endif %} +## Code Graph + +The graph in `graphify-out/` is rebuilt by git hooks after commit, checkout, +merge, and rebase. It is AST only and can lag a moment behind. Each worktree +has its own graph, and it is not committed. + +- Query it before broad searches: `graphify query ""`, + `graphify explain ""`, `graphify path "A" "B"`, and + `graphify-out/GRAPH_REPORT.md` for an overview. +- `uv run pre-commit install --allow-missing-config` installs all hook types, + including these. The flag keeps checkouts of old commits without a config + working. +- If the graph is missing, run `sh scripts/graphify-update.sh`. Do not call + `graphify update .` directly: it overwrites the global graphify skill files. + ## Documentation and Comment Style Brevity is not tidiness — it is what gets the text read. A docstring, comment, diff --git a/template/README.md.jinja b/template/README.md.jinja index 2eaff08..9c82e78 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -32,9 +32,12 @@ pip install {{ project_slug | replace('_', '-') }} ```bash uv sync -uv run pre-commit install +uv run pre-commit install --allow-missing-config ``` +The hooks keep a local code graph in `graphify-out/` current after commit, +checkout, merge and rebase. They need a POSIX `sh` (on Windows: Git Bash). + ## Running tests ```bash diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 4505f7a..1f51c81 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -94,9 +94,11 @@ type = "github" [dependency-groups] dev = [ + "graphifyy>=0.9.92", {%- if include_hexagonal %} "import-linter>=2.0", {%- endif %} + "pre-commit>=3.2", "pytest>=8.0", "ruff>=0.15", "ty>=0.0.17", diff --git a/template/scripts/graphify-update.sh b/template/scripts/graphify-update.sh new file mode 100755 index 0000000..939cde0 --- /dev/null +++ b/template/scripts/graphify-update.sh @@ -0,0 +1,102 @@ +#!/bin/sh +# Rebuild the graphify code graph (graphify-out/) for this worktree. +# +# Called by the pre-commit "graphify" hook after commit, checkout, merge and +# rewrite. Needs a POSIX sh. Always exits 0 so it never breaks a git operation. +# +# Every call marks the worktree "dirty" and starts a detached worker. Only one +# worker per worktree holds the lock (a mkdir lock, portable to macOS); it +# rebuilds until nothing marked the tree dirty meanwhile, so the final graph +# always reflects the latest trigger. +# +# GRAPHIFY_UPDATE_FOREGROUND=1 runs the worker inline (for tests). + +# Stops graphify from rewriting the global agent skill files with this +# project's pinned version. +export GRAPHIFY_NO_AUTO_REFRESH=1 + +# Work from the worktree root, whatever the caller's directory (kept as is +# outside a git repository). +self=$(cd "$(dirname "$0")" && pwd)/$(basename "$0") +root=$(git rev-parse --show-toplevel 2>/dev/null) && cd "$root" + +out=graphify-out +lock=$out/.lock +dirty=$out/.dirty +log=$out/update.log +stale_minutes=10 +max_log_bytes=100000 + +say() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >>"$log"; } + +trim_log() { + size=$(wc -c <"$log" 2>/dev/null || echo 0) + if [ "${size:-0}" -gt "$max_log_bytes" ]; then + tail -n 200 "$log" >"$log.tmp" 2>/dev/null && mv "$log.tmp" "$log" + fi +} + +# Remove the lock only if this process still owns it. +release() { + [ "$(cat "$lock/pid" 2>/dev/null)" = "$$" ] && rm -rf "$lock" + return 0 +} + +# Take the lock. It is stale if its holder is dead, or if it has no pid file +# and has not been touched for a while. A live holder is never robbed. +acquire() { + mkdir "$lock" 2>/dev/null && { echo $$ >"$lock/pid"; return 0; } + pid=$(cat "$lock/pid" 2>/dev/null) + if { [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; } || + { [ -z "$pid" ] && + [ -n "$(find "$lock" -maxdepth 0 -mmin +"$stale_minutes" 2>/dev/null)" ]; }; then + say "removing stale lock (holder ${pid:-unknown})" + rm -rf "$lock" + mkdir "$lock" 2>/dev/null && { echo $$ >"$lock/pid"; return 0; } + fi + return 1 +} + +worker() { + while acquire; do + trap release EXIT + while [ -f "$dirty" ]; do + touch "$lock" 2>/dev/null + rm -f "$dirty" + say "update start" + if uv run --frozen graphify update . >>"$log" 2>&1; then + say "update done" + else + say "update failed (exit $?)" + fi + trim_log + done + release + trap - EXIT + # A trigger may have arrived between the last check and the release. + [ -f "$dirty" ] || return 0 + done +} + +if [ "$1" = "--worker" ]; then + worker + exit 0 +fi + +command -v uv >/dev/null 2>&1 || exit 0 +mkdir -p "$out" 2>/dev/null || exit 0 + +if [ ! -f uv.lock ]; then + say "no uv.lock in this checkout; skipping (run 'uv lock' to enable the graph)" + exit 0 +fi + +touch "$dirty" +if [ -n "$GRAPHIFY_UPDATE_FOREGROUND" ]; then + worker +elif command -v setsid >/dev/null 2>&1; then + setsid sh "$self" --worker >/dev/null 2>&1 /dev/null 2>&1 dict: + config = yaml.safe_load( + (project_path / ".pre-commit-config.yaml").read_text(encoding="utf-8") + ) + hooks = [h for r in config["repos"] for h in r["hooks"] if h["id"] == "graphify"] + assert len(hooks) == 1 + return hooks[0] + + +def test_graphify_is_configured(project): + """The rendered project wires graphify into pre-commit, deps and docs.""" + config = yaml.safe_load( + (project.path / ".pre-commit-config.yaml").read_text(encoding="utf-8") + ) + assert set(config["default_install_hook_types"]) == {"pre-commit", *GRAPHIFY_STAGES} + + assert config["default_stages"] == ["pre-commit"] + + hook = _graphify_hook(project.path) + assert hook["stages"] == GRAPHIFY_STAGES + assert hook["always_run"] is True + assert hook["pass_filenames"] is False + assert hook["language"] == "system" + + script = project.path / "scripts" / "graphify-update.sh" + assert script.stat().st_mode & 0o111, "hook script must be executable" + body = script.read_text(encoding="utf-8") + assert "GRAPHIFY_NO_AUTO_REFRESH=1" in body + assert "uv run --frozen graphify update ." in body + + pyproject = tomllib.loads((project.path / "pyproject.toml").read_text("utf-8")) + assert any( + d.startswith("graphifyy>=") for d in pyproject["dependency-groups"]["dev"] + ) + gitignore = (project.path / ".gitignore").read_text(encoding="utf-8") + assert "graphify-out/" in gitignore.splitlines() + assert not (project.path / ".gitattributes").exists() + agents = (project.path / "AGENTS.md").read_text(encoding="utf-8") + assert "## Code Graph" in agents + assert "graphify query" in agents + + +def _script(project_path: Path) -> str: + return str(project_path / "scripts" / "graphify-update.sh") + + +def _fake_uv(bin_dir: Path, body: str) -> dict: + bin_dir.mkdir(exist_ok=True) + fake = bin_dir / "uv" + fake.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8") + fake.chmod(0o755) + return {**os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}"} + + +def test_graphify_script_exits_zero_without_tools(project, tmp_path): + """With neither uv nor graphify on PATH the script is a silent no-op.""" + result = subprocess.run( + [shutil.which("sh"), _script(project.path)], + cwd=tmp_path, + env={"PATH": str(tmp_path)}, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert not (tmp_path / "graphify-out").exists() + + +def test_graphify_script_skips_without_lockfile(project, tmp_path): + """No uv.lock: log why, exit 0, and do not create a lock.""" + env = _fake_uv(tmp_path / "bin", "echo built >> built.txt") + result = subprocess.run( + ["sh", _script(project.path)], + cwd=tmp_path, + env={**env, "GRAPHIFY_UPDATE_FOREGROUND": "1"}, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "no uv.lock" in (tmp_path / "graphify-out" / "update.log").read_text() + assert not (tmp_path / "uv.lock").exists() + assert not (tmp_path / "built.txt").exists() + + +def test_graphify_script_survives_failing_build(project, tmp_path): + """A failing build is logged and still exits 0, releasing the lock.""" + (tmp_path / "uv.lock").write_text("") + env = _fake_uv(tmp_path / "bin", "echo boom; exit 3") + result = subprocess.run( + ["sh", _script(project.path)], + cwd=tmp_path, + env={**env, "GRAPHIFY_UPDATE_FOREGROUND": "1"}, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + log = (tmp_path / "graphify-out" / "update.log").read_text() + assert "boom" in log + assert "update failed" in log + assert not (tmp_path / "graphify-out" / ".lock").exists() + + +def test_graphify_script_coalesces_triggers(project, tmp_path): + """A trigger during a running build makes the holder build once more.""" + (tmp_path / "uv.lock").write_text("") + counter = tmp_path / "builds.txt" + env = { + **_fake_uv(tmp_path / "bin", f"echo x >> {counter}; sleep 2"), + "GRAPHIFY_UPDATE_FOREGROUND": "1", + } + first = subprocess.Popen( + ["sh", _script(project.path)], cwd=tmp_path, env=env + ) + deadline = time.monotonic() + 30 + while not counter.exists() and time.monotonic() < deadline: + time.sleep(0.1) + # Second and third triggers arrive mid-build: no extra parallel build. + for _ in range(2): + second = subprocess.run(["sh", _script(project.path)], cwd=tmp_path, env=env) + assert second.returncode == 0 + assert first.wait(timeout=60) == 0 + assert counter.read_text().count("x") == 2 + assert not (tmp_path / "graphify-out" / ".lock").exists() + + +def test_graphify_script_recovers_stale_lock(project, tmp_path): + """A lock whose holder is dead does not block builds forever.""" + (tmp_path / "uv.lock").write_text("") + lock = tmp_path / "graphify-out" / ".lock" + lock.mkdir(parents=True) + dead = subprocess.Popen(["true"]) + dead.wait() + (lock / "pid").write_text(str(dead.pid)) + env = _fake_uv(tmp_path / "bin", f"echo x >> {tmp_path / 'builds.txt'}") + subprocess.run( + ["sh", _script(project.path)], + cwd=tmp_path, + env={**env, "GRAPHIFY_UPDATE_FOREGROUND": "1"}, + check=True, + ) + assert (tmp_path / "builds.txt").read_text().count("x") == 1 + + +def test_graphify_script_does_not_steal_live_lock(project, tmp_path): + """A live holder keeps its lock even when the lock directory is old.""" + (tmp_path / "uv.lock").write_text("") + lock = tmp_path / "graphify-out" / ".lock" + lock.mkdir(parents=True) + holder = subprocess.Popen(["sleep", "30"]) + try: + (lock / "pid").write_text(str(holder.pid)) + old = time.time() - 3600 + os.utime(lock, (old, old)) + env = _fake_uv(tmp_path / "bin", f"echo x >> {tmp_path / 'builds.txt'}") + subprocess.run( + ["sh", _script(project.path)], + cwd=tmp_path, + env={**env, "GRAPHIFY_UPDATE_FOREGROUND": "1"}, + check=True, + ) + assert not (tmp_path / "builds.txt").exists() + assert (lock / "pid").read_text() == str(holder.pid) + finally: + holder.kill() + holder.wait() + + +def test_generation_survives_failing_uv_lock(tmp_path): + """The best-effort `uv lock` task must not abort project generation.""" + env = _fake_uv(tmp_path / "bin", "echo offline >&2; exit 2") + dst = tmp_path / "proj" + # copier runs tasks with plumbum's environment, not os.environ. + with plumbum.local.env(PATH=env["PATH"]): + copier.run_copy( + src_path=str(TEMPLATE_ROOT), + dst_path=str(dst), + data={**COPIER_DATA, "include_hexagonal": False}, + defaults=True, + unsafe=True, + vcs_ref="HEAD", + ) + assert (dst / "pyproject.toml").is_file() + assert not (dst / "uv.lock").exists() + + +def _online() -> bool: + try: + socket.create_connection(("pypi.org", 443), timeout=5).close() + except OSError: + return False + return True + + +def _wait_for(path: Path, timeout: float = 90.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if path.is_file(): + return True + time.sleep(1) + return path.is_file() + + +def _wait_idle(*roots: Path, timeout: float = 60.0) -> None: + """Wait for background builds to finish so tmp_path can be removed.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not any((r / "graphify-out" / ".lock").exists() for r in roots): + return + time.sleep(1) + for r in roots: + pid_file = r / "graphify-out" / ".lock" / "pid" + if pid_file.exists(): + with contextlib.suppress(OSError, ValueError): + os.kill(int(pid_file.read_text()), signal.SIGTERM) + + +@pytest.mark.skipif(shutil.which("uv") is None, reason="uv not available") +@pytest.mark.skipif(not _online(), reason="network not available") +def test_graphify_hooks_build_graph_per_worktree(tmp_path): + """Commit and `git worktree add` each produce their own graphify-out/. + + Needs network access (installs the project's dev dependencies, graphify + included). Exercises the real post-commit and post-checkout hooks, and that + a checkout of a commit without a pre-commit config still succeeds. + """ + dst = tmp_path / "proj" + worktree = tmp_path / "wt" + copier.run_copy( + src_path=str(TEMPLATE_ROOT), + dst_path=str(dst), + data={**COPIER_DATA, "include_hexagonal": False}, + defaults=True, + unsafe=True, + vcs_ref="HEAD", + ) + assert (dst / "uv.lock").is_file(), "copier _tasks should create uv.lock" + + def run(*cmd, cwd=dst): + return subprocess.run(cmd, cwd=cwd, check=True, capture_output=True, text=True) + + try: + run("uv", "sync") + run("git", "config", "user.email", "t@example.com") + run("git", "config", "user.name", "T") + run("uv", "run", "pre-commit", "install", "--allow-missing-config") + for name in ["post-commit", "post-checkout", "post-merge", "post-rewrite"]: + assert (dst / ".git" / "hooks" / name).is_file(), f"{name} not installed" + + run("git", "add", ".") + # --no-verify skips the pre-commit stage only; post-commit still fires. + run("git", "commit", "--no-verify", "-m", "chore: initial") + assert _wait_for(dst / "graphify-out" / "graph.json"), "no graph after commit" + + run("git", "worktree", "add", "-b", "other", str(worktree)) + assert _wait_for(worktree / "graphify-out" / "graph.json"), ( + "post-checkout did not build a graph in the new worktree" + ) + + # A commit without .pre-commit-config.yaml must stay checkout-able. + run("git", "checkout", "-q", "--orphan", "bare") + run("git", "rm", "-rfq", ".") + run("git", "commit", "-q", "--allow-empty", "--no-verify", "-m", "bare") + run("git", "checkout", "-q", "main") + run("git", "checkout", "-q", "bare") + finally: + _wait_idle(dst, worktree)