mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Signed-off-by: Claude <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -360,11 +360,15 @@ class ProjectService:
|
||||
}
|
||||
await self.repository.create(project_data)
|
||||
|
||||
# Add projects that exist in DB but not in config to config
|
||||
# Remove projects that exist in DB but not in config
|
||||
# Config is the source of truth - if a project was deleted from config,
|
||||
# it should be deleted from DB too (fixes issue #193)
|
||||
for name, project in db_projects_by_permalink.items():
|
||||
if name not in config_projects:
|
||||
logger.info(f"Adding project '{name}' to configuration")
|
||||
self.config_manager.add_project(name, project.path)
|
||||
logger.info(
|
||||
f"Removing project '{name}' from database (deleted from config, source of truth)"
|
||||
)
|
||||
await self.repository.delete(project.id)
|
||||
|
||||
# Ensure database default project state is consistent
|
||||
await self._ensure_single_default_project()
|
||||
|
||||
@@ -236,6 +236,17 @@ class WatchService:
|
||||
# avoid circular imports
|
||||
from basic_memory.sync.sync_service import get_sync_service
|
||||
|
||||
# Check if project still exists in configuration before processing
|
||||
# This prevents deleted projects from being recreated by background sync
|
||||
from basic_memory.config import ConfigManager
|
||||
config_manager = ConfigManager()
|
||||
if project.name not in config_manager.projects and project.permalink not in config_manager.projects:
|
||||
logger.info(
|
||||
f"Skipping sync for deleted project: {project.name}, "
|
||||
f"change_count={len(changes)}"
|
||||
)
|
||||
return
|
||||
|
||||
sync_service = await get_sync_service(project)
|
||||
file_service = sync_service.file_service
|
||||
|
||||
|
||||
@@ -1198,3 +1198,55 @@ async def test_add_project_nested_validation_with_project_root(
|
||||
# Clean up
|
||||
if parent_project_name in project_service.projects:
|
||||
await project_service.remove_project(parent_project_name)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_synchronize_projects_removes_db_only_projects(project_service: ProjectService):
|
||||
"""Test that synchronize_projects removes projects that exist in DB but not in config.
|
||||
|
||||
This is a regression test for issue #193 where deleted projects would be re-added
|
||||
to config during synchronization, causing them to reappear after deletion.
|
||||
Config is the source of truth - if a project is deleted from config, it should be
|
||||
removed from the database during synchronization.
|
||||
"""
|
||||
test_project_name = f"test-db-only-{os.urandom(4).hex()}"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_root = Path(temp_dir)
|
||||
test_project_path = str(test_root / "test-db-only")
|
||||
|
||||
# Make sure the test directory exists
|
||||
os.makedirs(test_project_path, exist_ok=True)
|
||||
|
||||
try:
|
||||
# Add project to database only (not to config) - simulating orphaned DB entry
|
||||
project_data = {
|
||||
"name": test_project_name,
|
||||
"path": test_project_path,
|
||||
"permalink": test_project_name.lower().replace(" ", "-"),
|
||||
"is_active": True,
|
||||
}
|
||||
created_project = await project_service.repository.create(project_data)
|
||||
|
||||
# Verify it exists in DB but not in config
|
||||
db_project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert db_project is not None
|
||||
assert test_project_name not in project_service.projects
|
||||
|
||||
# Call synchronize_projects - this should remove the orphaned DB entry
|
||||
# because config is the source of truth
|
||||
await project_service.synchronize_projects()
|
||||
|
||||
# Verify project was removed from database
|
||||
db_project_after = await project_service.repository.get_by_name(test_project_name)
|
||||
assert db_project_after is None, (
|
||||
"Project should be removed from DB when not in config (config is source of truth)"
|
||||
)
|
||||
|
||||
# Verify it's still not in config
|
||||
assert test_project_name not in project_service.projects
|
||||
|
||||
finally:
|
||||
# Clean up if needed
|
||||
db_project = await project_service.repository.get_by_name(test_project_name)
|
||||
if db_project:
|
||||
await project_service.repository.delete(db_project.id)
|
||||
|
||||
@@ -448,3 +448,85 @@ def test_is_project_path(watch_service, tmp_path):
|
||||
|
||||
# Test the project path itself
|
||||
assert watch_service.is_project_path(project, project_path) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_changes_skips_deleted_project(
|
||||
watch_service, project_config, test_project, sync_service, project_service, tmp_path
|
||||
):
|
||||
"""Test that handle_changes skips processing changes for projects that have been deleted.
|
||||
|
||||
This is a regression test for issue #193 where deleted projects were being recreated
|
||||
by background sync because the directory still existed on disk.
|
||||
"""
|
||||
from textwrap import dedent
|
||||
|
||||
project_dir = project_config.home
|
||||
|
||||
# Create a test file in the project
|
||||
test_file = project_dir / "test_note.md"
|
||||
content = dedent("""
|
||||
---
|
||||
type: knowledge
|
||||
---
|
||||
# Test Note
|
||||
Test content
|
||||
""").strip()
|
||||
await create_test_file(test_file, content)
|
||||
|
||||
# Initial sync to create the entity
|
||||
await sync_service.sync(project_dir)
|
||||
|
||||
# Verify entity was created
|
||||
entity_before = await sync_service.entity_repository.get_by_file_path("test_note.md")
|
||||
assert entity_before is not None
|
||||
|
||||
# Create a second project directly in the database and set it as default
|
||||
# so we can remove the first one (cannot remove default project)
|
||||
other_project_path = str(tmp_path.parent / "other-project-for-test")
|
||||
project_data = {
|
||||
"name": "other-project",
|
||||
"path": other_project_path,
|
||||
"permalink": "other-project",
|
||||
"is_active": True,
|
||||
}
|
||||
other_project = await project_service.repository.create(project_data)
|
||||
await project_service.repository.set_as_default(other_project.id)
|
||||
|
||||
# Also add to config
|
||||
config = project_service.config_manager.load_config()
|
||||
config.projects["other-project"] = other_project_path
|
||||
config.default_project = "other-project"
|
||||
project_service.config_manager.save_config(config)
|
||||
|
||||
# Remove the test project from configuration (simulating project deletion)
|
||||
# This should prevent background sync from processing changes
|
||||
await project_service.remove_project(test_project.name)
|
||||
|
||||
# Simulate file changes after project deletion
|
||||
# These changes should be ignored by the watch service
|
||||
modified_content = dedent("""
|
||||
---
|
||||
type: knowledge
|
||||
---
|
||||
# Test Note
|
||||
Modified content after project deletion
|
||||
""").strip()
|
||||
await create_test_file(test_file, modified_content)
|
||||
|
||||
changes = {(Change.modified, str(test_file))}
|
||||
|
||||
# Handle changes - should skip processing since project is deleted
|
||||
await watch_service.handle_changes(test_project, changes)
|
||||
|
||||
# Verify that the entity was NOT re-created or updated
|
||||
# Since the project was deleted, the database should still have the old state
|
||||
# or the entity should be gone entirely if cleanup happened
|
||||
entity_after = await sync_service.entity_repository.get_by_file_path("test_note.md")
|
||||
|
||||
# The entity might be deleted or unchanged, but it should not be updated with new content
|
||||
if entity_after is not None:
|
||||
# If the entity still exists, it should have the old content, not the new content
|
||||
assert entity_after.checksum == entity_before.checksum, (
|
||||
"Entity should not be updated for deleted project"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user