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
26 changes: 19 additions & 7 deletions coworker/tools/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}

Expand All @@ -139,22 +146,27 @@ 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)
if len(parts) == 3:
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],
}
Expand Down
13 changes: 13 additions & 0 deletions tests/test_code_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down