Skip to content

Commit 7570e24

Browse files
committed
fix(sync): guard overlapping hidden watch roots
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent ed9a17b commit 7570e24

2 files changed

Lines changed: 65 additions & 36 deletions

File tree

src/basic_memory/sync/watch_service.py

Lines changed: 50 additions & 36 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._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,48 @@ 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._watch_filter_roots
131+
self._watch_filter_roots = tuple(
132+
Path(project.path).expanduser().resolve() for project in projects
133+
)
129134

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

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-
]
162+
# create coroutines to handle changes
163+
change_handlers = [
164+
self.handle_changes(project, set(changes))
165+
for project, changes in project_changes.items()
166+
]
161167

162-
# process changes
163-
await asyncio.gather(*change_handlers)
168+
# process changes
169+
await asyncio.gather(*change_handlers)
170+
finally:
171+
self._watch_filter_roots = previous_filter_roots
164172

165173
async def _select_projects_to_watch(self) -> list[Project]:
166174
"""Return the set of projects this watch cycle should observe.
@@ -276,14 +284,20 @@ def filter_changes(self, change: Change, path: str) -> bool: # pragma: no cover
276284

277285
path_obj = Path(path).expanduser().resolve()
278286

279-
project_paths = sorted(
280-
(
287+
project_roots = self._watch_filter_roots
288+
if project_roots is None:
289+
project_roots = tuple(
281290
Path(entry.path).expanduser().resolve()
282291
for entry in self.app_config.projects.values()
283292
if entry.path
284-
),
293+
)
294+
295+
project_paths = sorted(
296+
project_roots,
297+
# Trigger: configured project roots can overlap.
298+
# Why: an enclosing project's hidden directory should still hide descendants.
299+
# Outcome: choose the outermost matching root when checking hidden path parts.
285300
key=lambda project_path: len(project_path.parts),
286-
reverse=True,
287301
)
288302

289303
relative_path = None

tests/sync/test_watch_service_edge_cases.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def test_filter_changes_allows_project_under_hidden_parent(watch_service, tmp_pa
2828
project_home = tmp_path / ".claude" / "projects" / "memory"
2929
project_home.mkdir(parents=True)
3030
watch_service.app_config.projects["hidden-parent"] = ProjectEntry(path=str(project_home))
31+
watch_service._watch_filter_roots = (project_home.resolve(),)
3132

3233
visible_note = project_home / "notes" / "visible.md"
3334
hidden_note = project_home / "notes" / ".drafts" / "hidden.md"
@@ -36,6 +37,20 @@ def test_filter_changes_allows_project_under_hidden_parent(watch_service, tmp_pa
3637
assert watch_service.filter_changes(Change.added, str(hidden_note)) is False
3738

3839

40+
def test_filter_changes_rejects_nested_project_inside_hidden_directory(watch_service, tmp_path):
41+
"""A nested project must not make its enclosing project's hidden path visible."""
42+
outer_project = tmp_path / "outer"
43+
nested_project = outer_project / ".private" / "subproject"
44+
nested_project.mkdir(parents=True)
45+
watch_service.app_config.projects["outer"] = ProjectEntry(path=str(outer_project))
46+
watch_service.app_config.projects["nested"] = ProjectEntry(path=str(nested_project))
47+
watch_service._watch_filter_roots = (outer_project.resolve(), nested_project.resolve())
48+
49+
nested_note = nested_project / "notes" / "visible.md"
50+
51+
assert watch_service.filter_changes(Change.added, str(nested_note)) is False
52+
53+
3954
def test_filter_changes_hidden_path(watch_service, project_config):
4055
"""Test the filter_changes method with hidden files/directories."""
4156
# Hidden file (starts with dot)

0 commit comments

Comments
 (0)