From dfdd3d7d684155ac5ba82f1adcc73a5770d5bb89 Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 28 Aug 2026 16:59:16 -0500 Subject: [PATCH 1/4] fix(cli): drop the local routing entry when a cloud project is removed `project add --cloud` writes a cloud-mode config entry (path "", workspace id) so later commands route to the cloud. `project remove` deleted the cloud project but only scrubbed sync fields from that entry, so the stub outlived the project: list-projects kept reporting it as a local project at "/", a second `remove` routed to the cloud again and got "not found", and `add` refused the name as taken with no way out short of editing config.json. Remove the entry after a cloud-routed delete succeeds. The default project is the one entry config must keep, so it is only scrubbed of sync state and the user is told how to retire it. Fixes #1340 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017STCpbNsYjZgUdftxgEAZ4 Signed-off-by: phernandez --- src/basic_memory/cli/commands/project.py | 27 +++++-- tests/cli/test_project_remove_cloud_stub.py | 87 +++++++++++++++++++++ 2 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 tests/cli/test_project_remove_cloud_stub.py diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index 0173073a0..29ec01506 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -949,11 +949,28 @@ async def _remove_project(): shutil.rmtree(bisync_state_path) console.print("[green]Removed bisync state[/green]") - # Clean up cloud sync fields on the project entry - if cloud and entry and entry.local_sync_path: - entry.local_sync_path = None - entry.bisync_initialized = False - entry.last_sync = None + # Trigger: the delete was cloud-routed — an explicit --cloud, or a + # cloud-mode entry written by `project add --cloud`. + # Why: the local API removes its own config entry, but a cloud delete + # never touches local config, so the routing stub outlived the project: + # list-projects kept reporting it as a local project at "/", a second + # `remove` routed to the cloud again and got "not found", and `add` + # refused the name as taken (#1340). + # Outcome: the stub goes with the project. The default project is the one + # entry config must keep, so it is only scrubbed of sync state and the + # user is told how to retire it. + if entry and (cloud or entry.mode == ProjectMode.CLOUD): + if config.default_project == name: + entry.local_sync_path = None + entry.bisync_initialized = False + entry.last_sync = None + console.print( + f"[yellow]'{name}' is still the default project in local config. " + "Choose another with `bm project default --local`, then run " + f"`bm project remove {name} --local` to drop this entry.[/yellow]" + ) + else: + del config.projects[name] ConfigManager().save_config(config) # Show informative message if files were not deleted diff --git a/tests/cli/test_project_remove_cloud_stub.py b/tests/cli/test_project_remove_cloud_stub.py new file mode 100644 index 000000000..51dc7d36b --- /dev/null +++ b/tests/cli/test_project_remove_cloud_stub.py @@ -0,0 +1,87 @@ +"""`bm project remove` must retire the local routing entry of a cloud project (#1340). + +`project add --cloud` writes a cloud-mode config entry (path "", workspace id) so +later commands route to the cloud. The cloud delete never touched local config, +so that stub outlived the project: list-projects kept showing it, a second +`remove` routed to the cloud and got "not found", and `add` refused the name. +""" + +import json +from contextlib import asynccontextmanager +from types import SimpleNamespace + +import pytest +from typer.testing import CliRunner + +from basic_memory.cli.app import app +from basic_memory.mcp.clients.project import ProjectClient + +# Importing registers project subcommands on the shared app instance. +import basic_memory.cli.commands.project as project_cmd # noqa: F401 + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def config_file(tmp_path, monkeypatch): + """An isolated config with a local default and a cloud-only project entry.""" + from basic_memory import config as config_module + + config_module._CONFIG_CACHE = None + config_module._CONFIG_MTIME = None + config_module._CONFIG_SIZE = None + + config_dir = tmp_path / ".basic-memory" + config_dir.mkdir(parents=True, exist_ok=True) + path = config_dir / "config.json" + path.write_text( + json.dumps( + { + "env": "dev", + "projects": { + "main": {"path": str(tmp_path / "main"), "mode": "local"}, + "openclaw-demo": {"path": "", "mode": "cloud", "workspace_id": "team-drew"}, + }, + "default_project": "main", + }, + indent=2, + ) + ) + monkeypatch.setenv("HOME", str(tmp_path)) + return path + + +@pytest.fixture +def cloud_delete(monkeypatch): + """Stub the API client so the cloud resolves and deletes the project.""" + seen: dict[str, str | None] = {} + + @asynccontextmanager + async def fake_get_client(*, project_name=None, workspace=None): + seen["workspace"] = workspace + yield object() + + async def fake_resolve_project(self, identifier): + return SimpleNamespace(external_id="ext-123") + + async def fake_delete_project(self, external_id, delete_notes=False): + seen["deleted"] = external_id + return SimpleNamespace(message="Project 'openclaw-demo' deletion queued") + + monkeypatch.setattr(project_cmd, "get_client", fake_get_client) + monkeypatch.setattr(ProjectClient, "resolve_project", fake_resolve_project) + monkeypatch.setattr(ProjectClient, "delete_project", fake_delete_project) + return seen + + +def test_removing_a_cloud_project_drops_its_local_routing_entry(runner, config_file, cloud_delete): + result = runner.invoke(app, ["project", "remove", "openclaw-demo"]) + + assert result.exit_code == 0, result.stdout + assert cloud_delete == {"workspace": "team-drew", "deleted": "ext-123"} + projects = json.loads(config_file.read_text())["projects"] + assert "openclaw-demo" not in projects + assert "main" in projects, "unrelated entries must survive" From b3a69548807dadc49cbe7a617cb10fb088ae0630 Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 28 Aug 2026 17:13:51 -0500 Subject: [PATCH 2/4] fix(cli): scope config-entry removal to cloud-mode entries and clean sync state on any cloud route Codex review on the first cut found four gaps: - `--cloud` is a routing override, not proof the local entry is a cloud stub: a same-named local project removed with `--cloud` lost its config entry while its database row and files stayed. Only entries whose stored mode is cloud are removed now. - Local sync path and bisync-state cleanup were gated on the raw `--cloud` flag, so an auto-routed remove of a cloud-mode entry with a sync path deleted the entry (and with it the only record of that path) but left `bisync-state/` behind, which would let a recreated name skip `--resync` against a stale baseline. Cleanup now follows the route the delete actually takes. - The config key was looked up by exact name, so removing `My Research` as `my-research` deleted the cloud project but kept the stub. The lookup is permalink-aware now, matching ConfigManager.remove_project. Tests cover each case. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017STCpbNsYjZgUdftxgEAZ4 Signed-off-by: phernandez --- src/basic_memory/cli/commands/project.py | 38 ++++--- tests/cli/test_project_remove_cloud_stub.py | 115 +++++++++++++++++--- 2 files changed, 123 insertions(+), 30 deletions(-) diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index 29ec01506..69bbb0bb6 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -892,8 +892,10 @@ def remove_project( async def _remove_project(): # Resolve workspace so cloud-only projects auto-route without --cloud - config = ConfigManager().config - entry = config.projects.get(name) + config_manager = ConfigManager() + config = config_manager.config + entry_name, _ = config_manager.get_project(name) + entry = config.projects.get(entry_name) if entry_name else None ws = None if entry and entry.workspace_id: ws = entry.workspace_id @@ -910,13 +912,20 @@ async def _remove_project(): ) try: - # Get config to check for local sync path and bisync state - config = ConfigManager().config + # A display name and its permalink address the same entry, and the API + # accepts either, so resolve the config key the same permalink-aware way. + config_manager = ConfigManager() + config = config_manager.config + entry_name, _ = config_manager.get_project(name) + entry = config.projects.get(entry_name) if entry_name else None + # The delete is cloud-routed on an explicit --cloud or a cloud-mode entry + # (per-project routing); local-artifact cleanup must follow the route the + # delete actually takes, not just the flag. + cloud_routed = cloud or (entry is not None and entry.mode == ProjectMode.CLOUD) local_path_config = None has_bisync_state = False - entry = config.projects.get(name) - if cloud and entry and entry.local_sync_path: + if cloud_routed and entry and entry.local_sync_path: local_path_config = entry.local_sync_path # Check for bisync state @@ -949,8 +958,9 @@ async def _remove_project(): shutil.rmtree(bisync_state_path) console.print("[green]Removed bisync state[/green]") - # Trigger: the delete was cloud-routed — an explicit --cloud, or a - # cloud-mode entry written by `project add --cloud`. + # Trigger: the entry is a cloud-mode routing entry (written by + # `project add --cloud` or `set-cloud`). An explicit --cloud alone is only + # a routing override — a same-named local project keeps its entry. # Why: the local API removes its own config entry, but a cloud delete # never touches local config, so the routing stub outlived the project: # list-projects kept reporting it as a local project at "/", a second @@ -959,19 +969,19 @@ async def _remove_project(): # Outcome: the stub goes with the project. The default project is the one # entry config must keep, so it is only scrubbed of sync state and the # user is told how to retire it. - if entry and (cloud or entry.mode == ProjectMode.CLOUD): - if config.default_project == name: + if entry is not None and entry_name is not None and entry.mode == ProjectMode.CLOUD: + if config.default_project == entry_name: entry.local_sync_path = None entry.bisync_initialized = False entry.last_sync = None console.print( - f"[yellow]'{name}' is still the default project in local config. " + f"[yellow]'{entry_name}' is still the default project in local config. " "Choose another with `bm project default --local`, then run " - f"`bm project remove {name} --local` to drop this entry.[/yellow]" + f"`bm project remove {entry_name} --local` to drop this entry.[/yellow]" ) else: - del config.projects[name] - ConfigManager().save_config(config) + del config.projects[entry_name] + config_manager.save_config(config) # Show informative message if files were not deleted if not delete_notes: diff --git a/tests/cli/test_project_remove_cloud_stub.py b/tests/cli/test_project_remove_cloud_stub.py index 51dc7d36b..78b2e3bdf 100644 --- a/tests/cli/test_project_remove_cloud_stub.py +++ b/tests/cli/test_project_remove_cloud_stub.py @@ -8,12 +8,14 @@ import json from contextlib import asynccontextmanager +from pathlib import Path from types import SimpleNamespace import pytest from typer.testing import CliRunner from basic_memory.cli.app import app +from basic_memory.cli.commands.cloud import rclone_commands from basic_memory.mcp.clients.project import ProjectClient # Importing registers project subcommands on the shared app instance. @@ -25,9 +27,8 @@ def runner(): return CliRunner() -@pytest.fixture -def config_file(tmp_path, monkeypatch): - """An isolated config with a local default and a cloud-only project entry.""" +def _write_config(tmp_path: Path, monkeypatch, projects: dict[str, dict[str, object]]) -> Path: + """Write an isolated config with the given project entries and point HOME at it.""" from basic_memory import config as config_module config_module._CONFIG_CACHE = None @@ -38,22 +39,29 @@ def config_file(tmp_path, monkeypatch): config_dir.mkdir(parents=True, exist_ok=True) path = config_dir / "config.json" path.write_text( - json.dumps( - { - "env": "dev", - "projects": { - "main": {"path": str(tmp_path / "main"), "mode": "local"}, - "openclaw-demo": {"path": "", "mode": "cloud", "workspace_id": "team-drew"}, - }, - "default_project": "main", - }, - indent=2, - ) + json.dumps({"env": "dev", "projects": projects, "default_project": "main"}, indent=2) ) monkeypatch.setenv("HOME", str(tmp_path)) return path +def _projects(config_file: Path) -> dict[str, dict[str, object]]: + return json.loads(config_file.read_text())["projects"] + + +@pytest.fixture +def config_file(tmp_path, monkeypatch): + """A local default plus a cloud-only routing entry.""" + return _write_config( + tmp_path, + monkeypatch, + { + "main": {"path": str(tmp_path / "main"), "mode": "local"}, + "openclaw-demo": {"path": "", "mode": "cloud", "workspace_id": "team-drew"}, + }, + ) + + @pytest.fixture def cloud_delete(monkeypatch): """Stub the API client so the cloud resolves and deletes the project.""" @@ -69,7 +77,7 @@ async def fake_resolve_project(self, identifier): async def fake_delete_project(self, external_id, delete_notes=False): seen["deleted"] = external_id - return SimpleNamespace(message="Project 'openclaw-demo' deletion queued") + return SimpleNamespace(message="Project deletion queued") monkeypatch.setattr(project_cmd, "get_client", fake_get_client) monkeypatch.setattr(ProjectClient, "resolve_project", fake_resolve_project) @@ -82,6 +90,81 @@ def test_removing_a_cloud_project_drops_its_local_routing_entry(runner, config_f assert result.exit_code == 0, result.stdout assert cloud_delete == {"workspace": "team-drew", "deleted": "ext-123"} - projects = json.loads(config_file.read_text())["projects"] + projects = _projects(config_file) assert "openclaw-demo" not in projects assert "main" in projects, "unrelated entries must survive" + + +def test_permalink_form_of_the_name_still_finds_the_entry( + tmp_path, monkeypatch, runner, cloud_delete +): + """The API resolves `my-research` for `My Research`; the config lookup must too.""" + config_file = _write_config( + tmp_path, + monkeypatch, + { + "main": {"path": str(tmp_path / "main"), "mode": "local"}, + "My Research": {"path": "", "mode": "cloud", "workspace_id": "team-drew"}, + }, + ) + + result = runner.invoke(app, ["project", "remove", "my-research"]) + + assert result.exit_code == 0, result.stdout + assert "My Research" not in _projects(config_file) + + +def test_cloud_flag_is_only_a_routing_override_for_a_local_entry( + tmp_path, monkeypatch, runner, cloud_delete +): + """`remove --cloud` on a same-named local project deletes the cloud copy, not local config.""" + config_file = _write_config( + tmp_path, + monkeypatch, + { + "main": {"path": str(tmp_path / "main"), "mode": "local"}, + "research": {"path": str(tmp_path / "research"), "mode": "local"}, + }, + ) + + result = runner.invoke(app, ["project", "remove", "research", "--cloud"]) + + assert result.exit_code == 0, result.stdout + assert cloud_delete["deleted"] == "ext-123" + assert "research" in _projects(config_file), "the local project keeps its entry" + + +def test_auto_routed_cloud_delete_cleans_local_sync_artifacts( + tmp_path, monkeypatch, runner, cloud_delete +): + """Cleanup follows the route the delete takes, not the raw --cloud flag.""" + local_sync = tmp_path / "research-sync" + local_sync.mkdir() + config_file = _write_config( + tmp_path, + monkeypatch, + { + "main": {"path": str(tmp_path / "main"), "mode": "local"}, + "research": { + "path": str(local_sync), + "mode": "cloud", + "workspace_id": "team-drew", + "local_sync_path": str(local_sync), + "bisync_initialized": True, + }, + }, + ) + bisync_state = tmp_path / "bisync-state" / "research" + bisync_state.mkdir(parents=True) + monkeypatch.setattr( + rclone_commands, "get_project_bisync_state", lambda project_name: bisync_state + ) + + result = runner.invoke(app, ["project", "remove", "research"]) + + assert result.exit_code == 0, result.stdout + assert not bisync_state.exists(), "stale bisync state would let a recreated name skip --resync" + assert local_sync.exists(), "notes stay on disk without --delete-notes" + # Rich wraps the long temp path across lines, so match the message alone. + assert "Local files remain at" in result.stdout + assert "research" not in _projects(config_file) From bcea8fdbd0cadc650b628cbb2cc89891cd518df7 Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 28 Aug 2026 17:23:45 -0500 Subject: [PATCH 3/4] fix(cli): key bisync-state cleanup by the canonical entry and skip config removal on --local Codex round two on #1355: bisync state lives under the canonical config name, so removing `My Research` as `my-research` probed the wrong directory and left the old baseline for a recreated project to reuse; and an explicit --local hands the delete to the local service, which already removes the config entry, so the CLI-side removal raised KeyError after a successful delete. Carry the canonical name through cleanup and leave config to the local service on --local. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017STCpbNsYjZgUdftxgEAZ4 Signed-off-by: phernandez --- src/basic_memory/cli/commands/project.py | 29 ++++++----- tests/cli/test_project_remove_cloud_stub.py | 54 +++++++++++++++++++++ 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index 69bbb0bb6..764b13b59 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -923,16 +923,16 @@ async def _remove_project(): # delete actually takes, not just the flag. cloud_routed = cloud or (entry is not None and entry.mode == ProjectMode.CLOUD) local_path_config = None - has_bisync_state = False + bisync_state_path: Path | None = None - if cloud_routed and entry and entry.local_sync_path: + if cloud_routed and entry is not None and entry_name is not None and entry.local_sync_path: local_path_config = entry.local_sync_path - # Check for bisync state + # Bisync state is keyed by the canonical config name, not the form + # the user typed. from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state - bisync_state_path = get_project_bisync_state(name) - has_bisync_state = bisync_state_path.exists() + bisync_state_path = get_project_bisync_state(entry_name) # Remove project from cloud/API with force_routing(local=local, cloud=cloud): @@ -949,14 +949,11 @@ async def _remove_project(): console.print(f"[green]Removed local sync directory: {local_path_config}[/green]") # Clean up bisync state if it exists - if has_bisync_state: - from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state + if bisync_state_path is not None and bisync_state_path.exists(): import shutil - bisync_state_path = get_project_bisync_state(name) - if bisync_state_path.exists(): - shutil.rmtree(bisync_state_path) - console.print("[green]Removed bisync state[/green]") + shutil.rmtree(bisync_state_path) + console.print("[green]Removed bisync state[/green]") # Trigger: the entry is a cloud-mode routing entry (written by # `project add --cloud` or `set-cloud`). An explicit --cloud alone is only @@ -968,8 +965,14 @@ async def _remove_project(): # refused the name as taken (#1340). # Outcome: the stub goes with the project. The default project is the one # entry config must keep, so it is only scrubbed of sync state and the - # user is told how to retire it. - if entry is not None and entry_name is not None and entry.mode == ProjectMode.CLOUD: + # user is told how to retire it. An explicit --local hands the delete to + # the local service, which removes the config entry itself. + if ( + not local + and entry is not None + and entry_name is not None + and entry.mode == ProjectMode.CLOUD + ): if config.default_project == entry_name: entry.local_sync_path = None entry.bisync_initialized = False diff --git a/tests/cli/test_project_remove_cloud_stub.py b/tests/cli/test_project_remove_cloud_stub.py index 78b2e3bdf..67e85874b 100644 --- a/tests/cli/test_project_remove_cloud_stub.py +++ b/tests/cli/test_project_remove_cloud_stub.py @@ -168,3 +168,57 @@ def test_auto_routed_cloud_delete_cleans_local_sync_artifacts( # Rich wraps the long temp path across lines, so match the message alone. assert "Local files remain at" in result.stdout assert "research" not in _projects(config_file) + + +def test_bisync_state_cleanup_uses_the_canonical_entry_name( + tmp_path, monkeypatch, runner, cloud_delete +): + """Removing `My Research` as `my-research` must clear `bisync-state/My Research`.""" + local_sync = tmp_path / "research-sync" + local_sync.mkdir() + _write_config( + tmp_path, + monkeypatch, + { + "main": {"path": str(tmp_path / "main"), "mode": "local"}, + "My Research": { + "path": str(local_sync), + "mode": "cloud", + "workspace_id": "team-drew", + "local_sync_path": str(local_sync), + "bisync_initialized": True, + }, + }, + ) + states = {"My Research": tmp_path / "bisync-state" / "My Research"} + states["My Research"].mkdir(parents=True) + monkeypatch.setattr( + rclone_commands, + "get_project_bisync_state", + lambda project_name: states.get(project_name, tmp_path / "bisync-state" / project_name), + ) + + result = runner.invoke(app, ["project", "remove", "my-research"]) + + assert result.exit_code == 0, result.stdout + assert not states["My Research"].exists() + + +def test_explicit_local_route_leaves_config_removal_to_the_local_service( + tmp_path, monkeypatch, runner, cloud_delete +): + """`remove --local` on a cloud-mode entry with a local row: the local API owns the entry.""" + config_file = _write_config( + tmp_path, + monkeypatch, + { + "main": {"path": str(tmp_path / "main"), "mode": "local"}, + "research": {"path": "", "mode": "cloud", "workspace_id": "team-drew"}, + }, + ) + + result = runner.invoke(app, ["project", "remove", "research", "--local"]) + + assert result.exit_code == 0, result.stdout + # The stubbed API did not touch config; the CLI must not double-delete either. + assert "research" in _projects(config_file) From 27fa2f7b40a96556f3912d8a8bc40e61ee9101e5 Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 28 Aug 2026 17:31:25 -0500 Subject: [PATCH 4/4] fix(cli): retire the cloud routing stub before fallible local cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round three on #1355: if the cloud delete succeeded but a local rmtree (sync directory, bisync state) raised, the outer handler ran before the config entry was removed — the remote project was gone and the stub survived, recreating the stuck state. Persist the config retirement immediately after the delete, then do the filesystem cleanup. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017STCpbNsYjZgUdftxgEAZ4 Signed-off-by: phernandez --- src/basic_memory/cli/commands/project.py | 36 +++++++++++++----------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index 764b13b59..27bc21c9a 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -939,22 +939,6 @@ async def _remove_project(): result = run_with_cleanup(_remove_project()) console.print(f"[green]{result.message}[/green]") - # Clean up local sync directory if it exists and delete_notes is True - if delete_notes and local_path_config: - local_dir = Path(local_path_config) - if local_dir.exists(): - import shutil - - shutil.rmtree(local_dir) - console.print(f"[green]Removed local sync directory: {local_path_config}[/green]") - - # Clean up bisync state if it exists - if bisync_state_path is not None and bisync_state_path.exists(): - import shutil - - shutil.rmtree(bisync_state_path) - console.print("[green]Removed bisync state[/green]") - # Trigger: the entry is a cloud-mode routing entry (written by # `project add --cloud` or `set-cloud`). An explicit --cloud alone is only # a routing override — a same-named local project keeps its entry. @@ -963,7 +947,9 @@ async def _remove_project(): # list-projects kept reporting it as a local project at "/", a second # `remove` routed to the cloud again and got "not found", and `add` # refused the name as taken (#1340). - # Outcome: the stub goes with the project. The default project is the one + # Outcome: the stub goes with the project — persisted before the fallible + # filesystem cleanups below, so a failed rmtree cannot leave the remote + # project deleted and the stub alive. The default project is the one # entry config must keep, so it is only scrubbed of sync state and the # user is told how to retire it. An explicit --local hands the delete to # the local service, which removes the config entry itself. @@ -986,6 +972,22 @@ async def _remove_project(): del config.projects[entry_name] config_manager.save_config(config) + # Clean up local sync directory if it exists and delete_notes is True + if delete_notes and local_path_config: + local_dir = Path(local_path_config) + if local_dir.exists(): + import shutil + + shutil.rmtree(local_dir) + console.print(f"[green]Removed local sync directory: {local_path_config}[/green]") + + # Clean up bisync state if it exists + if bisync_state_path is not None and bisync_state_path.exists(): + import shutil + + shutil.rmtree(bisync_state_path) + console.print("[green]Removed bisync state[/green]") + # Show informative message if files were not deleted if not delete_notes: if local_path_config: