Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions src/basic_memory/services/project_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,14 +261,36 @@ async def add_project(self, name: str, path: str, set_default: bool = False) ->
if config_default is not None:
db_default_project = await self.repository.get_by_name(config_default)
if db_default_project is None:
await self.repository.set_as_default(created_project.id)
self.config_manager.set_default_project(name)
logger.info(
"Promoted project '%s' to default because configured default '%s' "
"is missing from database",
name,
config_default,
)
# Trigger: config names a default project that has no database row
# (the fresh-config wedge from issue #974).
# Why: synchronize_projects treats an existing database default as
# authoritative, so a surviving default must win over promoting
# the just-added project. set_default_project raises for names
# absent from config — and reconciliation deletes such rows —
# so a database default unknown to config cannot be repointed to.
# Outcome: config is repointed at a usable database default when one
# exists; otherwise the added project becomes the default.
db_default = await self.repository.get_default_project()
if (
db_default is not None
and self.config_manager.get_project(db_default.name)[0] is not None
):
self.config_manager.set_default_project(db_default.name)
logger.info(
"Repointed config default from missing '%s' at existing "
"database default '%s'",
config_default,
db_default.name,
)
else:
await self.repository.set_as_default(created_project.id)
self.config_manager.set_default_project(name)
logger.info(
"Promoted project '%s' to default because configured default '%s' "
"is missing from database",
name,
config_default,
)

logger.info(f"Project '{name}' added at {resolved_path}")

Expand Down
48 changes: 47 additions & 1 deletion tests/services/test_project_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ async def test_add_project_promotes_when_config_default_missing_from_db(
):
"""Regression #974: config default exists only in config, not DB — promote on add."""
from basic_memory import config as config_module
from basic_memory.config import ProjectConfig, ProjectEntry
from basic_memory.config import ProjectEntry
from basic_memory.markdown.entity_parser import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.repository.project_repository import ProjectRepository
Expand Down Expand Up @@ -480,6 +480,52 @@ async def test_add_project_promotes_when_config_default_missing_from_db(
assert await repo.get_by_name("main") is None


@pytest.mark.asyncio
async def test_add_project_preserves_existing_db_default(
project_service: ProjectService, config_manager: ConfigManager, test_project
):
"""The #974 repair must not steal an existing database default.

Drift state: config's default_project names a project with no database row,
but the database still holds a valid default of its own. synchronize_projects
resolves this by trusting the database default, so add_project's repair must
repoint config at it rather than promoting the just-added project.
"""
config_default = config_manager.default_project
assert config_default is not None

surviving_name = f"test-surviving-default-{os.urandom(4).hex()}"
added_name = f"test-no-steal-{os.urandom(4).hex()}"
with tempfile.TemporaryDirectory() as temp_dir:
surviving_path = str(Path(temp_dir) / "surviving")
os.makedirs(surviving_path, exist_ok=True)

# A normally-added project that then becomes the database default,
# while config's default_project still names the fixture project.
await project_service.add_project(surviving_name, surviving_path)
surviving = await project_service.repository.get_by_name(surviving_name)
assert surviving is not None
await project_service.repository.set_as_default(surviving.id)

# Wedge config: its named default loses its database row.
await project_service.repository.delete(test_project.id)
assert await project_service.get_project(config_default) is None

added_path = str(Path(temp_dir) / "added")
os.makedirs(added_path, exist_ok=True)
await project_service.add_project(added_name, added_path)

# The surviving database default wins: config is repointed at it and
# the newly added project is not promoted.
assert config_manager.default_project == surviving_name
db_default = await project_service.repository.get_default_project()
assert db_default is not None
assert db_default.name == surviving_name
added = await project_service.repository.get_by_name(added_name)
assert added is not None
assert added.is_default is not True


@pytest.mark.asyncio
async def test_add_project_default_parameter_omitted(project_service: ProjectService):
"""Test adding a project without set_default parameter defaults to False behavior."""
Expand Down
Loading