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)
+51
View File
@@ -222,6 +222,57 @@ async def test_initialize_file_sync_no_constraint_when_env_unset(
assert _FakeWatchService.last_kwargs.get("constrained_project") is None
@pytest.mark.asyncio
async def test_initialize_file_sync_skips_project_with_non_absolute_path(
app_config: BasicMemoryConfig, config_manager, config_home, monkeypatch
):
"""Projects without an absolute local path are excluded from background sync (issue #949).
A config entry of ``{"path": ""}`` defaults to LOCAL mode and is not
recognized as cloud, yet Path("") resolves to the process cwd. Syncing it
would inject frontmatter into unrelated files, so it must be skipped.
"""
await db.shutdown_db()
try:
from basic_memory.config import ProjectEntry
good = config_home / "good"
good.mkdir(parents=True, exist_ok=True)
updated = app_config.model_copy(
update={
"projects": {
"good": ProjectEntry(path=str(good)),
# No mode -> defaults to LOCAL, empty (cwd-relative) path.
"empty-path": ProjectEntry(path=""),
},
"default_project": "good",
}
)
config_manager.save_config(updated)
await initialize_database(updated)
await reconcile_projects_with_config(updated)
_disable_test_env_short_circuit(monkeypatch)
monkeypatch.setattr("basic_memory.sync.WatchService", _FakeWatchService)
infos: list[str] = []
monkeypatch.setattr(
"basic_memory.services.initialization.logger.info",
lambda message, *args, **kwargs: infos.append(message),
)
await initialize_file_sync(updated, quiet=True)
skip_logs = [m for m in infos if "without an absolute local path" in m]
assert skip_logs, "expected a skip log for the empty-path project"
assert "empty-path" in skip_logs[0]
assert "good" not in skip_logs[0]
finally:
await db.shutdown_db()
@pytest.mark.asyncio
async def test_initialize_app_no_precedence_warning_when_not_conflicting(
app_config: BasicMemoryConfig, monkeypatch
+43
View File
@@ -236,6 +236,49 @@ async def test_run_keeps_cloud_projects_with_local_bisync(monkeypatch, tmp_path)
assert seen_project_names == [["local-project", "cloud-bisync"]]
@pytest.mark.asyncio
async def test_run_filters_empty_path_local_mode_project(monkeypatch, tmp_path):
"""A project with an empty path is skipped even when mode is LOCAL (issue #949).
ProjectEntry.mode defaults to LOCAL, so a hand-edited config entry of
``{"path": ""}`` is not recognized as cloud. The watch cycle must still skip
it: Path("") resolves to the process cwd, and watching that would mutate
whatever directory the server was launched from.
"""
config = BasicMemoryConfig(
watch_project_reload_interval=1,
projects={
"local-project": {"path": str(tmp_path / "local"), "mode": "local"},
# No explicit mode -> defaults to LOCAL, with an empty (cwd-relative) path.
"empty-path": {"path": ""},
},
)
repo = _Repo(
projects_return=[
Project(id=1, name="local-project", path=str(tmp_path / "local"), permalink="local"),
Project(id=2, name="empty-path", path="", permalink="empty-path"),
]
)
watch_service = _watch_service(config, repo)
seen_project_names: list[list[str]] = []
async def watch_cycle_stub(projects, stop_event):
seen_project_names.append([p.name for p in projects])
watch_service.state.running = False
stop_event.set()
async def fake_write_status():
return None
monkeypatch.setattr(watch_service, "_watch_projects_cycle", watch_cycle_stub)
monkeypatch.setattr(watch_service, "write_status", fake_write_status)
await watch_service.run()
assert seen_project_names == [["local-project"]]
@pytest.mark.asyncio
async def test_run_continues_after_cycle_error(monkeypatch, tmp_path):
config = BasicMemoryConfig(