@@ -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
0 commit comments