diff --git a/hermes_state.py b/hermes_state.py index aba3b92992476..948f30e35e2f7 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -189,7 +189,16 @@ def _escape_like(text: str) -> str: def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]: prefix = cwd_prefix.rstrip("/\\") or cwd_prefix - return "(s.cwd = ? OR s.cwd LIKE ? OR s.cwd LIKE ?)", [prefix, f"{prefix}/%", f"{prefix}\\%"] + # ``_`` and ``%`` are LIKE wildcards but ordinary characters in a path + # (``my_project``), so an unescaped prefix also matches sibling directories. + # Escape the needle and pair it with ESCAPE; the literal separator + # backslash in the Windows pattern needs escaping for the same reason. The + # ``=`` arm is an exact compare and keeps the raw prefix. + esc = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + return ( + "(s.cwd = ? OR s.cwd LIKE ? ESCAPE '\\' OR s.cwd LIKE ? ESCAPE '\\')", + [prefix, f"{esc}/%", f"{esc}\\\\%"], + ) def _workspace_key_clause(key: str) -> Tuple[str, List[str]]: diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 02d4c874c9d6d..6434522ba446c 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -1121,6 +1121,35 @@ class TestPruneSessionFilters: assert {r["id"] for r in db.list_prune_candidates(title_like="smoke")} == {"smoke"} assert {r["id"] for r in db.list_prune_candidates(title_like=r"c:\tmp")} == {"winpath"} + def test_cwd_prefix_underscore_is_literal_not_a_wildcard(self, db): + """``_`` is a LIKE wildcard but an ordinary character in a path, so an + unescaped prefix also matched a same-length sibling directory — and + prune_sessions deletes what it matches.""" + self._mk(db, "target", cwd="/home/me/my_project/src") + self._mk(db, "sibling", cwd="/home/me/myXproject/src") + + rows = db.list_prune_candidates(cwd_prefix="/home/me/my_project") + assert {r["id"] for r in rows} == {"target"} + + pruned = db.prune_sessions(older_than_days=None, cwd_prefix="/home/me/my_project") + assert pruned == 1 + assert db.get_session("sibling") is not None + + def test_cwd_prefix_percent_does_not_select_everything(self, db): + self._mk(db, "a", cwd="/home/me/one") + self._mk(db, "b", cwd="/home/me/two") + + assert db.list_prune_candidates(cwd_prefix="/home/me/%") == [] + + def test_cwd_prefix_still_matches_the_directory_and_its_children(self, db): + """Control: the prefix must keep matching itself and anything under it.""" + self._mk(db, "root", cwd="/home/me/proj") + self._mk(db, "child", cwd="/home/me/proj/src") + self._mk(db, "outside", cwd="/home/me/other") + + rows = db.list_prune_candidates(cwd_prefix="/home/me/proj") + assert {r["id"] for r in rows} == {"root", "child"} + def test_unknown_filter_rejected(self, db): import pytest as _pytest with _pytest.raises(TypeError):