@@ -154,21 +154,28 @@ def test_set_cloud_with_oauth_session(self, runner, tmp_path, monkeypatch):
154154class 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
214287class TestSetCloudWithWorkspace :
215288 """Tests for 'bm project set-cloud --workspace' option."""
0 commit comments