Skip to content

Commit 4aa0cbd

Browse files
authored
fix(sync): ignore hidden paths relative to watched project (#815)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 55f3142 commit 4aa0cbd

3 files changed

Lines changed: 145 additions & 35 deletions

File tree

src/basic_memory/sync/watch_service.py

Lines changed: 77 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def __init__(
9393
self.status_path = app_config.data_dir_path / WATCH_STATUS_JSON
9494
self.status_path.parent.mkdir(parents=True, exist_ok=True)
9595
self._ignore_patterns_cache: dict[Path, Set[str]] = {}
96+
self._sorted_watch_filter_roots: tuple[Path, ...] | None = None
9697
self._sync_service_factory = sync_service_factory
9798
# When set (typically from BASIC_MEMORY_MCP_PROJECT), the watch cycle
9899
# only observes this project. Without it, each `basic-memory mcp --project X`
@@ -126,41 +127,54 @@ def _get_ignore_patterns(self, project_path: Path) -> Set[str]:
126127
async def _watch_projects_cycle(self, projects: Sequence[Project], stop_event: asyncio.Event):
127128
"""Run one cycle of watching the given projects until stop_event is set."""
128129
project_paths = [project.path for project in projects]
130+
previous_filter_roots = self._sorted_watch_filter_roots
131+
self._sorted_watch_filter_roots = tuple(
132+
sorted(
133+
(Path(project.path).expanduser().resolve() for project in projects),
134+
# Trigger: configured project roots can overlap.
135+
# Why: an enclosing project's hidden directory should still hide descendants.
136+
# Outcome: choose the outermost matching root when checking hidden path parts.
137+
key=lambda project_path: len(project_path.parts),
138+
)
139+
)
129140

130-
async for changes in awatch(
131-
*project_paths,
132-
debounce=self.app_config.sync_delay,
133-
watch_filter=self.filter_changes,
134-
recursive=True,
135-
stop_event=stop_event,
136-
):
137-
# group changes by project and filter using ignore patterns
138-
project_changes = defaultdict(list)
139-
for change, path in changes:
140-
for project in projects:
141-
if self.is_project_path(project, path):
142-
# Check if the file should be ignored based on gitignore patterns
143-
project_path = Path(project.path)
144-
file_path = Path(path)
145-
ignore_patterns = self._get_ignore_patterns(project_path)
146-
147-
if should_ignore_path(file_path, project_path, ignore_patterns):
148-
logger.trace(
149-
f"Ignoring watched file change: {file_path.relative_to(project_path)}"
150-
)
151-
continue
152-
153-
project_changes[project].append((change, path))
154-
break
141+
try:
142+
async for changes in awatch(
143+
*project_paths,
144+
debounce=self.app_config.sync_delay,
145+
watch_filter=self.filter_changes,
146+
recursive=True,
147+
stop_event=stop_event,
148+
):
149+
# group changes by project and filter using ignore patterns
150+
project_changes = defaultdict(list)
151+
for change, path in changes:
152+
for project in projects:
153+
if self.is_project_path(project, path):
154+
# Check if the file should be ignored based on gitignore patterns
155+
project_path = Path(project.path)
156+
file_path = Path(path)
157+
ignore_patterns = self._get_ignore_patterns(project_path)
158+
159+
if should_ignore_path(file_path, project_path, ignore_patterns):
160+
logger.trace(
161+
f"Ignoring watched file change: {file_path.relative_to(project_path)}"
162+
)
163+
continue
164+
165+
project_changes[project].append((change, path))
166+
break
155167

156-
# create coroutines to handle changes
157-
change_handlers = [
158-
self.handle_changes(project, set(changes))
159-
for project, changes in project_changes.items()
160-
]
168+
# create coroutines to handle changes
169+
change_handlers = [
170+
self.handle_changes(project, set(changes))
171+
for project, changes in project_changes.items()
172+
]
161173

162-
# process changes
163-
await asyncio.gather(*change_handlers)
174+
# process changes
175+
await asyncio.gather(*change_handlers)
176+
finally:
177+
self._sorted_watch_filter_roots = previous_filter_roots
164178

165179
async def _select_projects_to_watch(self) -> list[Project]:
166180
"""Return the set of projects this watch cycle should observe.
@@ -267,15 +281,43 @@ async def run(self): # pragma: no cover
267281
self.state.running = False
268282
await self.write_status()
269283

270-
def filter_changes(self, change: Change, path: str) -> bool: # pragma: no cover
284+
def filter_changes(self, change: Change, path: str) -> bool:
271285
"""Filter to only watch non-hidden files and directories.
272286
273287
Returns:
274288
True if the file should be watched, False if it should be ignored
275289
"""
276290

277-
# Skip hidden directories and files
278-
path_parts = Path(path).parts
291+
path_obj = Path(path).expanduser().resolve()
292+
293+
project_paths = self._sorted_watch_filter_roots
294+
if project_paths is None:
295+
project_paths = tuple(
296+
sorted(
297+
(
298+
Path(entry.path).expanduser().resolve()
299+
for entry in self.app_config.projects.values()
300+
if entry.path
301+
),
302+
# Trigger: direct callers may not run inside a watch cycle.
303+
# Why: tests and one-off calls still need the same hidden-path semantics.
304+
# Outcome: compute the stable outermost-first order only for fallback calls.
305+
key=lambda project_path: len(project_path.parts),
306+
)
307+
)
308+
309+
relative_path = None
310+
for project_path in project_paths:
311+
try:
312+
relative_path = path_obj.relative_to(project_path)
313+
break
314+
except ValueError:
315+
continue
316+
317+
# Trigger: a project may live under a hidden parent such as ~/.claude.
318+
# Why: only dotfiles and dot-directories inside the watched project should be ignored.
319+
# Outcome: hidden parents outside the project root do not mute legitimate project changes.
320+
path_parts = relative_path.parts if relative_path is not None else path_obj.parts
279321
for part in path_parts:
280322
if part.startswith("."):
281323
return False

test-int/mcp/test_string_params_integration.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ async def test_search_notes_entity_types_as_string(mcp_server, app, test_project
2828
{
2929
"project": test_project.name,
3030
"query": "coercion",
31+
"search_type": "text",
3132
"entity_types": '["entity"]',
3233
},
3334
)
@@ -54,6 +55,7 @@ async def test_search_notes_note_types_as_string(mcp_server, app, test_project):
5455
{
5556
"project": test_project.name,
5657
"query": "coercion",
58+
"search_type": "text",
5759
"note_types": '["note"]',
5860
},
5961
)
@@ -81,6 +83,7 @@ async def test_search_notes_tags_as_string(mcp_server, app, test_project):
8183
{
8284
"project": test_project.name,
8385
"query": "tagged",
86+
"search_type": "text",
8487
"tags": '["alpha"]',
8588
},
8689
)
@@ -107,6 +110,7 @@ async def test_search_notes_metadata_filters_as_string(mcp_server, app, test_pro
107110
{
108111
"project": test_project.name,
109112
"query": "metadata",
113+
"search_type": "text",
110114
"metadata_filters": '{"type": "note"}',
111115
},
112116
)

tests/sync/test_watch_service_edge_cases.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
"""Test edge cases in the WatchService."""
22

3+
import builtins
4+
35
import pytest
46
from watchfiles import Change
57

8+
from basic_memory.config import ProjectEntry
9+
610

711
def test_filter_changes_valid_path(watch_service, project_config):
812
"""Test the filter_changes method with valid non-hidden paths."""
@@ -21,6 +25,66 @@ def test_filter_changes_valid_path(watch_service, project_config):
2125
)
2226

2327

28+
def test_filter_changes_allows_project_under_hidden_parent(watch_service, tmp_path):
29+
"""Hidden parent directories outside the project root must not mute the watcher."""
30+
project_home = tmp_path / ".claude" / "projects" / "memory"
31+
project_home.mkdir(parents=True)
32+
watch_service.app_config.projects["hidden-parent"] = ProjectEntry(path=str(project_home))
33+
watch_service._sorted_watch_filter_roots = (project_home.resolve(),)
34+
35+
visible_note = project_home / "notes" / "visible.md"
36+
hidden_note = project_home / "notes" / ".drafts" / "hidden.md"
37+
38+
assert watch_service.filter_changes(Change.added, str(visible_note)) is True
39+
assert watch_service.filter_changes(Change.added, str(hidden_note)) is False
40+
41+
42+
def test_filter_changes_rejects_nested_project_inside_hidden_directory(watch_service, tmp_path):
43+
"""A nested project must not make its enclosing project's hidden path visible."""
44+
outer_project = tmp_path / "outer"
45+
nested_project = outer_project / ".private" / "subproject"
46+
nested_project.mkdir(parents=True)
47+
watch_service.app_config.projects["outer"] = ProjectEntry(path=str(outer_project))
48+
watch_service.app_config.projects["nested"] = ProjectEntry(path=str(nested_project))
49+
watch_service._sorted_watch_filter_roots = (
50+
outer_project.resolve(),
51+
nested_project.resolve(),
52+
)
53+
54+
nested_note = nested_project / "notes" / "visible.md"
55+
56+
assert watch_service.filter_changes(Change.added, str(nested_note)) is False
57+
58+
59+
def test_filter_changes_uses_cached_sorted_roots_without_resorting(
60+
monkeypatch,
61+
watch_service,
62+
tmp_path,
63+
):
64+
"""The watch callback hot path should not sort roots after the cycle cached them."""
65+
project_home = tmp_path / "project"
66+
project_home.mkdir()
67+
watch_service._sorted_watch_filter_roots = (project_home.resolve(),)
68+
69+
def fail_if_sorted(*args, **kwargs):
70+
raise AssertionError("cached watch roots should already be sorted")
71+
72+
monkeypatch.setattr(builtins, "sorted", fail_if_sorted)
73+
74+
assert watch_service.filter_changes(Change.added, str(project_home / "note.md")) is True
75+
76+
77+
def test_filter_changes_path_outside_all_projects(watch_service, tmp_path):
78+
"""Unmatched paths should still use full-path hidden filtering as a fallback."""
79+
watch_service._sorted_watch_filter_roots = ()
80+
81+
unrelated = tmp_path / "unrelated" / "file.md"
82+
hidden_unrelated = tmp_path / ".hidden" / "file.md"
83+
84+
assert watch_service.filter_changes(Change.added, str(unrelated)) is True
85+
assert watch_service.filter_changes(Change.added, str(hidden_unrelated)) is False
86+
87+
2488
def test_filter_changes_hidden_path(watch_service, project_config):
2589
"""Test the filter_changes method with hidden files/directories."""
2690
# Hidden file (starts with dot)

0 commit comments

Comments
 (0)