Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions codegraph/cache_prune.py
Original file line number Diff line number Diff line change
@@ -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
)
68 changes: 68 additions & 0 deletions codegraph/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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]")
Expand Down
72 changes: 72 additions & 0 deletions tests/test_cache_prune.py
Original file line number Diff line number Diff line change
@@ -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
Loading