fix(sync): skip projects without an absolute local path (#949)

A project entry in config.json with an empty path (e.g. `{"path": ""}`)
caused background sync and the watch service to adopt the process cwd as
the project root, injecting Basic Memory frontmatter into unrelated
markdown files.

The existing guards only skipped a project when get_project_mode()
returned CLOUD. But ProjectEntry.mode defaults to LOCAL, so an empty- or
relative-path entry without an explicit mode slipped through, and
Path("") resolves against the current working directory.

Gate local sync and watching on the path itself: any project whose path
is not absolute is excluded, regardless of mode. Legitimate local
projects are always resolved to absolute paths at creation, and cloud
projects with a real local bisync copy keep their absolute path and are
still synced/watched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
This commit is contained in:
Drew Cain
2026-06-12 16:31:38 -05:00
committed by Drew Cain
parent d46c68806e
commit 8dd6451dfe
4 changed files with 120 additions and 26 deletions
+15 -13
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from loguru import logger
from basic_memory import db
from basic_memory.config import BasicMemoryConfig, DatabaseBackend, ProjectMode
from basic_memory.config import BasicMemoryConfig, DatabaseBackend
from basic_memory.models import Project
from basic_memory.repository import (
ProjectRepository,
@@ -120,18 +120,20 @@ async def initialize_file_sync(
active_projects = [p for p in active_projects if p.name == constrained_project]
logger.info(f"Background sync constrained to project: {constrained_project}")
# Skip cloud-mode projects that have no local directory.
# Cloud projects with a local bisync copy (absolute path) are kept for local sync.
cloud_skip = []
for p in active_projects:
if app_config.get_project_mode(p.name) == ProjectMode.CLOUD:
entry = app_config.projects.get(p.name)
if entry and Path(entry.path).is_absolute():
continue # Cloud project with local bisync copy — keep for local sync
cloud_skip.append(p.name)
if cloud_skip:
active_projects = [p for p in active_projects if p.name not in cloud_skip]
logger.info(f"Skipping cloud-mode projects for local sync: {cloud_skip}")
# Only projects with an absolute local path are safe to sync.
# Trigger: a project whose path is empty or relative.
# Why: Path("") and other relative paths resolve against the process cwd, so
# syncing such a project would adopt whatever directory the server was
# launched from as the project root and inject frontmatter into unrelated
# markdown files there (issue #949). Empty paths come from cloud-only
# projects, but also from any hand-edited config that left mode at the
# LOCAL default, so the gate is on the path itself, not the project mode.
# Outcome: such projects are excluded from local sync. Cloud projects that
# have a real local bisync copy keep their absolute path and still sync.
skip = [p.name for p in active_projects if not Path(p.path).is_absolute()]
if skip:
active_projects = [p for p in active_projects if p.name not in skip]
logger.info(f"Skipping projects without an absolute local path for sync: {skip}")
# Start sync for all projects as background tasks (non-blocking)
async def sync_project_background(project: Project):
+11 -13
View File
@@ -10,7 +10,7 @@ from typing import List, Optional, Set, Sequence, Callable, Awaitable, TYPE_CHEC
if TYPE_CHECKING:
from basic_memory.sync.sync_service import SyncService
from basic_memory.config import BasicMemoryConfig, ProjectMode, WATCH_STATUS_JSON
from basic_memory.config import BasicMemoryConfig, WATCH_STATUS_JSON
from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
from basic_memory.models import Project
from basic_memory.repository import ProjectRepository
@@ -184,24 +184,22 @@ class WatchService:
``--project``, only that project is watched. This keeps concurrent
MCP processes from producing duplicate watchers that race on the
same files.
2. Cloud-only projects without a local bisync copy are skipped so we
don't watch a path that does not exist on disk.
2. Projects without an absolute local path are skipped. A non-absolute
path (empty string for cloud-only projects, or any relative value)
resolves against the process cwd, so watching it would observe and
mutate whatever directory the server was launched from (issue #949).
Cloud projects with a local bisync copy keep their absolute path and
are still watched.
"""
projects = await self.project_repository.get_active_projects()
if self.constrained_project:
projects = [p for p in projects if p.name == self.constrained_project]
cloud_skip: list[str] = []
for p in projects:
if self.app_config.get_project_mode(p.name) == ProjectMode.CLOUD:
entry = self.app_config.projects.get(p.name)
if entry and Path(entry.path).is_absolute():
continue # Cloud project with local bisync copy — keep watching
cloud_skip.append(p.name)
if cloud_skip:
projects = [p for p in projects if p.name not in cloud_skip]
logger.debug(f"Skipping cloud-mode projects in watch cycle: {cloud_skip}")
skip = [p.name for p in projects if not Path(p.path).is_absolute()]
if skip:
projects = [p for p in projects if p.name not in skip]
logger.debug(f"Skipping projects without an absolute local path in watch cycle: {skip}")
return list(projects)