fix: Prevent deleted projects from being recreated by background sync (#193) (#370)

Signed-off-by: Claude <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2025-10-16 15:16:24 -05:00
committed by GitHub
parent b7497d7484
commit 449b62d947
4 changed files with 152 additions and 3 deletions
+7 -3
View File
@@ -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()
+11
View File
@@ -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
+52
View File
@@ -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)
+82
View File
@@ -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"
)