From d9487f94a5e12709530420737098c39959a423a4 Mon Sep 17 00:00:00 2001 From: Mohamed Salah Date: Thu, 27 Aug 2026 20:44:33 +0300 Subject: [PATCH] fix: search workspaces under ignored directory names (#576) ripgrep matches --glob patterns against paths relative to the process cwd, not the search root. Passing the absolute workspace path while the agent runs elsewhere made the exclusion globs (!**/node_modules/** and friends) match the workspace's own ancestors, so a workspace located under e.g. .../node_modules/ silently excluded itself: rg exited 1 with no output. Run rg with cwd anchored to the search base and '.' as the target so only directories inside the workspace can match, and resolve rg's now relative output paths against that base before relativizing to the workspace root. Regression-tested with a workspace nested under node_modules; the existing glob/escape/ignore tests are unchanged. --- coworker/tools/search.py | 26 +++++++++++++++++++------- tests/test_code_tools.py | 13 +++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/coworker/tools/search.py b/coworker/tools/search.py index 3ff7fc3e12..1ecbb7bda8 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 0edc83b2f1..e6ed10a392 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