diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dab3b7..8fe963c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`codegraph clean` subcommand + auto-prune of `.codegraph/explore/`.** + `codegraph serve` generated a pyvis HTML page per explored function + and never pruned them — long-lived projects could grow this cache to + hundreds of MB. New module `codegraph/cache_prune.py` implements LRU + eviction by mtime (`prune_cache_to_size(path, max_size_mb)`), and the + new `codegraph clean` subcommand exposes it with `--max-size-mb 50` + (default) and `--all` (wipe everything). `codegraph serve` runs the + prune on startup and prints the current cache size. - **Auto-populate ignore patterns from detected ecosystem during `init`.** The free-text "Extra ignore patterns" prompt was a footgun — a user once typed `y` thinking it was yes/no and got `ignore: [- y]` in diff --git a/codegraph/cache_prune.py b/codegraph/cache_prune.py new file mode 100644 index 0000000..26cd8a1 --- /dev/null +++ b/codegraph/cache_prune.py @@ -0,0 +1,92 @@ +"""LRU pruning for `.codegraph/explore/` and similar generated caches. + +`codegraph serve` regenerates pyvis HTML per explored function and never +prunes; long-lived projects can grow this to hundreds of MB. This +module gives us a single helper that evicts oldest-mtime files until +the total size is under a cap, plus a CLI-friendly size reporter. +""" +from __future__ import annotations + +import contextlib +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class PruneResult: + files_deleted: int + bytes_freed: int + bytes_remaining: int + + +def directory_size_bytes(path: Path) -> int: + """Total size in bytes of all files under *path* (recursive).""" + if not path.exists(): + return 0 + total = 0 + for p in path.rglob("*"): + try: + if p.is_file(): + total += p.stat().st_size + except OSError: + continue + return total + + +def prune_cache_to_size( + path: Path, max_size_mb: float +) -> PruneResult: + """Evict files by oldest mtime until total size is under *max_size_mb*. + + Files are ranked by mtime ascending (oldest first). Empty + directories left behind by the prune are removed. Subdirectories + are walked recursively. + + Symlinks and non-file entries are ignored. ``max_size_mb`` is the + soft cap; the final size may be below it. + """ + if not path.exists() or not path.is_dir(): + return PruneResult(0, 0, 0) + + files: list[tuple[float, int, Path]] = [] + for p in path.rglob("*"): + try: + if not p.is_file() or p.is_symlink(): + continue + stat = p.stat() + files.append((stat.st_mtime, stat.st_size, p)) + except OSError: + continue + + total = sum(size for _mtime, size, _p in files) + cap = int(max_size_mb * 1024 * 1024) + if total <= cap: + return PruneResult(0, 0, total) + + # Oldest first. + files.sort(key=lambda t: t[0]) + deleted = 0 + freed = 0 + for _mtime, size, victim in files: + if total <= cap: + break + try: + victim.unlink() + except OSError: + continue + deleted += 1 + freed += size + total -= size + + # Sweep empty subdirs left behind. + for sub in sorted(path.rglob("*"), key=lambda p: -len(p.parts)): + if sub.is_dir(): + try: + next(sub.iterdir()) + except StopIteration: + with contextlib.suppress(OSError): + sub.rmdir() + + return PruneResult( + files_deleted=deleted, bytes_freed=freed, bytes_remaining=total + ) diff --git a/codegraph/cli.py b/codegraph/cli.py index d6924d9..6f4e623 100644 --- a/codegraph/cli.py +++ b/codegraph/cli.py @@ -802,6 +802,54 @@ def analyze( print(text) +@app.command() +def clean( + explore_dir: str = typer.Option( + ".codegraph/explore", + "--explore-dir", + help="Folder of generated pyvis pages to prune.", + ), + max_size_mb: float = typer.Option( + 50.0, + "--max-size-mb", + help="LRU-evict files until total cache size is under this cap (MB).", + ), + all_: bool = typer.Option( + False, "--all", help="Delete every file in the cache (ignores --max-size-mb).", + ), +) -> None: + """Prune the local pyvis explore cache (`.codegraph/explore/`).""" + from codegraph.cache_prune import ( + directory_size_bytes, + prune_cache_to_size, + ) + + repo_root = Path.cwd() + target = Path(explore_dir) + if not target.is_absolute(): + target = repo_root / target + + if not target.exists(): + console.print(f"[dim]·[/dim] {target} does not exist — nothing to clean.") + return + + before = directory_size_bytes(target) + cap = 0.0 if all_ else max_size_mb + result = prune_cache_to_size(target, cap) + + if result.files_deleted == 0: + console.print( + f"[dim]·[/dim] {target} already under cap " + f"({before / 1024 / 1024:.1f} MB)." + ) + else: + console.print( + f"[green]✓[/green] Pruned {result.files_deleted} file(s); " + f"freed {result.bytes_freed / 1024 / 1024:.1f} MB. " + f"Remaining: {result.bytes_remaining / 1024 / 1024:.1f} MB." + ) + + @app.command() def explore( output: str = typer.Option( @@ -896,6 +944,26 @@ def _rebuild() -> nx.MultiDiGraph: ) return graph + # Auto-prune the explore cache to the default 50 MB cap on startup, + # and surface the current size so users notice if it's growing. + from codegraph.cache_prune import ( + directory_size_bytes, + prune_cache_to_size, + ) + + if explore_path.exists(): + size_mb = directory_size_bytes(explore_path) / 1024 / 1024 + prune = prune_cache_to_size(explore_path, 50.0) + if prune.files_deleted: + console.print( + f"[dim]· pruned {prune.files_deleted} old cache file(s); " + f"explore cache now {prune.bytes_remaining / 1024 / 1024:.1f} MB[/dim]" + ) + else: + console.print( + f"[dim]· explore cache: {size_mb:.1f} MB[/dim]" + ) + # Make sure pyvis pages exist on first run. if not (explore_path / "architecture.html").exists(): console.print("[dim]First run: generating pyvis pages...[/dim]") diff --git a/tests/test_cache_prune.py b/tests/test_cache_prune.py new file mode 100644 index 0000000..939a997 --- /dev/null +++ b/tests/test_cache_prune.py @@ -0,0 +1,72 @@ +"""Tests for `cache_prune` (v0.1.2 #6).""" +from __future__ import annotations + +import os +import time +from pathlib import Path + +from codegraph.cache_prune import ( + directory_size_bytes, + prune_cache_to_size, +) + + +def _make_file(path: Path, size_bytes: int, mtime: float | None = None) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"x" * size_bytes) + if mtime is not None: + os.utime(path, (mtime, mtime)) + + +def test_directory_size_handles_missing_path(tmp_path: Path) -> None: + assert directory_size_bytes(tmp_path / "nope") == 0 + + +def test_directory_size_sums_all_files(tmp_path: Path) -> None: + _make_file(tmp_path / "a.html", 100) + _make_file(tmp_path / "sub" / "b.html", 200) + assert directory_size_bytes(tmp_path) == 300 + + +def test_prune_returns_zero_when_under_cap(tmp_path: Path) -> None: + _make_file(tmp_path / "a.html", 1000) + result = prune_cache_to_size(tmp_path, max_size_mb=1.0) # 1 MB cap + assert result.files_deleted == 0 + assert result.bytes_freed == 0 + assert result.bytes_remaining == 1000 + + +def test_prune_evicts_oldest_first(tmp_path: Path) -> None: + now = time.time() + _make_file(tmp_path / "old.html", 600_000, mtime=now - 1000) + _make_file(tmp_path / "mid.html", 600_000, mtime=now - 500) + _make_file(tmp_path / "new.html", 600_000, mtime=now) + # Cap = 1 MB. Total = ~1.8 MB. Need to evict ~0.8 MB; the oldest + # 600KB file goes first; that leaves ~1.2 MB which is still over + # cap, so the next-oldest gets evicted too. + result = prune_cache_to_size(tmp_path, max_size_mb=1.0) + assert result.files_deleted == 2 + assert not (tmp_path / "old.html").exists() + assert not (tmp_path / "mid.html").exists() + assert (tmp_path / "new.html").exists() + + +def test_prune_removes_empty_subdirs(tmp_path: Path) -> None: + _make_file(tmp_path / "sub" / "a.html", 2_000_000, mtime=0) + _make_file(tmp_path / "keep.html", 100) + prune_cache_to_size(tmp_path, max_size_mb=0.001) + assert not (tmp_path / "sub").exists() + + +def test_prune_max_size_zero_wipes_everything(tmp_path: Path) -> None: + _make_file(tmp_path / "a.html", 100, mtime=1.0) + _make_file(tmp_path / "b.html", 100, mtime=2.0) + result = prune_cache_to_size(tmp_path, max_size_mb=0.0) + assert result.files_deleted == 2 + assert result.bytes_remaining == 0 + + +def test_prune_no_op_on_nonexistent_dir(tmp_path: Path) -> None: + result = prune_cache_to_size(tmp_path / "nope", max_size_mb=1.0) + assert result.files_deleted == 0 + assert result.bytes_remaining == 0