Skip to content
Closed
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: 26 additions & 0 deletions tests/tools/test_file_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,13 @@ def test_normalize_search_pagination_clamps_invalid_values(self):
def test_escape_shell_arg_simple(self, file_ops):
assert file_ops._escape_shell_arg("hello") == "'hello'"

def test_escape_shell_raw_preserves_backslashes_and_paths(self, monkeypatch, file_ops):
import tools.environments.local as local_mod
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
assert file_ops._escape_shell_raw(r"\d+") == r"'\d+'"
assert file_ops._escape_shell_raw(r"C:\Users\alice\notes.txt") == r"'C:\Users\alice\notes.txt'"
assert file_ops._escape_shell_raw("C:/Users/alice/notes.txt") == "'C:/Users/alice/notes.txt'"

def test_escape_shell_arg_with_quotes(self, file_ops):
result = file_ops._escape_shell_arg("it's")
assert "'" in result
Expand Down Expand Up @@ -696,6 +703,25 @@ def side_effect(command, **kwargs):
assert result.error is not None
assert "search failed" in result.error.lower() or "Search error" in result.error

def test_search_rg_preserves_windows_drive_paths(self, mock_env):
"""search() should preserve Windows drive letters and regex backslashes for ripgrep command."""
executed_command = []
def side_effect(command, **kwargs):
if "test -e" in command:
return {"output": "exists", "returncode": 0}
if "command -v" in command:
return {"output": "yes", "returncode": 0}
executed_command.append(command)
return {"output": "", "returncode": 1}
mock_env.execute.side_effect = side_effect
ops = ShellFileOperations(mock_env)
result = ops.search(r"\d+", path="C:/Users/ADMIN/AppData/Local/hermes/config.yaml")
assert result.error is None
assert len(executed_command) == 1
# It should preserve both the raw regex '\d+' and native Windows path
assert r"'\d+'" in executed_command[0]
assert "'C:/Users/ADMIN/AppData/Local/hermes/config.yaml'" in executed_command[0]


class TestSearchFilesFallbackHiddenPaths:
def _make_env(self):
Expand Down
26 changes: 18 additions & 8 deletions tools/file_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,16 @@ def _escape_shell_arg(self, arg: str) -> str:
# Use single quotes and escape any single quotes in the string
return "'" + arg.replace("'", "'\"'\"'") + "'"

def _escape_shell_raw(self, arg: str) -> str:
"""Escape a string for safe use in shell commands without translating paths.

Useful for search patterns, python code snippets, and native Windows CLI
tool paths (like ripgrep) where backslashes or drive letters (C:/...)
should be preserved directly.
"""
# Use single quotes and escape any single quotes in the string
return "'" + arg.replace("'", "'\"'\"'") + "'"

def _atomic_write(self, path: str, content: str) -> "ExecuteResult":
"""Write ``content`` to ``path`` atomically via temp-file + rename.

Expand Down Expand Up @@ -1320,12 +1330,12 @@ def _python_delete(self, path: str, recursive: bool) -> WriteResult:
" print(str(exc), file=sys.stderr); sys.exit(1)\n"
)

result = self._exec(f"python3 -c {self._escape_shell_arg(snippet)}")
result = self._exec(f"python3 -c {self._escape_shell_raw(snippet)}")

# Fall back to ``python`` (Windows / older systems where there's no
# ``python3`` symlink but a ``python`` binary is on PATH).
if result.exit_code != 0 and "python3" in (result.stdout or ""):
result = self._exec(f"python -c {self._escape_shell_arg(snippet)}")
result = self._exec(f"python -c {self._escape_shell_raw(snippet)}")

if result.exit_code != 0:
return WriteResult(error=f"Failed to delete {path}: {(result.stdout or '').strip() or 'unknown error'}")
Expand Down Expand Up @@ -2218,7 +2228,7 @@ def _search_files_rg(self, pattern: str, path: str, limit: int, offset: int) ->
# Try mtime-sorted first (rg 13+); fall back to unsorted if not supported.
cmd_sorted = (
f"rg --files --sortr=modified -g {self._escape_shell_arg(glob_pattern)} "
f"{self._escape_shell_arg(path)} 2>/dev/null "
f"{self._escape_shell_raw(path)} 2>/dev/null "
f"| head -n {fetch_limit}"
)
result = self._exec(cmd_sorted, timeout=60)
Expand All @@ -2229,7 +2239,7 @@ def _search_files_rg(self, pattern: str, path: str, limit: int, offset: int) ->
# --sortr may have failed on older rg; retry without it.
cmd_plain = (
f"rg --files -g {self._escape_shell_arg(glob_pattern)} "
f"{self._escape_shell_arg(path)} 2>/dev/null "
f"{self._escape_shell_raw(path)} 2>/dev/null "
f"| head -n {fetch_limit}"
)
result = self._exec(cmd_plain, timeout=60)
Expand Down Expand Up @@ -2284,8 +2294,8 @@ def _search_with_rg(self, pattern: str, path: str, file_glob: Optional[str],
cmd_parts.append("-c") # Count per file

# Add pattern and path
cmd_parts.append(self._escape_shell_arg(pattern))
cmd_parts.append(self._escape_shell_arg(path))
cmd_parts.append(self._escape_shell_raw(pattern))
cmd_parts.append(self._escape_shell_raw(path))

# Fetch extra rows so we can report the true total before slicing.
# For context mode, rg emits separator lines ("--") between groups,
Expand Down Expand Up @@ -2414,8 +2424,8 @@ def _search_with_grep(self, pattern: str, path: str, file_glob: Optional[str],
cmd_parts.append("-c")

# Add pattern and path
cmd_parts.append(self._escape_shell_arg(pattern))
cmd_parts.append(self._escape_shell_arg(path))
cmd_parts.append(self._escape_shell_raw(pattern))
cmd_parts.append(self._escape_shell_raw(path))

# Fetch generously so we can compute total before slicing
fetch_limit = limit + offset + (200 if context > 0 else 0)
Expand Down