Skip to content

Commit 449b62d

Browse files
authored
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>
1 parent b7497d7 commit 449b62d

4 files changed

Lines changed: 152 additions & 3 deletions

File tree

src/basic_memory/services/project_service.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -360,11 +360,15 @@ async def synchronize_projects(self) -> None: # pragma: no cover
360360
}
361361
await self.repository.create(project_data)
362362

363-
# Add projects that exist in DB but not in config to config
363+
# Remove projects that exist in DB but not in config
364+
# Config is the source of truth - if a project was deleted from config,
365+
# it should be deleted from DB too (fixes issue #193)
364366
for name, project in db_projects_by_permalink.items():
365367
if name not in config_projects:
366-
logger.info(f"Adding project '{name}' to configuration")
367-
self.config_manager.add_project(name, project.path)
368+
logger.info(
369+
f"Removing project '{name}' from database (deleted from config, source of truth)"
370+
)
371+
await self.repository.delete(project.id)
368372

369373
# Ensure database default project state is consistent
370374
await self._ensure_single_default_project()

src/basic_memory/sync/watch_service.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,17 @@ async def handle_changes(self, project: Project, changes: Set[FileChange]) -> No
236236
# avoid circular imports
237237
from basic_memory.sync.sync_service import get_sync_service
238238

239+
# Check if project still exists in configuration before processing
240+
# This prevents deleted projects from being recreated by background sync
241+
from basic_memory.config import ConfigManager
242+
config_manager = ConfigManager()
243+
if project.name not in config_manager.projects and project.permalink not in config_manager.projects:
244+
logger.info(
245+
f"Skipping sync for deleted project: {project.name}, "
246+
f"change_count={len(changes)}"
247+
)
248+
return
249+
239250
sync_service = await get_sync_service(project)
240251
file_service = sync_service.file_service
241252

tests/services/test_project_service.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1198,3 +1198,55 @@ async def test_add_project_nested_validation_with_project_root(
11981198
# Clean up
11991199
if parent_project_name in project_service.projects:
12001200
await project_service.remove_project(parent_project_name)
1201+
1202+
1203+
@pytest.mark.asyncio
1204+
async def test_synchronize_projects_removes_db_only_projects(project_service: ProjectService):
1205+
"""Test that synchronize_projects removes projects that exist in DB but not in config.
1206+
1207+
This is a regression test for issue #193 where deleted projects would be re-added
1208+
to config during synchronization, causing them to reappear after deletion.
1209+
Config is the source of truth - if a project is deleted from config, it should be
1210+
removed from the database during synchronization.
1211+
"""
1212+
test_project_name = f"test-db-only-{os.urandom(4).hex()}"
1213+
with tempfile.TemporaryDirectory() as temp_dir:
1214+
test_root = Path(temp_dir)
1215+
test_project_path = str(test_root / "test-db-only")
1216+
1217+
# Make sure the test directory exists
1218+
os.makedirs(test_project_path, exist_ok=True)
1219+
1220+
try:
1221+
# Add project to database only (not to config) - simulating orphaned DB entry
1222+
project_data = {
1223+
"name": test_project_name,
1224+
"path": test_project_path,
1225+
"permalink": test_project_name.lower().replace(" ", "-"),
1226+
"is_active": True,
1227+
}
1228+
created_project = await project_service.repository.create(project_data)
1229+
1230+
# Verify it exists in DB but not in config
1231+
db_project = await project_service.repository.get_by_name(test_project_name)
1232+
assert db_project is not None
1233+
assert test_project_name not in project_service.projects
1234+
1235+
# Call synchronize_projects - this should remove the orphaned DB entry
1236+
# because config is the source of truth
1237+
await project_service.synchronize_projects()
1238+
1239+
# Verify project was removed from database
1240+
db_project_after = await project_service.repository.get_by_name(test_project_name)
1241+
assert db_project_after is None, (
1242+
"Project should be removed from DB when not in config (config is source of truth)"
1243+
)
1244+
1245+
# Verify it's still not in config
1246+
assert test_project_name not in project_service.projects
1247+
1248+
finally:
1249+
# Clean up if needed
1250+
db_project = await project_service.repository.get_by_name(test_project_name)
1251+
if db_project:
1252+
await project_service.repository.delete(db_project.id)

tests/sync/test_watch_service.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,3 +448,85 @@ def test_is_project_path(watch_service, tmp_path):
448448

449449
# Test the project path itself
450450
assert watch_service.is_project_path(project, project_path) is False
451+
452+
453+
@pytest.mark.asyncio
454+
async def test_handle_changes_skips_deleted_project(
455+
watch_service, project_config, test_project, sync_service, project_service, tmp_path
456+
):
457+
"""Test that handle_changes skips processing changes for projects that have been deleted.
458+
459+
This is a regression test for issue #193 where deleted projects were being recreated
460+
by background sync because the directory still existed on disk.
461+
"""
462+
from textwrap import dedent
463+
464+
project_dir = project_config.home
465+
466+
# Create a test file in the project
467+
test_file = project_dir / "test_note.md"
468+
content = dedent("""
469+
---
470+
type: knowledge
471+
---
472+
# Test Note
473+
Test content
474+
""").strip()
475+
await create_test_file(test_file, content)
476+
477+
# Initial sync to create the entity
478+
await sync_service.sync(project_dir)
479+
480+
# Verify entity was created
481+
entity_before = await sync_service.entity_repository.get_by_file_path("test_note.md")
482+
assert entity_before is not None
483+
484+
# Create a second project directly in the database and set it as default
485+
# so we can remove the first one (cannot remove default project)
486+
other_project_path = str(tmp_path.parent / "other-project-for-test")
487+
project_data = {
488+
"name": "other-project",
489+
"path": other_project_path,
490+
"permalink": "other-project",
491+
"is_active": True,
492+
}
493+
other_project = await project_service.repository.create(project_data)
494+
await project_service.repository.set_as_default(other_project.id)
495+
496+
# Also add to config
497+
config = project_service.config_manager.load_config()
498+
config.projects["other-project"] = other_project_path
499+
config.default_project = "other-project"
500+
project_service.config_manager.save_config(config)
501+
502+
# Remove the test project from configuration (simulating project deletion)
503+
# This should prevent background sync from processing changes
504+
await project_service.remove_project(test_project.name)
505+
506+
# Simulate file changes after project deletion
507+
# These changes should be ignored by the watch service
508+
modified_content = dedent("""
509+
---
510+
type: knowledge
511+
---
512+
# Test Note
513+
Modified content after project deletion
514+
""").strip()
515+
await create_test_file(test_file, modified_content)
516+
517+
changes = {(Change.modified, str(test_file))}
518+
519+
# Handle changes - should skip processing since project is deleted
520+
await watch_service.handle_changes(test_project, changes)
521+
522+
# Verify that the entity was NOT re-created or updated
523+
# Since the project was deleted, the database should still have the old state
524+
# or the entity should be gone entirely if cleanup happened
525+
entity_after = await sync_service.entity_repository.get_by_file_path("test_note.md")
526+
527+
# The entity might be deleted or unchanged, but it should not be updated with new content
528+
if entity_after is not None:
529+
# If the entity still exists, it should have the old content, not the new content
530+
assert entity_after.checksum == entity_before.checksum, (
531+
"Entity should not be updated for deleted project"
532+
)

0 commit comments

Comments
 (0)