cgcardona / muse public
test_cli_new_commands.py python
593 lines 23.0 KB
e0353dfe feat: muse reflog, gc, archive, bisect, blame, worktree, workspace Gabriel Cardona <cgcardona@gmail.com> 6h ago
1 """CLI integration tests for: reflog, gc, archive, bisect, blame, worktree, workspace."""
2
3 from __future__ import annotations
4
5 import datetime
6 import hashlib
7 import json
8 import pathlib
9
10 import pytest
11 from typer.testing import CliRunner
12
13 from muse.cli.app import cli
14
15 runner = CliRunner()
16
17
18 # ---------------------------------------------------------------------------
19 # Repo scaffold helpers
20 # ---------------------------------------------------------------------------
21
22
23 def _sha256(content: bytes) -> str:
24 return hashlib.sha256(content).hexdigest()
25
26
27 def _make_repo(
28 tmp_path: pathlib.Path,
29 monkeypatch: pytest.MonkeyPatch,
30 ) -> pathlib.Path:
31 """Create a minimal repo with one commit, one file tracked. Sets cwd."""
32 monkeypatch.chdir(tmp_path)
33 muse = tmp_path / ".muse"
34 for d in ("objects", "commits", "snapshots", "refs/heads", "logs/refs/heads"):
35 (muse / d).mkdir(parents=True, exist_ok=True)
36
37 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
38 (muse / "HEAD").write_text("refs/heads/main\n")
39
40 content = b"hello world\n"
41 sha = _sha256(content)
42 obj_dir = muse / "objects" / sha[:2]
43 obj_dir.mkdir(parents=True, exist_ok=True)
44 (obj_dir / sha[2:]).write_bytes(content)
45
46 snap_id = "s" * 64
47 (muse / "snapshots" / f"{snap_id}.json").write_text(
48 json.dumps({"snapshot_id": snap_id, "manifest": {"hello.txt": sha}})
49 )
50
51 commit_id = "c" * 64
52 (muse / "commits" / f"{commit_id}.json").write_text(json.dumps({
53 "commit_id": commit_id,
54 "repo_id": "test-repo",
55 "branch": "main",
56 "snapshot_id": snap_id,
57 "message": "initial commit",
58 "committed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
59 "parent_commit_id": None,
60 "parent2_commit_id": None,
61 "author": "Test User",
62 "metadata": {},
63 }))
64 (muse / "refs" / "heads" / "main").write_text(commit_id)
65 return tmp_path
66
67
68 def _add_commits(repo: pathlib.Path, n: int, parent: str) -> list[str]:
69 """Append *n* commits to the main branch, return all commit IDs."""
70 commit_ids = [parent]
71 prev = parent
72 for i in range(n):
73 cid = format(i + 1, "064x")
74 snap_id = format(100 + i, "064x")
75 (repo / ".muse" / "snapshots" / f"{snap_id}.json").write_text(
76 json.dumps({"snapshot_id": snap_id, "manifest": {}})
77 )
78 (repo / ".muse" / "commits" / f"{cid}.json").write_text(json.dumps({
79 "commit_id": cid, "repo_id": "test-repo", "branch": "main",
80 "snapshot_id": snap_id, "message": f"commit {i + 1}",
81 "committed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
82 "parent_commit_id": prev, "parent2_commit_id": None,
83 "author": "Test", "metadata": {},
84 }))
85 commit_ids.append(cid)
86 prev = cid
87 (repo / ".muse" / "refs" / "heads" / "main").write_text(commit_ids[-1])
88 return commit_ids
89
90
91 # ---------------------------------------------------------------------------
92 # muse reflog
93 # ---------------------------------------------------------------------------
94
95
96 class TestReflogCli:
97 def test_reflog_no_entries_exits_ok(
98 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
99 ) -> None:
100 _make_repo(tmp_path, monkeypatch)
101 result = runner.invoke(cli, ["reflog"], catch_exceptions=False)
102 assert result.exit_code == 0
103 assert "No reflog entries" in result.output
104
105 def test_reflog_shows_entries(
106 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
107 ) -> None:
108 from muse.core.reflog import append_reflog
109
110 _make_repo(tmp_path, monkeypatch)
111 append_reflog(tmp_path, "main", old_id=None, new_id="c" * 64, author="A", operation="commit: test")
112 result = runner.invoke(cli, ["reflog"], catch_exceptions=False)
113 assert result.exit_code == 0
114 assert "commit: test" in result.output
115
116 def test_reflog_all_flag(
117 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
118 ) -> None:
119 from muse.core.reflog import append_reflog
120
121 _make_repo(tmp_path, monkeypatch)
122 append_reflog(tmp_path, "main", old_id=None, new_id="c" * 64, author="A", operation="commit: x")
123 result = runner.invoke(cli, ["reflog", "--all"], catch_exceptions=False)
124 assert result.exit_code == 0
125 assert "refs/heads/main" in result.output
126
127 def test_reflog_branch_filter(
128 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
129 ) -> None:
130 from muse.core.reflog import append_reflog
131
132 _make_repo(tmp_path, monkeypatch)
133 append_reflog(tmp_path, "dev", old_id=None, new_id="d" * 64, author="A", operation="commit: dev")
134 result = runner.invoke(cli, ["reflog", "--branch", "dev"], catch_exceptions=False)
135 assert result.exit_code == 0
136 assert "commit: dev" in result.output
137
138 def test_reflog_limit(
139 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
140 ) -> None:
141 from muse.core.reflog import append_reflog
142
143 _make_repo(tmp_path, monkeypatch)
144 for i in range(10):
145 append_reflog(tmp_path, "main", old_id=None, new_id="c" * 64, author="A", operation=f"commit: {i}")
146 result = runner.invoke(cli, ["reflog", "--limit", "3"], catch_exceptions=False)
147 assert result.exit_code == 0
148 # At most 3 @{N} entries.
149 lines = [l for l in result.output.splitlines() if l.startswith("@{")]
150 assert len(lines) <= 3
151
152 def test_reflog_shows_at_index_format(
153 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
154 ) -> None:
155 from muse.core.reflog import append_reflog
156
157 _make_repo(tmp_path, monkeypatch)
158 append_reflog(tmp_path, "main", old_id=None, new_id="c" * 64, author="A", operation="commit: x")
159 result = runner.invoke(cli, ["reflog"], catch_exceptions=False)
160 # Format is @{N:...} so just check the @ prefix.
161 assert "@{" in result.output
162
163
164 # ---------------------------------------------------------------------------
165 # muse gc
166 # ---------------------------------------------------------------------------
167
168
169 class TestGcCli:
170 def test_gc_empty_reports_zero(
171 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
172 ) -> None:
173 _make_repo(tmp_path, monkeypatch)
174 result = runner.invoke(cli, ["gc"], catch_exceptions=False)
175 assert result.exit_code == 0
176 assert "0 object" in result.output
177
178 def test_gc_dry_run_does_not_delete(
179 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
180 ) -> None:
181 _make_repo(tmp_path, monkeypatch)
182 # Write an orphan object with a valid 2+62 path.
183 orphan_content = b"totally orphaned"
184 sha = _sha256(orphan_content)
185 obj_dir = tmp_path / ".muse" / "objects" / sha[:2]
186 obj_dir.mkdir(parents=True, exist_ok=True)
187 obj_file = obj_dir / sha[2:]
188 obj_file.write_bytes(orphan_content)
189
190 result = runner.invoke(cli, ["gc", "--dry-run"], catch_exceptions=False)
191 assert result.exit_code == 0
192 assert "dry-run" in result.output
193 assert obj_file.exists()
194
195 def test_gc_removes_orphan(
196 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
197 ) -> None:
198 _make_repo(tmp_path, monkeypatch)
199 orphan_content = b"not referenced anywhere at all"
200 sha = _sha256(orphan_content)
201 obj_dir = tmp_path / ".muse" / "objects" / sha[:2]
202 obj_dir.mkdir(parents=True, exist_ok=True)
203 obj_file = obj_dir / sha[2:]
204 obj_file.write_bytes(orphan_content)
205
206 result = runner.invoke(cli, ["gc"], catch_exceptions=False)
207 assert result.exit_code == 0
208 assert not obj_file.exists()
209
210 def test_gc_verbose_lists_objects(
211 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
212 ) -> None:
213 _make_repo(tmp_path, monkeypatch)
214 orphan_content = b"verbose orphan"
215 sha = _sha256(orphan_content)
216 obj_dir = tmp_path / ".muse" / "objects" / sha[:2]
217 obj_dir.mkdir(parents=True, exist_ok=True)
218 (obj_dir / sha[2:]).write_bytes(orphan_content)
219
220 result = runner.invoke(cli, ["gc", "--verbose"], catch_exceptions=False)
221 assert result.exit_code == 0
222 assert sha in result.output
223
224 def test_gc_preserves_reachable(
225 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
226 ) -> None:
227 # The hello.txt object in the initial commit must survive GC.
228 _make_repo(tmp_path, monkeypatch)
229 content = b"hello world\n"
230 sha = _sha256(content)
231 obj_path = tmp_path / ".muse" / "objects" / sha[:2] / sha[2:]
232 result = runner.invoke(cli, ["gc"], catch_exceptions=False)
233 assert result.exit_code == 0
234 assert obj_path.exists()
235
236
237 # ---------------------------------------------------------------------------
238 # muse archive
239 # ---------------------------------------------------------------------------
240
241
242 class TestArchiveCli:
243 def test_archive_creates_targz(
244 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
245 ) -> None:
246 _make_repo(tmp_path, monkeypatch)
247 out = str(tmp_path / "snap.tar.gz")
248 result = runner.invoke(cli, ["archive", "--output", out], catch_exceptions=False)
249 assert result.exit_code == 0
250 assert pathlib.Path(out).exists()
251
252 def test_archive_creates_zip(
253 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
254 ) -> None:
255 _make_repo(tmp_path, monkeypatch)
256 out = str(tmp_path / "snap.zip")
257 result = runner.invoke(cli, ["archive", "--format", "zip", "--output", out], catch_exceptions=False)
258 assert result.exit_code == 0
259 assert pathlib.Path(out).exists()
260
261 def test_archive_invalid_format(
262 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
263 ) -> None:
264 _make_repo(tmp_path, monkeypatch)
265 result = runner.invoke(cli, ["archive", "--format", "rar"])
266 assert result.exit_code != 0
267 assert "Unknown format" in result.output
268
269 def test_archive_with_prefix(
270 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
271 ) -> None:
272 _make_repo(tmp_path, monkeypatch)
273 out = str(tmp_path / "out.tar.gz")
274 result = runner.invoke(
275 cli, ["archive", "--output", out, "--prefix", "myproject/"],
276 catch_exceptions=False,
277 )
278 assert result.exit_code == 0
279 import tarfile
280 with tarfile.open(out, "r:gz") as tar:
281 names = tar.getnames()
282 assert any("myproject/" in n for n in names)
283
284 def test_archive_output_shows_commit_info(
285 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
286 ) -> None:
287 _make_repo(tmp_path, monkeypatch)
288 out = str(tmp_path / "out.tar.gz")
289 result = runner.invoke(cli, ["archive", "--output", out], catch_exceptions=False)
290 assert result.exit_code == 0
291 assert "initial commit" in result.output
292
293 def test_archive_default_name_is_sha_based(
294 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
295 ) -> None:
296 _make_repo(tmp_path, monkeypatch)
297 result = runner.invoke(cli, ["archive"], catch_exceptions=False)
298 assert result.exit_code == 0
299 # Should create a .tar.gz file.
300 tar_files = list(tmp_path.glob("*.tar.gz"))
301 assert len(tar_files) == 1
302
303
304 # ---------------------------------------------------------------------------
305 # muse bisect
306 # ---------------------------------------------------------------------------
307
308
309 class TestBisectCli:
310 def _setup(
311 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, n: int = 4
312 ) -> list[str]:
313 _make_repo(tmp_path, monkeypatch)
314 initial = "c" * 64
315 return _add_commits(tmp_path, n, initial)
316
317 def test_bisect_start_requires_good(
318 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
319 ) -> None:
320 commits = self._setup(tmp_path, monkeypatch)
321 result = runner.invoke(cli, ["bisect", "start", "--bad", commits[-1]])
322 assert result.exit_code != 0
323
324 def test_bisect_start_success(
325 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
326 ) -> None:
327 commits = self._setup(tmp_path, monkeypatch, n=4)
328 result = runner.invoke(
329 cli, ["bisect", "start", "--bad", commits[-1], "--good", commits[0]],
330 catch_exceptions=False,
331 )
332 assert result.exit_code == 0
333 assert "Bisect session started" in result.output
334
335 def test_bisect_reset(
336 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
337 ) -> None:
338 commits = self._setup(tmp_path, monkeypatch, n=4)
339 runner.invoke(cli, ["bisect", "start", "--bad", commits[-1], "--good", commits[0]])
340 result = runner.invoke(cli, ["bisect", "reset"], catch_exceptions=False)
341 assert result.exit_code == 0
342 assert "reset" in result.output
343
344 def test_bisect_log_shows_entries(
345 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
346 ) -> None:
347 commits = self._setup(tmp_path, monkeypatch, n=4)
348 runner.invoke(
349 cli, ["bisect", "start", "--bad", commits[-1], "--good", commits[0]],
350 catch_exceptions=False,
351 )
352 result = runner.invoke(cli, ["bisect", "log"], catch_exceptions=False)
353 assert result.exit_code == 0
354 assert "bad" in result.output or "good" in result.output
355
356 def test_bisect_bad_without_session(
357 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
358 ) -> None:
359 _make_repo(tmp_path, monkeypatch)
360 result = runner.invoke(cli, ["bisect", "bad"])
361 assert result.exit_code != 0
362
363 def test_bisect_good_without_session(
364 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
365 ) -> None:
366 _make_repo(tmp_path, monkeypatch)
367 result = runner.invoke(cli, ["bisect", "good"])
368 assert result.exit_code != 0
369
370 def test_bisect_shows_next_to_test(
371 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
372 ) -> None:
373 commits = self._setup(tmp_path, monkeypatch, n=8)
374 result = runner.invoke(
375 cli, ["bisect", "start", "--bad", commits[-1], "--good", commits[0]],
376 catch_exceptions=False,
377 )
378 assert "Next to test:" in result.output
379
380 def test_bisect_skip(
381 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
382 ) -> None:
383 commits = self._setup(tmp_path, monkeypatch, n=4)
384 runner.invoke(
385 cli, ["bisect", "start", "--bad", commits[-1], "--good", commits[0]],
386 )
387 from muse.core.bisect import _load_state
388 state = _load_state(tmp_path)
389 assert state is not None
390 remaining = state.get("remaining", [])
391 if remaining:
392 mid = remaining[len(remaining) // 2]
393 result = runner.invoke(cli, ["bisect", "skip", mid], catch_exceptions=False)
394 assert result.exit_code == 0
395
396
397 # ---------------------------------------------------------------------------
398 # muse blame (core VCS)
399 # ---------------------------------------------------------------------------
400
401
402 class TestBlameCli:
403 def test_blame_missing_file(
404 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
405 ) -> None:
406 _make_repo(tmp_path, monkeypatch)
407 result = runner.invoke(cli, ["blame", "nonexistent.txt"])
408 assert result.exit_code != 0
409 assert "not found" in result.output
410
411 def test_blame_existing_file(
412 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
413 ) -> None:
414 _make_repo(tmp_path, monkeypatch)
415 result = runner.invoke(cli, ["blame", "hello.txt"], catch_exceptions=False)
416 assert result.exit_code == 0
417 assert "hello world" in result.output
418
419 def test_blame_shows_author(
420 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
421 ) -> None:
422 _make_repo(tmp_path, monkeypatch)
423 result = runner.invoke(cli, ["blame", "hello.txt"], catch_exceptions=False)
424 assert result.exit_code == 0
425 assert "Test User" in result.output
426
427 def test_blame_porcelain_json_output(
428 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
429 ) -> None:
430 _make_repo(tmp_path, monkeypatch)
431 result = runner.invoke(cli, ["blame", "--porcelain", "hello.txt"], catch_exceptions=False)
432 assert result.exit_code == 0
433 lines = [l for l in result.output.strip().split("\n") if l.strip()]
434 assert len(lines) >= 1
435 parsed = json.loads(lines[0])
436 assert "lineno" in parsed
437 assert "commit_id" in parsed
438 assert "content" in parsed
439
440 def test_blame_lineno_starts_at_1(
441 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
442 ) -> None:
443 _make_repo(tmp_path, monkeypatch)
444 result = runner.invoke(cli, ["blame", "--porcelain", "hello.txt"], catch_exceptions=False)
445 assert result.exit_code == 0
446 parsed = json.loads(result.output.strip().split("\n")[0])
447 assert parsed["lineno"] == 1
448
449
450 # ---------------------------------------------------------------------------
451 # muse worktree
452 # ---------------------------------------------------------------------------
453
454
455 class TestWorktreeCli:
456 def _make_named_repo(
457 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
458 ) -> pathlib.Path:
459 """Create a repo in myproject/ subdirectory."""
460 repo_dir = tmp_path / "myproject"
461 repo_dir.mkdir()
462 muse = repo_dir / ".muse"
463 for d in ("objects", "commits", "snapshots", "refs/heads"):
464 (muse / d).mkdir(parents=True, exist_ok=True)
465 (muse / "repo.json").write_text(json.dumps({"repo_id": "test"}))
466 (muse / "HEAD").write_text("refs/heads/main\n")
467 (muse / "refs" / "heads" / "main").write_text("0" * 64)
468 monkeypatch.chdir(repo_dir)
469 return repo_dir
470
471 def test_worktree_list_shows_main(
472 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
473 ) -> None:
474 self._make_named_repo(tmp_path, monkeypatch)
475 result = runner.invoke(cli, ["worktree", "list"], catch_exceptions=False)
476 assert result.exit_code == 0
477 assert "(main)" in result.output
478
479 def test_worktree_add_and_list(
480 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
481 ) -> None:
482 repo = self._make_named_repo(tmp_path, monkeypatch)
483 (repo / ".muse" / "refs" / "heads" / "dev").write_text("0" * 64)
484 result = runner.invoke(cli, ["worktree", "add", "mydev", "dev"], catch_exceptions=False)
485 assert result.exit_code == 0
486 result2 = runner.invoke(cli, ["worktree", "list"], catch_exceptions=False)
487 assert "mydev" in result2.output
488
489 def test_worktree_remove(
490 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
491 ) -> None:
492 repo = self._make_named_repo(tmp_path, monkeypatch)
493 (repo / ".muse" / "refs" / "heads" / "dev").write_text("0" * 64)
494 runner.invoke(cli, ["worktree", "add", "mydev", "dev"])
495 result = runner.invoke(cli, ["worktree", "remove", "mydev"], catch_exceptions=False)
496 assert result.exit_code == 0
497 assert "mydev" in result.output
498
499 def test_worktree_prune_empty(
500 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
501 ) -> None:
502 self._make_named_repo(tmp_path, monkeypatch)
503 result = runner.invoke(cli, ["worktree", "prune"], catch_exceptions=False)
504 assert result.exit_code == 0
505 assert "Nothing to prune" in result.output
506
507 def test_worktree_remove_nonexistent(
508 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
509 ) -> None:
510 self._make_named_repo(tmp_path, monkeypatch)
511 result = runner.invoke(cli, ["worktree", "remove", "nonexistent"])
512 assert result.exit_code != 0
513
514
515 # ---------------------------------------------------------------------------
516 # muse workspace
517 # ---------------------------------------------------------------------------
518
519
520 class TestWorkspaceCli:
521 def test_workspace_add_and_list(
522 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
523 ) -> None:
524 _make_repo(tmp_path, monkeypatch)
525 result = runner.invoke(
526 cli, ["workspace", "add", "core", "https://musehub.ai/acme/core"],
527 catch_exceptions=False,
528 )
529 assert result.exit_code == 0
530 assert "Added workspace member" in result.output
531
532 result2 = runner.invoke(cli, ["workspace", "list"], catch_exceptions=False)
533 assert result2.exit_code == 0
534 assert "core" in result2.output
535
536 def test_workspace_remove(
537 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
538 ) -> None:
539 _make_repo(tmp_path, monkeypatch)
540 runner.invoke(cli, ["workspace", "add", "core", "https://musehub.ai/acme/core"])
541 result = runner.invoke(cli, ["workspace", "remove", "core"], catch_exceptions=False)
542 assert result.exit_code == 0
543 assert "Removed" in result.output
544
545 def test_workspace_status_empty(
546 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
547 ) -> None:
548 _make_repo(tmp_path, monkeypatch)
549 result = runner.invoke(cli, ["workspace", "status"], catch_exceptions=False)
550 assert result.exit_code == 0
551 assert "No workspace members" in result.output
552
553 def test_workspace_list_empty(
554 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
555 ) -> None:
556 _make_repo(tmp_path, monkeypatch)
557 result = runner.invoke(cli, ["workspace", "list"], catch_exceptions=False)
558 assert result.exit_code == 0
559 assert "No workspace members" in result.output
560
561 def test_workspace_add_with_branch(
562 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
563 ) -> None:
564 _make_repo(tmp_path, monkeypatch)
565 runner.invoke(
566 cli, ["workspace", "add", "data", "https://example.com/data", "--branch", "v2"],
567 )
568 result = runner.invoke(cli, ["workspace", "list"], catch_exceptions=False)
569 assert "v2" in result.output
570
571 def test_workspace_remove_nonexistent(
572 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
573 ) -> None:
574 _make_repo(tmp_path, monkeypatch)
575 runner.invoke(cli, ["workspace", "add", "core", "https://example.com/core"])
576 result = runner.invoke(cli, ["workspace", "remove", "nonexistent"])
577 assert result.exit_code != 0
578
579 def test_workspace_sync_empty(
580 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
581 ) -> None:
582 _make_repo(tmp_path, monkeypatch)
583 result = runner.invoke(cli, ["workspace", "sync"], catch_exceptions=False)
584 assert result.exit_code == 0
585 assert "No members" in result.output
586
587 def test_workspace_add_duplicate(
588 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
589 ) -> None:
590 _make_repo(tmp_path, monkeypatch)
591 runner.invoke(cli, ["workspace", "add", "core", "https://example.com/core"])
592 result = runner.invoke(cli, ["workspace", "add", "core", "https://example.com/other"])
593 assert result.exit_code != 0