diff --git a/README.md b/README.md index 4e5821a..4243920 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,8 @@ The agent sees every class, method, and docstring — with line numbers — with The `--root` flag tells codetree which project to analyze. Use `.` for the current directory, or a full path. +Dependency and build directories (`.venv`, `node_modules`, `dist`, …) are skipped. To leave more out of the index, repeat `--exclude` with a path relative to the root: `--exclude third_party --exclude docs/generated`. It scopes the index only: `git_history` still reports every tracked file. + ### Claude Code `cd` into your project, then: diff --git a/src/codetree/__main__.py b/src/codetree/__main__.py index ce7f185..472d126 100644 --- a/src/codetree/__main__.py +++ b/src/codetree/__main__.py @@ -5,8 +5,10 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("--root", default=".", help="Path to repo root (default: current directory)") + parser.add_argument("--exclude", action="append", default=[], metavar="PATH", + help="Path to leave out of the index, relative to the repo root (repeatable)") args = parser.parse_args() - run(os.path.abspath(args.root)) + run(os.path.abspath(args.root), exclude=args.exclude) if __name__ == "__main__": main() diff --git a/src/codetree/indexer.py b/src/codetree/indexer.py index 0fb2256..e1ad3b9 100644 --- a/src/codetree/indexer.py +++ b/src/codetree/indexer.py @@ -1,3 +1,4 @@ +from collections.abc import Iterable from pathlib import Path from dataclasses import dataclass from .languages.base import LanguagePlugin @@ -32,8 +33,10 @@ class Indexer: "setup", "teardown", "setUp", "tearDown", } - def __init__(self, root: str | Path): + def __init__(self, root: str | Path, exclude: Iterable[str] = ()): self.root = Path(root) + # Root-relative path prefixes to skip, on top of SKIP_DIRS. + self._exclude: list[tuple[str, ...]] = [self._exclude_prefix(e) for e in exclude] self._index: dict[str, FileEntry] = {} self._definitions: dict[str, list[tuple[str, int]]] = {} # Keys are "rel_path::symbol_name" to prevent name collisions across files. @@ -56,12 +59,34 @@ def files(self) -> list[Path]: ".codetree", } + def _exclude_prefix(self, exclude: str) -> tuple[str, ...]: + """Normalize one --exclude value to path parts relative to the root. + + A value that could never match an indexed path is an error, not a no-op. + """ + path = Path(exclude) + if path.is_absolute(): + try: + path = path.relative_to(self.root) + except ValueError: + raise ValueError( + f"--exclude '{exclude}' is outside the repo root '{self.root}'" + ) from None + if not path.parts or ".." in path.parts: + raise ValueError( + f"--exclude '{exclude}' must name a path below the repo root, without '..'" + ) + return path.parts + def _should_skip(self, path: Path) -> bool: for part in path.parts: if part in self.SKIP_DIRS: return True if part.endswith(".egg-info"): return True + for prefix in self._exclude: + if path.parts[:len(prefix)] == prefix: + return True return False def _rebuild_definitions(self) -> None: diff --git a/src/codetree/server.py b/src/codetree/server.py index 189e22f..f6b5b0e 100644 --- a/src/codetree/server.py +++ b/src/codetree/server.py @@ -1,10 +1,11 @@ +from collections.abc import Iterable from fastmcp import FastMCP from pathlib import Path from .indexer import Indexer from .cache import Cache -def create_server(root: str) -> FastMCP: +def create_server(root: str, exclude: Iterable[str] = ()) -> FastMCP: mcp = FastMCP("codetree") root_path = Path(root) @@ -30,7 +31,7 @@ def _validate_path(file_path: str | None, _root: Path = root_path) -> str | None cached_mtimes = { k: v["mtime"] for k, v in (cache._data or {}).items() } - indexer = Indexer(root) + indexer = Indexer(root, exclude=exclude) indexer.build(cached_mtimes=cached_mtimes) # Inject cached entries for unchanged files (skip ignored dirs) @@ -762,6 +763,6 @@ def suggest_docs(file_path: str | None = None, return mcp -def run(root: str): - mcp = create_server(root) +def run(root: str, exclude: Iterable[str] = ()): + mcp = create_server(root, exclude=exclude) mcp.run() diff --git a/tests/test_indexer.py b/tests/test_indexer.py index d8feadd..45b4526 100644 --- a/tests/test_indexer.py +++ b/tests/test_indexer.py @@ -82,6 +82,38 @@ def test_indexes_multiple_languages(self, multi_lang_repo): assert ".go" in exts assert ".rs" in exts + def test_skips_excluded_path(self, tmp_path): + (tmp_path / "app.py").write_text("def main(): pass") + vendored = tmp_path / "third_party" / "lib" + vendored.mkdir(parents=True) + (vendored / "util.py").write_text("def vendored_fn(): pass") + idx = Indexer(str(tmp_path), exclude=["third_party"]) + idx.build() + assert list(idx._index) == ["app.py"] + + def test_excluded_path_is_a_prefix_from_root(self, tmp_path): + for rel in ("docs/gen", "docs/generated", "src/docs/gen"): + d = tmp_path / rel + d.mkdir(parents=True) + (d / "mod.py").write_text("def fn(): pass") + idx = Indexer(str(tmp_path), exclude=["docs/gen/"]) + idx.build() + assert sorted(idx._index) == ["docs/generated/mod.py", "src/docs/gen/mod.py"] + + def test_exclude_accepts_absolute_path_under_root(self, tmp_path): + (tmp_path / "app.py").write_text("def main(): pass") + vendored = tmp_path / "third_party" + vendored.mkdir() + (vendored / "util.py").write_text("def vendored_fn(): pass") + idx = Indexer(str(tmp_path), exclude=[str(vendored)]) + idx.build() + assert list(idx._index) == ["app.py"] + + @pytest.mark.parametrize("bad", ["../elsewhere", "src/../third_party", "/outside/the/root"]) + def test_exclude_rejects_path_it_cannot_match(self, tmp_path, bad): + with pytest.raises(ValueError, match="--exclude"): + Indexer(str(tmp_path), exclude=[bad]) + def test_skips_git_directory(self, tmp_path): (tmp_path / "app.py").write_text("def main(): pass") git = tmp_path / ".git" / "hooks" diff --git a/tests/test_server.py b/tests/test_server.py index 68b1b75..85b98eb 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -342,3 +342,40 @@ def test_cross_file_callers_discovered(self, rich_py_repo): result = fn(file_path="models.py", function_name="get_user_by_email") # get_user_by_email is imported and potentially used in services.py assert "models.py" in result or "services.py" in result + + +# ─── exclude ────────────────────────────────────────────────────────────────── + +class TestExclude: + + def _repo(self, tmp_path): + (tmp_path / "app.py").write_text("def main(): pass\n") + vendored = tmp_path / "third_party" / "lib" + vendored.mkdir(parents=True) + (vendored / "util.py").write_text("def vendored_fn(): pass\n") + return tmp_path + + def test_excluded_path_is_not_served(self, tmp_path): + repo = self._repo(tmp_path) + fn = _tool(create_server(str(repo), exclude=["third_party"]), "get_file_skeleton") + assert "main" in fn(file_path="app.py") + assert "File not found" in fn(file_path="third_party/lib/util.py") + + def test_exclude_drops_previously_cached_file(self, tmp_path): + repo = self._repo(tmp_path) + fn = _tool(create_server(str(repo)), "get_file_skeleton") + assert "vendored_fn" in fn(file_path="third_party/lib/util.py") + fn = _tool(create_server(str(repo), exclude=["third_party"]), "get_file_skeleton") + assert "File not found" in fn(file_path="third_party/lib/util.py") + + def test_cli_passes_exclude_to_run(self, tmp_path, monkeypatch): + import sys + import codetree.__main__ as cli + calls = [] + monkeypatch.setattr(cli, "run", lambda root, exclude=(): calls.append((root, list(exclude)))) + monkeypatch.setattr(sys, "argv", [ + "codetree", "--root", str(tmp_path), + "--exclude", "third_party", "--exclude", "docs/gen", + ]) + cli.main() + assert calls == [(str(tmp_path), ["third_party", "docs/gen"])]