Skip to content

Commit c7e6eab

Browse files
phernandezclaude
andauthored
feat: Add delete_notes parameter to remove project endpoint (#391)
Signed-off-by: phernandez <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent bb8da31 commit c7e6eab

File tree

5 files changed

+225
-7
lines changed

5 files changed

+225
-7
lines changed

src/basic_memory/api/routers/project_router.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Router for project management."""
22

33
import os
4-
from fastapi import APIRouter, HTTPException, Path, Body, BackgroundTasks, Response
4+
from fastapi import APIRouter, HTTPException, Path, Body, BackgroundTasks, Response, Query
55
from typing import Optional
66
from loguru import logger
77

@@ -247,11 +247,15 @@ async def add_project(
247247
async def remove_project(
248248
project_service: ProjectServiceDep,
249249
name: str = Path(..., description="Name of the project to remove"),
250+
delete_notes: bool = Query(
251+
False, description="If True, delete project directory from filesystem"
252+
),
250253
) -> ProjectStatusResponse:
251254
"""Remove a project from configuration and database.
252255
253256
Args:
254257
name: The name of the project to remove
258+
delete_notes: If True, delete the project directory from the filesystem
255259
256260
Returns:
257261
Response confirming the project was removed
@@ -276,7 +280,7 @@ async def remove_project(
276280
detail += "This is the only project in your configuration."
277281
raise HTTPException(status_code=400, detail=detail)
278282

279-
await project_service.remove_project(name)
283+
await project_service.remove_project(name, delete_notes=delete_notes)
280284

281285
return ProjectStatusResponse(
282286
message=f"Project '{name}' removed successfully",

src/basic_memory/cli/commands/project.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,18 @@ async def _add_project():
119119
@project_app.command("remove")
120120
def remove_project(
121121
name: str = typer.Argument(..., help="Name of the project to remove"),
122+
delete_notes: bool = typer.Option(
123+
False, "--delete-notes", help="Delete project files from disk"
124+
),
122125
) -> None:
123126
"""Remove a project."""
124127

125128
async def _remove_project():
126129
async with get_client() as client:
127130
project_permalink = generate_permalink(name)
128-
response = await call_delete(client, f"/projects/{project_permalink}")
131+
response = await call_delete(
132+
client, f"/projects/{project_permalink}?delete_notes={delete_notes}"
133+
)
129134
return ProjectStatusResponse.model_validate(response.json())
130135

131136
try:
@@ -135,8 +140,9 @@ async def _remove_project():
135140
console.print(f"[red]Error removing project: {str(e)}[/red]")
136141
raise typer.Exit(1)
137142

138-
# Show this message regardless of method used
139-
console.print("[yellow]Note: The project files have not been deleted from disk.[/yellow]")
143+
# Show this message only if files were not deleted
144+
if not delete_notes:
145+
console.print("[yellow]Note: The project files have not been deleted from disk.[/yellow]")
140146

141147

142148
@project_app.command("default")

src/basic_memory/services/project_service.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Project management service for Basic Memory."""
22

3+
import asyncio
34
import json
45
import os
6+
import shutil
57
from datetime import datetime
68
from pathlib import Path
79
from typing import Dict, Optional, Sequence
@@ -219,28 +221,46 @@ async def add_project(self, name: str, path: str, set_default: bool = False) ->
219221

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

222-
async def remove_project(self, name: str) -> None:
224+
async def remove_project(self, name: str, delete_notes: bool = False) -> None:
223225
"""Remove a project from configuration and database.
224226
225227
Args:
226228
name: The name of the project to remove
229+
delete_notes: If True, delete the project directory from filesystem
227230
228231
Raises:
229232
ValueError: If the project doesn't exist or is the default project
230233
"""
231234
if not self.repository: # pragma: no cover
232235
raise ValueError("Repository is required for remove_project")
233236

237+
# Get project path before removing from config
238+
project = await self.get_project(name)
239+
project_path = project.path if project else None
240+
234241
# First remove from config (this will validate the project exists and is not default)
235242
self.config_manager.remove_project(name)
236243

237244
# Then remove from database using robust lookup
238-
project = await self.get_project(name)
239245
if project:
240246
await self.repository.delete(project.id)
241247

242248
logger.info(f"Project '{name}' removed from configuration and database")
243249

250+
# Optionally delete the project directory
251+
if delete_notes and project_path:
252+
try:
253+
path_obj = Path(project_path)
254+
if path_obj.exists() and path_obj.is_dir():
255+
await asyncio.to_thread(shutil.rmtree, project_path)
256+
logger.info(f"Deleted project directory: {project_path}")
257+
else:
258+
logger.warning(
259+
f"Project directory not found or not a directory: {project_path}"
260+
)
261+
except Exception as e:
262+
logger.warning(f"Failed to delete project directory {project_path}: {e}")
263+
244264
async def set_default_project(self, name: str) -> None:
245265
"""Set the default project in configuration and database.
246266

tests/api/test_project_router.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,3 +635,86 @@ async def test_create_project_fails_different_path(test_config, client, project_
635635
await project_service.remove_project(test_project_name)
636636
except Exception:
637637
pass
638+
639+
640+
@pytest.mark.asyncio
641+
async def test_remove_project_with_delete_notes_false(test_config, client, project_service):
642+
"""Test that removing a project with delete_notes=False leaves directory intact."""
643+
# Create a test project with actual directory
644+
test_project_name = "test-remove-keep-files"
645+
with tempfile.TemporaryDirectory() as temp_dir:
646+
test_path = Path(temp_dir) / "test-project"
647+
test_path.mkdir()
648+
test_file = test_path / "test.md"
649+
test_file.write_text("# Test Note")
650+
651+
await project_service.add_project(test_project_name, str(test_path))
652+
653+
# Remove the project without deleting files (default)
654+
response = await client.delete(f"/projects/{test_project_name}")
655+
656+
# Verify response
657+
assert response.status_code == 200
658+
data = response.json()
659+
assert data["status"] == "success"
660+
661+
# Verify project is removed from config/db
662+
removed_project = await project_service.get_project(test_project_name)
663+
assert removed_project is None
664+
665+
# Verify directory still exists
666+
assert test_path.exists()
667+
assert test_file.exists()
668+
669+
670+
@pytest.mark.asyncio
671+
async def test_remove_project_with_delete_notes_true(test_config, client, project_service):
672+
"""Test that removing a project with delete_notes=True deletes the directory."""
673+
# Create a test project with actual directory
674+
test_project_name = "test-remove-delete-files"
675+
with tempfile.TemporaryDirectory() as temp_dir:
676+
test_path = Path(temp_dir) / "test-project"
677+
test_path.mkdir()
678+
test_file = test_path / "test.md"
679+
test_file.write_text("# Test Note")
680+
681+
await project_service.add_project(test_project_name, str(test_path))
682+
683+
# Remove the project with delete_notes=True
684+
response = await client.delete(f"/projects/{test_project_name}?delete_notes=true")
685+
686+
# Verify response
687+
assert response.status_code == 200
688+
data = response.json()
689+
assert data["status"] == "success"
690+
691+
# Verify project is removed from config/db
692+
removed_project = await project_service.get_project(test_project_name)
693+
assert removed_project is None
694+
695+
# Verify directory is deleted
696+
assert not test_path.exists()
697+
698+
699+
@pytest.mark.asyncio
700+
async def test_remove_project_delete_notes_nonexistent_directory(
701+
test_config, client, project_service
702+
):
703+
"""Test that removing a project with delete_notes=True handles missing directory gracefully."""
704+
# Create a project pointing to a non-existent path
705+
test_project_name = "test-remove-missing-dir"
706+
test_path = "/tmp/this-directory-does-not-exist-12345"
707+
708+
await project_service.add_project(test_project_name, test_path)
709+
710+
# Remove the project with delete_notes=True (should not fail even if dir doesn't exist)
711+
response = await client.delete(f"/projects/{test_project_name}?delete_notes=true")
712+
713+
# Should succeed
714+
assert response.status_code == 200
715+
data = response.json()
716+
assert data["status"] == "success"
717+
718+
# Verify project is removed
719+
removed_project = await project_service.get_project(test_project_name)
720+
assert removed_project is None

tests/services/test_project_service.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,3 +1250,108 @@ async def test_synchronize_projects_removes_db_only_projects(project_service: Pr
12501250
db_project = await project_service.repository.get_by_name(test_project_name)
12511251
if db_project:
12521252
await project_service.repository.delete(db_project.id)
1253+
1254+
1255+
@pytest.mark.asyncio
1256+
async def test_remove_project_with_delete_notes_false(project_service: ProjectService):
1257+
"""Test that remove_project with delete_notes=False keeps directory intact."""
1258+
test_project_name = f"test-remove-keep-{os.urandom(4).hex()}"
1259+
with tempfile.TemporaryDirectory() as temp_dir:
1260+
test_root = Path(temp_dir)
1261+
test_project_path = test_root / "test-project"
1262+
test_project_path.mkdir()
1263+
test_file = test_project_path / "test.md"
1264+
test_file.write_text("# Test Note")
1265+
1266+
try:
1267+
# Add project
1268+
await project_service.add_project(test_project_name, str(test_project_path))
1269+
1270+
# Verify project exists
1271+
assert test_project_name in project_service.projects
1272+
assert test_project_path.exists()
1273+
assert test_file.exists()
1274+
1275+
# Remove project without deleting notes (default behavior)
1276+
await project_service.remove_project(test_project_name, delete_notes=False)
1277+
1278+
# Verify project is removed from config/db
1279+
assert test_project_name not in project_service.projects
1280+
db_project = await project_service.repository.get_by_name(test_project_name)
1281+
assert db_project is None
1282+
1283+
# Verify directory and files still exist
1284+
assert test_project_path.exists()
1285+
assert test_file.exists()
1286+
1287+
finally:
1288+
# Cleanup happens automatically with temp_dir context manager
1289+
pass
1290+
1291+
1292+
@pytest.mark.asyncio
1293+
async def test_remove_project_with_delete_notes_true(project_service: ProjectService):
1294+
"""Test that remove_project with delete_notes=True deletes directory."""
1295+
test_project_name = f"test-remove-delete-{os.urandom(4).hex()}"
1296+
with tempfile.TemporaryDirectory() as temp_dir:
1297+
test_root = Path(temp_dir)
1298+
test_project_path = test_root / "test-project"
1299+
test_project_path.mkdir()
1300+
test_file = test_project_path / "test.md"
1301+
test_file.write_text("# Test Note")
1302+
1303+
try:
1304+
# Add project
1305+
await project_service.add_project(test_project_name, str(test_project_path))
1306+
1307+
# Verify project exists
1308+
assert test_project_name in project_service.projects
1309+
assert test_project_path.exists()
1310+
assert test_file.exists()
1311+
1312+
# Remove project with delete_notes=True
1313+
await project_service.remove_project(test_project_name, delete_notes=True)
1314+
1315+
# Verify project is removed from config/db
1316+
assert test_project_name not in project_service.projects
1317+
db_project = await project_service.repository.get_by_name(test_project_name)
1318+
assert db_project is None
1319+
1320+
# Verify directory and files are deleted
1321+
assert not test_project_path.exists()
1322+
1323+
finally:
1324+
# Cleanup happens automatically with temp_dir context manager
1325+
pass
1326+
1327+
1328+
@pytest.mark.asyncio
1329+
async def test_remove_project_delete_notes_missing_directory(project_service: ProjectService):
1330+
"""Test that remove_project with delete_notes=True handles missing directory gracefully."""
1331+
test_project_name = f"test-remove-missing-{os.urandom(4).hex()}"
1332+
test_project_path = f"/tmp/nonexistent-directory-{os.urandom(8).hex()}"
1333+
1334+
try:
1335+
# Add project pointing to non-existent path
1336+
await project_service.add_project(test_project_name, test_project_path)
1337+
1338+
# Verify project exists in config/db
1339+
assert test_project_name in project_service.projects
1340+
db_project = await project_service.repository.get_by_name(test_project_name)
1341+
assert db_project is not None
1342+
1343+
# Remove project with delete_notes=True (should not fail even if dir doesn't exist)
1344+
await project_service.remove_project(test_project_name, delete_notes=True)
1345+
1346+
# Verify project is removed from config/db
1347+
assert test_project_name not in project_service.projects
1348+
db_project = await project_service.repository.get_by_name(test_project_name)
1349+
assert db_project is None
1350+
1351+
finally:
1352+
# Ensure cleanup
1353+
if test_project_name in project_service.projects:
1354+
try:
1355+
project_service.config_manager.remove_project(test_project_name)
1356+
except Exception:
1357+
pass

0 commit comments

Comments
 (0)