diff --git a/src/context_engine/indexer/ignorefile.py b/src/context_engine/indexer/ignorefile.py index fc20f7e..b310b60 100644 --- a/src/context_engine/indexer/ignorefile.py +++ b/src/context_engine/indexer/ignorefile.py @@ -64,8 +64,13 @@ def matches_any(rel_path: str, is_dir: bool, patterns: list[str]) -> bool: """ if not patterns: return False - # Normalise: forward slashes, no leading "./" - rel = rel_path.replace("\\", "/").lstrip("./") + # Normalise: forward slashes, no leading "./" or "/". Not `lstrip("./")`: + # that strips a character *set*, so ".env" became "env" and dotted + # patterns never matched root-level entries (#165). + rel = rel_path.replace("\\", "/") + while rel.startswith("./"): + rel = rel[2:] + rel = rel.lstrip("/") name = rel.rsplit("/", 1)[-1] for pat in patterns: # Trailing slash → directory-only pattern. diff --git a/src/context_engine/indexer/pipeline.py b/src/context_engine/indexer/pipeline.py index 5a78e31..7b8e3b9 100644 --- a/src/context_engine/indexer/pipeline.py +++ b/src/context_engine/indexer/pipeline.py @@ -186,6 +186,7 @@ def _iter_project_files( *, redact_secrets: bool = True, cceignore_patterns: list[str] | None = None, + relative_to: Path | None = None, ) -> Iterable[Path]: """Yield files under `root` respecting ignore list, skipping symlinks. @@ -199,16 +200,21 @@ def _iter_project_files( `cceignore_patterns` (typically loaded from `.cceignore`) supplements the name-only `ignore_set` with gitignore-style globs evaluated - against the path relative to `root`. + against the path relative to `relative_to` (default: `root`). A walk + started below the project root must pass the project root here, or + root-relative patterns (`vendor/`, `data/`) are matched against the + wrong path. """ from context_engine.indexer.secrets import is_secret_file as _is_secret_file from context_engine.indexer.ignorefile import matches_any as _ignore_matches patterns = cceignore_patterns or [] seen: set[Path] = set() + base = relative_to if relative_to is not None else root + def _rel(entry: Path) -> str: try: - return str(entry.relative_to(root)).replace("\\", "/") + return str(entry.relative_to(base)).replace("\\", "/") except ValueError: return entry.name @@ -421,11 +427,30 @@ async def _run_indexing_locked( else: file_iter = [target] elif target.is_dir(): - file_iter = list(_iter_project_files( - target, ignore_set, _SKIP_EXTENSIONS, - redact_secrets=getattr(config, "indexer_redact_secrets", True), - cceignore_patterns=cceignore_patterns, - )) + # A directory target must not bypass exclusions that apply to + # the directory itself or one of its ancestors (#165): the walk + # below only tests entries *inside* it. + rel = str(target.relative_to(project_dir)) + rel_posix = rel.replace("\\", "/") + parts = [] if rel_posix in ("", ".") else rel_posix.split("/") + from context_engine.indexer.ignorefile import matches_any + if any(part in ignore_set for part in parts) or ( + cceignore_patterns and any( + matches_any("/".join(parts[:i]), True, cceignore_patterns) + for i in range(1, len(parts) + 1) + ) + ): + if log_fn: + log_fn(f" [skip] {rel} (ignored directory)") + result.skipped_files.append(rel) + file_iter = [] + else: + file_iter = list(_iter_project_files( + target, ignore_set, _SKIP_EXTENSIONS, + redact_secrets=getattr(config, "indexer_redact_secrets", True), + cceignore_patterns=cceignore_patterns, + relative_to=project_dir, + )) else: # Not on disk. The watcher enqueues deletions through the same # code path — if the manifest still tracks this file, prune its diff --git a/tests/indexer/test_ignorefile.py b/tests/indexer/test_ignorefile.py index 051d357..7a0c131 100644 --- a/tests/indexer/test_ignorefile.py +++ b/tests/indexer/test_ignorefile.py @@ -92,6 +92,23 @@ def test_empty_patterns_matches_nothing(): assert not matches_any("anything.txt", is_dir=False, patterns=[]) +@pytest.mark.parametrize("rel,is_dir,pattern", [ + (".pytest-tmp-ci", True, ".pytest-tmp*/"), + (".env", False, ".env"), + (".mcp.json", False, ".mcp.json"), + (".claude/settings.local.json", False, ".claude/settings.local.json"), + ("./.env", False, ".env"), +]) +def test_dotted_patterns_match_root_entries(rel, is_dir, pattern): + """`lstrip("./")` stripped every leading dot, so `.env` was matched as + `env` and dotted patterns never matched at the root (#165).""" + assert matches_any(rel, is_dir=is_dir, patterns=[pattern]) + + +def test_leading_dot_is_not_dropped_from_the_name(): + assert not matches_any(".env", is_dir=False, patterns=["env"]) + + def test_anchored_leading_slash_is_treated_as_root_relative(): """Leading `/` is stripped — patterns are root-relative anyway.""" p = ["/main.py"] diff --git a/tests/indexer/test_pipeline_target_path.py b/tests/indexer/test_pipeline_target_path.py index 06cb47c..9bca549 100644 --- a/tests/indexer/test_pipeline_target_path.py +++ b/tests/indexer/test_pipeline_target_path.py @@ -82,6 +82,27 @@ async def test_target_path_respects_cceignore(project): assert _backend(config, project_dir).count_chunks() == 0 +@pytest.mark.asyncio +async def test_directory_target_respects_cceignore(project): + """`cce index --path ` must not bypass a rule matching or an + ancestor, nor evaluate root-relative rules against -relative + paths (#165).""" + project_dir, config = project + (project_dir / ".cceignore").write_text("data/\nsrc/gen/\n") + for rel in ("data/notes.py", "data/sub/more.py", "src/gen/out.py", "src/ok.py"): + path = project_dir / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("def f():\n return 1\n") + + for target in ("data", "data/sub"): + result = await run_indexing(config, str(project_dir), target_path=target) + assert result.indexed_files == [], f"{target} should have been ignored" + assert not result.errors + + result = await run_indexing(config, str(project_dir), target_path="src") + assert [f.replace("\\", "/") for f in result.indexed_files] == ["src/ok.py"] + + @pytest.mark.asyncio async def test_target_path_normal_file_still_indexed(project): """Sanity: the filters must not over-block ordinary files."""