diff --git a/coworker/tools/search.py b/coworker/tools/search.py index 3ff7fc3e1..1ecbb7bda 100644 --- a/coworker/tools/search.py +++ b/coworker/tools/search.py @@ -115,14 +115,21 @@ def grep( # last because ripgrep resolves conflicting globs with the later one winning. for ignored in sorted(_IGNORE_DIRS): cmd += ["--glob", f"!**/{ignored}/**"] - cmd.append(str(base)) + # Search from the workspace itself: ripgrep resolves --glob patterns + # against paths relative to the *current working directory*, so passing + # an absolute path made the exclusion globs match the workspace's + # ancestors too — a workspace under e.g. .../node_modules/ excluded + # itself entirely (issue #576). With cwd anchored to the search root + # and "." as the target, only directory names inside the workspace + # can match. + cmd.append(".") try: - out = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + out = subprocess.run(cmd, capture_output=True, text=True, timeout=30, cwd=str(base)) except Exception as exc: return {"error": f"grep failed: {exc}"} if out.returncode not in (0, 1): # 1 = no matches return {"error": (out.stderr or "ripgrep error").strip()[:300]} - return {"engine": "ripgrep", **_parse_rg(out.stdout, root, n)} + return {"engine": "ripgrep", **_parse_rg(out.stdout, root, base, n)} return {"engine": "python", **_py_grep(root, base, pattern, glob, n)} @@ -139,14 +146,19 @@ def grep( return [grep] -def _rel(path: str, root: Path) -> str: +def _rel(path: str, root: Path, base: Path | None = None) -> str: try: - return str(Path(path).resolve().relative_to(root)) + p = Path(path) + # ripgrep emits paths relative to its own cwd (the search base); resolve + # them against that, not the process cwd, before relativizing. + if not p.is_absolute() and base is not None: + p = base / p + return str(p.resolve().relative_to(root)) except (ValueError, OSError): return path -def _parse_rg(stdout: str, root: Path, n: int) -> dict[str, Any]: +def _parse_rg(stdout: str, root: Path, base: Path, n: int) -> dict[str, Any]: matches: list[dict[str, Any]] = [] for line in stdout.splitlines(): parts = line.split(":", 2) @@ -154,7 +166,7 @@ def _parse_rg(stdout: str, root: Path, n: int) -> dict[str, Any]: f, ln, txt = parts matches.append( { - "file": _rel(f, root), + "file": _rel(f, root, base), "line": int(ln) if ln.isdigit() else 0, "text": txt[:300], } diff --git a/tests/test_code_tools.py b/tests/test_code_tools.py index 0edc83b2f..e6ed10a39 100644 --- a/tests/test_code_tools.py +++ b/tests/test_code_tools.py @@ -41,6 +41,19 @@ def test_grep_finds_matches_and_respects_glob(tmp_path): assert only_py["matches"][0]["line"] == 1 +def test_grep_works_when_workspace_sits_under_an_ignored_dir_name(tmp_path): + # Regression for #576: ripgrep matches --glob patterns against paths + # relative to the *current working directory*, not the search root, so a + # workspace living under e.g. .../node_modules/ws excluded itself entirely. + ws = tmp_path / "node_modules" / "ws" + ws.mkdir(parents=True) + (ws / "a.py").write_text("hello under ignored parent\n", encoding="utf-8") + grep = search_tools(str(ws))[0] + out = grep(pattern="hello") + assert out["count"] == 1 + assert out["matches"][0]["file"] == "a.py" + + def test_ripgrep_uses_the_same_ignored_dirs_as_the_python_fallback(tmp_path, monkeypatch): import coworker.tools.search as search