Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion src/codetree/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
27 changes: 26 additions & 1 deletion src/codetree/indexer.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from collections.abc import Iterable
from pathlib import Path
from dataclasses import dataclass
from .languages.base import LanguagePlugin
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down
9 changes: 5 additions & 4 deletions src/codetree/server.py
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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()
32 changes: 32 additions & 0 deletions tests/test_indexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
37 changes: 37 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])]