mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user