Skip to content

Commit c4956ca

Browse files
authored
fix(cli): cleanup local DB state on set-cloud/set-local (#775)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 128c2da commit c4956ca

2 files changed

Lines changed: 208 additions & 13 deletions

File tree

src/basic_memory/cli/commands/project.py

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
)
2727
from basic_memory.cli.commands.command_utils import get_project_info, run_with_cleanup
2828
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
29-
from basic_memory.config import ConfigManager, ProjectEntry, ProjectMode
29+
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectEntry, ProjectMode
3030
from basic_memory.mcp.async_client import get_client, resolve_configured_workspace
3131
from basic_memory.mcp.clients import ProjectClient
3232
from basic_memory.schemas.cloud import (
@@ -838,6 +838,76 @@ async def _move_project():
838838
raise typer.Exit(1)
839839

840840

841+
async def _detach_local_project_row(app_config: BasicMemoryConfig, name: str) -> bool:
842+
"""Drop the project's row from the local index DB.
843+
844+
Trigger: `bm project set-cloud` is making a project cloud-only.
845+
Why: the local row is what causes `_merge_projects` to report
846+
`source: "local+cloud"` after the toggle (#680). Removing it
847+
forces the merged listing to honor the user's chosen mode.
848+
Outcome: returns True if a row was deleted, False if there was
849+
nothing to clean up. On-disk note files are not touched.
850+
"""
851+
from basic_memory import db
852+
from basic_memory.repository import ProjectRepository
853+
854+
_, session_maker = await db.get_or_create_db(
855+
db_path=app_config.database_path,
856+
db_type=db.DatabaseType.FILESYSTEM,
857+
)
858+
try:
859+
repo = ProjectRepository(session_maker)
860+
existing = await repo.get_by_name(name)
861+
if existing is None:
862+
return False
863+
await repo.delete(existing.id)
864+
return True
865+
finally:
866+
# CLI-only: safe to tear down the global DB singleton here since
867+
# set-cloud/set-local never run inside a long-lived MCP/API server.
868+
await db.shutdown_db()
869+
870+
871+
async def _attach_local_project_row(app_config: BasicMemoryConfig, name: str, path: str) -> None:
872+
"""Ensure the project has a row in the local index DB at the given path.
873+
874+
Trigger: `bm project set-local` is making a previously cloud-only
875+
project local again.
876+
Why: without a row in the local DB, every local-side tool (`list`,
877+
`info`, sync, indexing) would skip this project.
878+
Outcome: a row is created if missing, or its path is updated to match
879+
the new local home if it already exists. On-disk files are not
880+
touched — the caller is responsible for ensuring the directory
881+
exists.
882+
"""
883+
from basic_memory import db
884+
from basic_memory.repository import ProjectRepository
885+
886+
_, session_maker = await db.get_or_create_db(
887+
db_path=app_config.database_path,
888+
db_type=db.DatabaseType.FILESYSTEM,
889+
)
890+
try:
891+
repo = ProjectRepository(session_maker)
892+
existing = await repo.get_by_name(name)
893+
if existing is None:
894+
await repo.create(
895+
{
896+
"name": name,
897+
"path": path,
898+
"permalink": generate_permalink(name),
899+
"is_active": True,
900+
}
901+
)
902+
return
903+
if existing.path != path:
904+
await repo.update_path(existing.id, path)
905+
finally:
906+
# CLI-only: safe to tear down the global DB singleton here since
907+
# set-cloud/set-local never run inside a long-lived MCP/API server.
908+
await db.shutdown_db()
909+
910+
841911
@project_app.command("set-cloud")
842912
def set_cloud(
843913
name: str = typer.Argument(..., help="Name of the project to route through cloud"),
@@ -855,6 +925,13 @@ def set_cloud(
855925
If omitted, uses the default workspace (if set) or auto-selects when
856926
only one workspace is available.
857927
928+
This is a one-way cutover: the project's row in the local index DB is
929+
removed and the local path in config is cleared so the project's
930+
configured state is purely cloud. On-disk note files are preserved —
931+
the caller can keep, archive, or delete them as they see fit. To
932+
return to local mode use `bm project set-local <name> --local-path
933+
<path>`.
934+
858935
Examples:
859936
bm project set-cloud research --workspace Personal
860937
bm project set-cloud research --workspace 11111111-...
@@ -883,27 +960,52 @@ def set_cloud(
883960

884961
resolved_workspace_id = _resolve_workspace_id(config, workspace)
885962

963+
# Drop the local DB row first so the user-visible state stays consistent
964+
# even if the config save below raises for some reason. Idempotent: a
965+
# second `set-cloud` simply finds no row and returns False.
966+
previous_path = config.projects[name].path
967+
detached = run_with_cleanup(_detach_local_project_row(config, name))
968+
886969
config.set_project_mode(name, ProjectMode.CLOUD)
887970
if resolved_workspace_id:
888971
config.projects[name].workspace_id = resolved_workspace_id
972+
# Clear local path: source-of-truth for this project is now the cloud
973+
config.projects[name].path = ""
889974
config_manager.save_config(config)
890975

891976
console.print(f"[green]Project '{name}' set to cloud mode[/green]")
892977
if resolved_workspace_id:
893978
console.print(f"[dim]Workspace: {resolved_workspace_id}[/dim]")
979+
if detached and previous_path:
980+
console.print(
981+
f"[dim]Local index entry removed. Files at {previous_path} are preserved on disk.[/dim]"
982+
)
894983
console.print("[dim]MCP tools and CLI commands for this project will route through cloud[/dim]")
895984

896985

897986
@project_app.command("set-local")
898987
def set_local(
899988
name: str = typer.Argument(..., help="Name of the project to revert to local mode"),
989+
local_path: str = typer.Option(
990+
None,
991+
"--local-path",
992+
help=(
993+
"Local filesystem path for this project. Required unless the project "
994+
"was previously local and its prior path is still in config."
995+
),
996+
),
900997
) -> None:
901998
"""Revert a project to local mode (use in-process ASGI transport).
902999
903-
Clears any associated cloud workspace.
1000+
Recreates the project's row in the local index DB and clears any
1001+
associated cloud workspace. If the project was previously local and
1002+
its prior path is still in config (e.g. an older version that didn't
1003+
blank `path` on `set-cloud`), `--local-path` may be omitted and that
1004+
path will be reused.
9041005
905-
Example:
906-
bm project set-local research
1006+
Examples:
1007+
bm project set-local research --local-path ~/Documents/research
1008+
bm project set-local research # reuse prior path
9071009
"""
9081010
config_manager = ConfigManager()
9091011
config = config_manager.config
@@ -913,11 +1015,31 @@ def set_local(
9131015
console.print(f"[red]Error: Project '{name}' not found in config[/red]")
9141016
raise typer.Exit(1)
9151017

1018+
entry = config.projects[name]
1019+
candidate = local_path or entry.path
1020+
if not candidate:
1021+
console.print(
1022+
f"[red]Error: --local-path is required for '{name}' "
1023+
"(no previous local path is recorded)[/red]"
1024+
)
1025+
raise typer.Exit(1)
1026+
1027+
resolved_path = Path(os.path.abspath(os.path.expanduser(candidate))).as_posix()
1028+
1029+
# Recreate the local DB row. Idempotent: if the row exists with the
1030+
# same path it's a no-op; if it exists at a stale path the path is
1031+
# updated. The directory itself is not auto-created — the user is
1032+
# expected to know whether they want to start a fresh project tree
1033+
# or point at an existing one.
1034+
run_with_cleanup(_attach_local_project_row(config, name, resolved_path))
1035+
9161036
config.set_project_mode(name, ProjectMode.LOCAL)
9171037
config.projects[name].workspace_id = None
1038+
config.projects[name].path = resolved_path
9181039
config_manager.save_config(config)
9191040

9201041
console.print(f"[green]Project '{name}' set to local mode[/green]")
1042+
console.print(f"[dim]Path: {resolved_path}[/dim]")
9211043
console.print("[dim]MCP tools and CLI commands for this project will use local transport[/dim]")
9221044

9231045

tests/cli/test_project_set_cloud_local.py

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -154,21 +154,28 @@ def test_set_cloud_with_oauth_session(self, runner, tmp_path, monkeypatch):
154154
class TestSetLocal:
155155
"""Tests for bm project set-local command."""
156156

157-
def test_set_local_success(self, runner, mock_config):
157+
def test_set_local_success(self, runner, mock_config, tmp_path):
158158
"""Test reverting a project to local mode."""
159-
# First set to cloud
159+
# First set to cloud (clears the local path as part of the cutover)
160160
runner.invoke(app, ["project", "set-cloud", "research"])
161161
config_data = json.loads(mock_config.read_text())
162162
assert config_data["projects"]["research"]["mode"] == "cloud"
163163

164-
# Now set back to local
165-
result = runner.invoke(app, ["project", "set-local", "research"])
164+
# Now set back to local — must supply a path since set-cloud blanked it
165+
new_path = tmp_path / "research"
166+
result = runner.invoke(
167+
app, ["project", "set-local", "research", "--local-path", str(new_path)]
168+
)
166169
assert result.exit_code == 0
167170
assert "local mode" in result.stdout.lower()
168171

169-
# Verify config was updated — mode reset to local
172+
# Verify config was updated — mode reset to local, path restored.
173+
# set-local normalizes the path via Path.as_posix(), matching the
174+
# convention used by `bm project add`. On Windows that means
175+
# backslashes are converted to forward slashes.
170176
config_data = json.loads(mock_config.read_text())
171177
assert config_data["projects"]["research"]["mode"] == "local"
178+
assert config_data["projects"]["research"]["path"] == new_path.as_posix()
172179

173180
def test_set_local_nonexistent_project(self, runner, mock_config):
174181
"""Test set-local with a project that doesn't exist in config."""
@@ -177,12 +184,23 @@ def test_set_local_nonexistent_project(self, runner, mock_config):
177184
assert "not found" in result.stdout.lower()
178185

179186
def test_set_local_already_local(self, runner, mock_config):
180-
"""Test set-local on a project that's already local (no-op, should succeed)."""
187+
"""Test set-local on a project that's already local (reuses existing path)."""
181188
result = runner.invoke(app, ["project", "set-local", "main"])
182189
assert result.exit_code == 0
183190
assert "local mode" in result.stdout.lower()
184191

185-
def test_set_local_clears_workspace_id(self, runner, mock_config):
192+
def test_set_local_requires_path_after_set_cloud(self, runner, mock_config):
193+
"""Regression for #680: after set-cloud blanks the path, set-local must
194+
refuse to silently default — the user has to specify where the project
195+
lives now."""
196+
runner.invoke(app, ["project", "set-cloud", "research"])
197+
198+
# No --local-path; config no longer has a path either.
199+
result = runner.invoke(app, ["project", "set-local", "research"])
200+
assert result.exit_code == 1
201+
assert "--local-path" in result.stdout
202+
203+
def test_set_local_clears_workspace_id(self, runner, mock_config, tmp_path):
186204
"""Test that set-local clears workspace_id from the project entry."""
187205
from basic_memory import config as config_module
188206

@@ -198,8 +216,12 @@ def test_set_local_clears_workspace_id(self, runner, mock_config):
198216
config_module._CONFIG_MTIME = None
199217
config_module._CONFIG_SIZE = None
200218

201-
# Set back to local
202-
result = runner.invoke(app, ["project", "set-local", "research"])
219+
# Set back to local — supply --local-path; existing config path is preserved
220+
# in this test setup, but new behavior recommends explicit path passing.
221+
new_path = tmp_path / "research"
222+
result = runner.invoke(
223+
app, ["project", "set-local", "research", "--local-path", str(new_path)]
224+
)
203225
assert result.exit_code == 0
204226

205227
# Verify workspace_id was cleared
@@ -210,6 +232,57 @@ def test_set_local_clears_workspace_id(self, runner, mock_config):
210232
assert updated_data["projects"]["research"]["workspace_id"] is None
211233
assert updated_data["projects"]["research"]["mode"] == "local"
212234

235+
def test_set_local_update_path_when_row_exists(self, runner, mock_config, tmp_path):
236+
"""Re-running set-local with a different --local-path must update the
237+
existing DB row (covers the repo.update_path() branch in
238+
_attach_local_project_row)."""
239+
path_a = tmp_path / "research_v1"
240+
path_b = tmp_path / "research_v2"
241+
242+
# First call seeds the DB row at path_a.
243+
result_a = runner.invoke(
244+
app, ["project", "set-local", "research", "--local-path", str(path_a)]
245+
)
246+
assert result_a.exit_code == 0
247+
248+
# Second call must update the existing row's path to path_b.
249+
result_b = runner.invoke(
250+
app, ["project", "set-local", "research", "--local-path", str(path_b)]
251+
)
252+
assert result_b.exit_code == 0
253+
254+
updated = json.loads(mock_config.read_text())
255+
assert updated["projects"]["research"]["path"] == path_b.as_posix()
256+
257+
258+
class TestSetCloudCutover:
259+
"""Regression tests for #680 — set-cloud as a one-way cutover."""
260+
261+
def test_set_cloud_clears_path(self, runner, mock_config):
262+
"""After set-cloud, config.projects[name].path must be blanked so the
263+
merged project list reports source: cloud (not local+cloud)."""
264+
runner.invoke(app, ["project", "set-cloud", "research"])
265+
updated = json.loads(mock_config.read_text())
266+
assert updated["projects"]["research"]["mode"] == "cloud"
267+
assert updated["projects"]["research"]["path"] == ""
268+
269+
def test_set_cloud_removes_existing_db_row(self, runner, mock_config, tmp_path):
270+
"""When set-cloud runs against a project with a DB row, the row must
271+
be removed and the user-facing message must mention the cleanup
272+
(covers the repo.delete() → return True branch in
273+
_detach_local_project_row)."""
274+
# Seed a DB row first by going through set-local.
275+
research_path = tmp_path / "research"
276+
seed = runner.invoke(
277+
app, ["project", "set-local", "research", "--local-path", str(research_path)]
278+
)
279+
assert seed.exit_code == 0
280+
281+
# Now flip to cloud — _detach_local_project_row should find and drop the row.
282+
result = runner.invoke(app, ["project", "set-cloud", "research"])
283+
assert result.exit_code == 0
284+
assert "local index entry removed" in result.stdout.lower()
285+
213286

214287
class TestSetCloudWithWorkspace:
215288
"""Tests for 'bm project set-cloud --workspace' option."""

0 commit comments

Comments
 (0)