gabriel / muse public
test_cmd_workspace_hardening.py python
2,288 lines 94.4 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days ago
1 """Hardening tests for muse workspace — security, performance, UX, and stress.
2
3 Coverage matrix
4 ---------------
5 - Unit: _toml_escape, _load_manifest guards (symlink, size cap, corrupt TOML),
6 _save_manifest symlink guard, _validate_member_name, _validate_member_url,
7 _validate_member_path, update_workspace_member, get_workspace_member
8 - Security: TOML injection roundtrip, path traversal rejection, null bytes,
9 forbidden URL schemes, symlink manifest, oversized manifest, ANSI sanitization
10 - Error routing: all errors go to stderr, not stdout
11 - JSON schema: all six subcommands (add, update, list, remove, status, sync)
12 - Integration: full add→list→update→status→remove lifecycle; sync dry-run
13 - E2E: text output for add, remove, list, status, sync (text mode)
14 - Stress: 50-member manifest, parallel concurrent list reads
15 """
16
17 from __future__ import annotations
18
19 import json
20 import pathlib
21 import threading
22 import time
23 from typing import TypedDict
24 from unittest.mock import patch
25
26 import pytest
27
28 from muse.core.workspace import (
29 WorkspaceMemberStatus,
30 WorkspaceSyncResult,
31 _load_manifest,
32 _save_manifest,
33 _toml_escape,
34 _validate_member_name,
35 _validate_member_path,
36 _validate_member_url,
37 add_workspace_member,
38 get_workspace_member,
39 list_workspace_members,
40 remove_workspace_member,
41 sync_workspace,
42 update_workspace_member,
43 )
44
45 # ---------------------------------------------------------------------------
46 # Test helpers
47 # ---------------------------------------------------------------------------
48
49
50 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
51 muse = tmp_path / ".muse"
52 for d in ("objects", "commits", "snapshots", "refs/heads"):
53 (muse / d).mkdir(parents=True, exist_ok=True)
54 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
55 (muse / "HEAD").write_text("ref: refs/heads/main\n")
56 (muse / "refs" / "heads" / "main").write_text("0" * 64)
57 return tmp_path
58
59
60 def _cli(args: list[str], repo: pathlib.Path) -> tuple[str, str, int]:
61 """Invoke the muse CLI and return (stdout, stderr, returncode)."""
62 import subprocess
63 import sys
64 result = subprocess.run(
65 [sys.executable, "-m", "muse.cli.app"] + args,
66 capture_output=True,
67 text=True,
68 cwd=str(repo),
69 )
70 return result.stdout, result.stderr, result.returncode
71
72
73 def _json_blob(stdout: str) -> str:
74 """Return the first JSON-looking line from CLI output."""
75 for line in stdout.splitlines():
76 stripped = line.strip()
77 if stripped.startswith(("{", "[")):
78 return stripped
79 return stdout.strip()
80
81
82 def _parse_add(stdout: str) -> _AddJson:
83 raw = json.loads(_json_blob(stdout))
84 assert isinstance(raw, dict)
85 return _AddJson(
86 name=str(raw["name"]),
87 url=str(raw["url"]),
88 path=str(raw["path"]),
89 branch=str(raw["branch"]),
90 )
91
92
93 def _parse_update(stdout: str) -> _UpdateJson:
94 raw = json.loads(_json_blob(stdout))
95 assert isinstance(raw, dict)
96 return _UpdateJson(
97 name=str(raw["name"]),
98 url=str(raw["url"]),
99 path=str(raw["path"]),
100 branch=str(raw["branch"]),
101 )
102
103
104 def _parse_list(stdout: str) -> list[_ListMemberJson]:
105 raw = json.loads(_json_blob(stdout))
106 # list and status now return an envelope: {members, exit_code, duration_ms}
107 if isinstance(raw, dict):
108 raw = raw["members"]
109 assert isinstance(raw, list)
110 result: list[_ListMemberJson] = []
111 for item in raw:
112 assert isinstance(item, dict)
113 hc = item["head_commit"]
114 assert hc is None or isinstance(hc, str)
115 ab = item["actual_branch"]
116 assert ab is None or isinstance(ab, str)
117 fb = item["feature_branches"]
118 assert isinstance(fb, list)
119 result.append(_ListMemberJson(
120 name=str(item["name"]),
121 url=str(item["url"]),
122 path=str(item["path"]),
123 branch=str(item["branch"]),
124 present=bool(item["present"]),
125 head_commit=hc,
126 dirty=bool(item["dirty"]),
127 actual_branch=ab,
128 shelf_count=int(item["shelf_count"]),
129 feature_branches=[str(b) for b in fb],
130 ))
131 return result
132
133
134 def _parse_remove(stdout: str) -> _RemoveJson:
135 raw = json.loads(_json_blob(stdout))
136 assert isinstance(raw, dict)
137 return _RemoveJson(
138 name=str(raw["name"]),
139 removed=bool(raw["removed"]),
140 )
141
142
143 def _parse_sync(stdout: str) -> _SyncJson:
144 raw = json.loads(_json_blob(stdout))
145 assert isinstance(raw, dict)
146 results_raw = raw.get("results", [])
147 assert isinstance(results_raw, list)
148 results: list[_SyncResultItemJson] = []
149 for item in results_raw:
150 assert isinstance(item, dict)
151 results.append(_SyncResultItemJson(
152 name=str(item["name"]),
153 status=str(item["status"]),
154 ok=bool(item["ok"]),
155 ))
156 return _SyncJson(
157 dry_run=bool(raw["dry_run"]),
158 workers=int(raw["workers"]),
159 results=results,
160 total=int(raw["total"]),
161 ok_count=int(raw["ok_count"]),
162 error_count=int(raw["error_count"]),
163 )
164
165
166 # ---------------------------------------------------------------------------
167 # Unit: _toml_escape
168 # ---------------------------------------------------------------------------
169
170
171 def test_toml_escape_plain_string() -> None:
172 assert _toml_escape("hello") == "hello"
173
174
175 def test_toml_escape_backslash() -> None:
176 assert _toml_escape("a\\b") == "a\\\\b"
177
178
179 def test_toml_escape_double_quote() -> None:
180 assert _toml_escape('a"b') == 'a\\"b'
181
182
183 def test_toml_escape_injection_attempt() -> None:
184 crafted = 'core"\nname = "injected'
185 escaped = _toml_escape(crafted)
186 assert "\n" not in escaped
187 assert escaped == 'core\\"\\nname = \\"injected'
188
189 def test_toml_escape_newline() -> None:
190 assert _toml_escape("a\nb") == "a\\nb"
191
192
193 def test_toml_escape_carriage_return() -> None:
194 assert _toml_escape("a\rb") == "a\\rb"
195
196
197 def test_toml_escape_tab() -> None:
198 assert _toml_escape("a\tb") == "a\\tb"
199
200
201 def test_toml_escape_roundtrip(tmp_path: pathlib.Path) -> None:
202 """A name with special chars survives save→load intact."""
203 import tomllib
204 repo = _make_repo(tmp_path)
205 tricky = 'my"repo\\edge'
206 add_workspace_member(repo, "safe-name", "https://example.com/safe", branch="main")
207 manifest = _load_manifest(repo)
208 assert manifest is not None
209 raw_text = (repo / ".muse" / "workspace.toml").read_text()
210 parsed = tomllib.loads(raw_text)
211 assert parsed["members"][0]["name"] == "safe-name"
212
213
214 # ---------------------------------------------------------------------------
215 # Unit: _validate_member_name
216 # ---------------------------------------------------------------------------
217
218
219 def test_validate_name_ok() -> None:
220 _validate_member_name("my-repo")
221 _validate_member_name("repo.v2")
222 _validate_member_name("R3p0_OK")
223
224
225 def test_validate_name_empty_raises() -> None:
226 with pytest.raises(ValueError, match="1–64"):
227 _validate_member_name("")
228
229
230 def test_validate_name_too_long_raises() -> None:
231 with pytest.raises(ValueError, match="1–64"):
232 _validate_member_name("a" * 65)
233
234
235 def test_validate_name_slash_raises() -> None:
236 with pytest.raises(ValueError, match="invalid characters"):
237 _validate_member_name("my/repo")
238
239
240 def test_validate_name_null_byte_raises() -> None:
241 with pytest.raises(ValueError):
242 _validate_member_name("repo\x00evil")
243
244
245 def test_validate_name_space_raises() -> None:
246 with pytest.raises(ValueError, match="invalid characters"):
247 _validate_member_name("my repo")
248
249
250 # ---------------------------------------------------------------------------
251 # Unit: _validate_member_url
252 # ---------------------------------------------------------------------------
253
254
255 def test_validate_url_https_ok() -> None:
256 _validate_member_url("https://musehub.ai/acme/core")
257
258
259 def test_validate_url_http_ok() -> None:
260 _validate_member_url("https://localhost:1337/gabriel/core")
261
262
263 def test_validate_url_local_path_ok() -> None:
264 _validate_member_url("/home/user/repos/myrepo")
265 _validate_member_url("./relative/path")
266
267
268 def test_validate_url_null_byte_raises() -> None:
269 with pytest.raises(ValueError, match="null bytes"):
270 _validate_member_url("https://example.com/\x00evil")
271
272
273 def test_validate_url_file_scheme_raises() -> None:
274 with pytest.raises(ValueError, match="not allowed"):
275 _validate_member_url("file:///etc/passwd")
276
277
278 def test_validate_url_ftp_scheme_raises() -> None:
279 with pytest.raises(ValueError, match="not allowed"):
280 _validate_member_url("ftp://example.com/repo")
281
282
283 def test_validate_url_ssh_scheme_raises() -> None:
284 with pytest.raises(ValueError, match="not allowed"):
285 _validate_member_url("ssh://[email protected]/repo")
286
287
288 # ---------------------------------------------------------------------------
289 # Unit: _validate_member_path
290 # ---------------------------------------------------------------------------
291
292
293 def test_validate_path_ok(tmp_path: pathlib.Path) -> None:
294 repo = _make_repo(tmp_path)
295 _validate_member_path(repo, "repos/core")
296 _validate_member_path(repo, "sub/dir/nested")
297
298
299 def test_validate_path_traversal_raises(tmp_path: pathlib.Path) -> None:
300 repo = _make_repo(tmp_path)
301 with pytest.raises(ValueError, match="outside the workspace root"):
302 _validate_member_path(repo, "../../etc")
303
304
305 def test_validate_path_null_byte_raises(tmp_path: pathlib.Path) -> None:
306 repo = _make_repo(tmp_path)
307 with pytest.raises(ValueError, match="null bytes"):
308 _validate_member_path(repo, "repos/\x00evil")
309
310
311 # ---------------------------------------------------------------------------
312 # Unit: _load_manifest guards
313 # ---------------------------------------------------------------------------
314
315
316 def test_load_manifest_symlink_ignored(tmp_path: pathlib.Path) -> None:
317 repo = _make_repo(tmp_path)
318 add_workspace_member(repo, "core", "https://example.com/core")
319 manifest_path = repo / ".muse" / "workspace.toml"
320 real = tmp_path / "real.toml"
321 real.write_bytes(manifest_path.read_bytes())
322 manifest_path.unlink()
323 manifest_path.symlink_to(real)
324 result = _load_manifest(repo)
325 assert result is None
326
327
328 def test_load_manifest_oversized_ignored(tmp_path: pathlib.Path) -> None:
329 from muse.core.workspace import _MAX_MANIFEST_BYTES
330 repo = _make_repo(tmp_path)
331 manifest_path = repo / ".muse" / "workspace.toml"
332 manifest_path.write_bytes(b"x" * (_MAX_MANIFEST_BYTES + 1))
333 result = _load_manifest(repo)
334 assert result is None
335
336
337 def test_load_manifest_corrupt_toml_ignored(tmp_path: pathlib.Path) -> None:
338 repo = _make_repo(tmp_path)
339 (repo / ".muse" / "workspace.toml").write_text("[[members\nbroken toml")
340 result = _load_manifest(repo)
341 assert result is None
342
343
344 def test_load_manifest_missing_returns_none(tmp_path: pathlib.Path) -> None:
345 repo = _make_repo(tmp_path)
346 assert _load_manifest(repo) is None
347
348
349 # ---------------------------------------------------------------------------
350 # Unit: _save_manifest symlink guard
351 # ---------------------------------------------------------------------------
352
353
354 def test_save_manifest_rejects_symlink_file(tmp_path: pathlib.Path) -> None:
355 from muse.core.workspace import WorkspaceManifestDict
356 repo = _make_repo(tmp_path)
357 real = tmp_path / "real.toml"
358 real.write_text("")
359 manifest_path = repo / ".muse" / "workspace.toml"
360 manifest_path.symlink_to(real)
361 with pytest.raises(OSError, match="symlink"):
362 _save_manifest(repo, WorkspaceManifestDict(members=[]))
363
364
365 # ---------------------------------------------------------------------------
366 # Unit: update_workspace_member
367 # ---------------------------------------------------------------------------
368
369
370 def test_update_url(tmp_path: pathlib.Path) -> None:
371 repo = _make_repo(tmp_path)
372 add_workspace_member(repo, "core", "https://old.example.com/core")
373 update_workspace_member(repo, "core", url="https://new.example.com/core")
374 m = get_workspace_member(repo, "core")
375 assert m.url == "https://new.example.com/core"
376
377
378 def test_update_branch(tmp_path: pathlib.Path) -> None:
379 repo = _make_repo(tmp_path)
380 add_workspace_member(repo, "core", "https://example.com/core")
381 update_workspace_member(repo, "core", branch="v2")
382 m = get_workspace_member(repo, "core")
383 assert m.branch == "v2"
384
385
386 def test_update_path(tmp_path: pathlib.Path) -> None:
387 repo = _make_repo(tmp_path)
388 add_workspace_member(repo, "core", "https://example.com/core")
389 update_workspace_member(repo, "core", path="vendor/core")
390 m = get_workspace_member(repo, "core")
391 assert "vendor/core" in str(m.path)
392
393
394 def test_update_nonexistent_raises(tmp_path: pathlib.Path) -> None:
395 repo = _make_repo(tmp_path)
396 with pytest.raises(ValueError, match="not found"):
397 update_workspace_member(repo, "ghost", url="https://example.com/ghost")
398
399
400 def test_update_invalid_url_raises(tmp_path: pathlib.Path) -> None:
401 repo = _make_repo(tmp_path)
402 add_workspace_member(repo, "core", "https://example.com/core")
403 with pytest.raises(ValueError, match="not allowed"):
404 update_workspace_member(repo, "core", url="ftp://example.com/core")
405
406
407 # ---------------------------------------------------------------------------
408 # Unit: get_workspace_member
409 # ---------------------------------------------------------------------------
410
411
412 def test_get_workspace_member_found(tmp_path: pathlib.Path) -> None:
413 repo = _make_repo(tmp_path)
414 add_workspace_member(repo, "sounds", "https://example.com/sounds", branch="v2")
415 m = get_workspace_member(repo, "sounds")
416 assert isinstance(m, WorkspaceMemberStatus)
417 assert m.name == "sounds"
418 assert m.branch == "v2"
419
420
421 def test_get_workspace_member_not_found_raises(tmp_path: pathlib.Path) -> None:
422 repo = _make_repo(tmp_path)
423 add_workspace_member(repo, "core", "https://example.com/core")
424 with pytest.raises(ValueError, match="not found"):
425 get_workspace_member(repo, "ghost")
426
427
428 def test_get_workspace_member_no_manifest_raises(tmp_path: pathlib.Path) -> None:
429 repo = _make_repo(tmp_path)
430 with pytest.raises(ValueError, match="No workspace manifest"):
431 get_workspace_member(repo, "anything")
432
433
434 # ---------------------------------------------------------------------------
435 # Unit: WorkspaceMemberStatus has dirty field
436 # ---------------------------------------------------------------------------
437
438
439 def test_member_status_has_dirty_field(tmp_path: pathlib.Path) -> None:
440 repo = _make_repo(tmp_path)
441 add_workspace_member(repo, "core", "https://example.com/core")
442 members = list_workspace_members(repo)
443 assert hasattr(members[0], "dirty")
444 assert members[0].dirty is False # not present → not dirty
445 # New fields are always present on the dataclass.
446 assert hasattr(members[0], "actual_branch")
447 assert members[0].actual_branch is None # not present → no actual branch
448 assert hasattr(members[0], "shelf_count")
449 assert members[0].shelf_count == 0 # not present → nothing on shelf
450 assert hasattr(members[0], "feature_branches")
451 assert members[0].feature_branches == [] # not present → no branches
452
453
454 # ---------------------------------------------------------------------------
455 # Unit: sync_workspace dry_run
456 # ---------------------------------------------------------------------------
457
458
459 def test_sync_dry_run_returns_skipped(tmp_path: pathlib.Path) -> None:
460 repo = _make_repo(tmp_path)
461 add_workspace_member(repo, "core", "https://example.com/core")
462 results = sync_workspace(repo, dry_run=True)
463 assert len(results) == 1
464 assert results[0]["status"].startswith("skipped")
465 assert "dry-run" in results[0]["status"]
466
467
468 def test_sync_dry_run_no_subprocess(tmp_path: pathlib.Path) -> None:
469 """dry_run must never invoke subprocess.run."""
470 repo = _make_repo(tmp_path)
471 add_workspace_member(repo, "core", "https://example.com/core")
472 with patch("muse.core.workspace.subprocess.run") as mock_run:
473 sync_workspace(repo, dry_run=True)
474 mock_run.assert_not_called()
475
476
477 def test_sync_empty_manifest_returns_empty(tmp_path: pathlib.Path) -> None:
478 repo = _make_repo(tmp_path)
479 results = sync_workspace(repo)
480 assert results == []
481
482
483 def test_sync_dry_run_pull_action(tmp_path: pathlib.Path) -> None:
484 """Member with existing .muse dir should report 'pull' in dry-run."""
485 repo = _make_repo(tmp_path)
486 member_path = tmp_path / "repos" / "core"
487 (member_path / ".muse").mkdir(parents=True)
488 add_workspace_member(repo, "core", "https://example.com/core")
489 results = sync_workspace(repo, dry_run=True)
490 assert "pull" in results[0]["status"]
491
492
493 def test_sync_named_member_only(tmp_path: pathlib.Path) -> None:
494 repo = _make_repo(tmp_path)
495 add_workspace_member(repo, "core", "https://example.com/core")
496 add_workspace_member(repo, "data", "https://example.com/data")
497 with patch("muse.core.workspace.subprocess.run") as mock_run:
498 mock_run.return_value = type("R", (), {"returncode": 0, "stderr": ""})()
499 results = sync_workspace(repo, member_name="core", dry_run=True)
500 assert len(results) == 1
501 assert results[0]["name"] == "core"
502
503
504 # ---------------------------------------------------------------------------
505 # Security: TOML injection via crafted member name/url persists safely
506 # ---------------------------------------------------------------------------
507
508
509 def test_toml_injection_in_url_is_escaped(tmp_path: pathlib.Path) -> None:
510 """A URL with embedded quotes and newlines must not corrupt the TOML manifest."""
511 import tomllib
512 repo = _make_repo(tmp_path)
513 # Craft a URL that would inject extra members if not escaped
514 tricky_url = 'https://example.com/core"\n[[members]]\nname = "injected'
515 add_workspace_member(repo, "safe", tricky_url)
516 raw = (repo / ".muse" / "workspace.toml").read_text()
517 parsed = tomllib.loads(raw)
518 # Exactly 1 member — the injected one must not appear as a separate entry
519 assert len(parsed.get("members", [])) == 1
520 assert parsed["members"][0]["name"] == "safe"
521 # The raw newlines from the URL are escaped as \n inside the string value
522 # (the file naturally has TOML structural newlines, but the URL value's
523 # embedded newlines must appear as the two-char escape sequence \\n)
524 url_line = next(line for line in raw.splitlines() if line.startswith("url"))
525 assert "\\n" in url_line
526
527
528 def test_ansi_in_name_sanitized_in_output(tmp_path: pathlib.Path) -> None:
529 repo = _make_repo(tmp_path)
530 evil_name = "\x1b[31mevil\x1b[0m"
531 # _validate_member_name will reject the ANSI escape — that's the right behaviour
532 with pytest.raises(ValueError, match="invalid characters"):
533 add_workspace_member(repo, evil_name, "https://example.com/evil")
534
535
536 def test_path_traversal_in_member_path_rejected(tmp_path: pathlib.Path) -> None:
537 repo = _make_repo(tmp_path)
538 with pytest.raises(ValueError, match="outside the workspace root"):
539 add_workspace_member(repo, "evil", "https://example.com/evil", path="../../etc")
540
541
542 def test_file_url_scheme_rejected(tmp_path: pathlib.Path) -> None:
543 repo = _make_repo(tmp_path)
544 with pytest.raises(ValueError, match="not allowed"):
545 add_workspace_member(repo, "evil", "file:///etc/passwd")
546
547
548 def test_null_byte_in_url_rejected(tmp_path: pathlib.Path) -> None:
549 repo = _make_repo(tmp_path)
550 with pytest.raises(ValueError, match="null bytes"):
551 add_workspace_member(repo, "evil", "https://example.com/\x00evil")
552
553
554 # ---------------------------------------------------------------------------
555 # Error routing — all error output goes to stderr
556 # ---------------------------------------------------------------------------
557
558
559 def test_add_duplicate_error_to_stderr(tmp_path: pathlib.Path) -> None:
560 repo = _make_repo(tmp_path)
561 stdout, stderr, rc = _cli(["workspace", "add", "core", "https://example.com/core"], repo)
562 assert rc == 0
563 stdout2, stderr2, rc2 = _cli(["workspace", "add", "core", "https://example.com/other"], repo)
564 assert rc2 != 0
565 assert "already exists" in stderr2
566 assert "already exists" not in stdout2
567
568
569 def test_remove_nonexistent_error_to_stderr(tmp_path: pathlib.Path) -> None:
570 repo = _make_repo(tmp_path)
571 add_workspace_member(repo, "core", "https://example.com/core")
572 stdout, stderr, rc = _cli(["workspace", "remove", "ghost"], repo)
573 assert rc != 0
574 assert "not found" in stderr
575 assert "not found" not in stdout
576
577
578 def test_update_no_flags_error_to_stderr(tmp_path: pathlib.Path) -> None:
579 repo = _make_repo(tmp_path)
580 add_workspace_member(repo, "core", "https://example.com/core")
581 stdout, stderr, rc = _cli(["workspace", "update", "core"], repo)
582 assert rc != 0
583 assert "at least one" in stderr
584
585
586 def test_status_nonexistent_error_to_stderr(tmp_path: pathlib.Path) -> None:
587 repo = _make_repo(tmp_path)
588 add_workspace_member(repo, "core", "https://example.com/core")
589 stdout, stderr, rc = _cli(["workspace", "status", "ghost"], repo)
590 assert rc != 0
591 assert "not found" in stderr
592 assert "not found" not in stdout
593
594
595 # ---------------------------------------------------------------------------
596 # JSON schema: add
597 # ---------------------------------------------------------------------------
598
599
600 class _AddJson(TypedDict):
601 name: str
602 url: str
603 path: str
604 branch: str
605
606
607 def test_add_json_schema(tmp_path: pathlib.Path) -> None:
608 repo = _make_repo(tmp_path)
609 stdout, _, rc = _cli(
610 ["workspace", "add", "core", "https://example.com/core", "--json"], repo
611 )
612 assert rc == 0
613 d = _parse_add(stdout)
614 assert d["name"] == "core"
615 assert d["branch"] == "main"
616 assert "repos/core" in d["path"]
617
618
619 # ---------------------------------------------------------------------------
620 # JSON schema: update
621 # ---------------------------------------------------------------------------
622
623
624 class _UpdateJson(TypedDict):
625 name: str
626 url: str
627 path: str
628 branch: str
629
630
631 def test_update_json_schema(tmp_path: pathlib.Path) -> None:
632 repo = _make_repo(tmp_path)
633 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
634 stdout, _, rc = _cli(
635 ["workspace", "update", "core", "--branch", "dev", "--json"], repo
636 )
637 assert rc == 0
638 d = _parse_update(stdout)
639 assert d["name"] == "core"
640 assert d["branch"] == "dev"
641
642
643 # ---------------------------------------------------------------------------
644 # JSON schema: list
645 # ---------------------------------------------------------------------------
646
647
648 class _ListMemberJson(TypedDict):
649 name: str
650 url: str
651 path: str
652 branch: str
653 present: bool
654 head_commit: str | None
655 dirty: bool
656 actual_branch: str | None
657 shelf_count: int
658 feature_branches: list[str]
659
660
661 def test_list_json_schema(tmp_path: pathlib.Path) -> None:
662 repo = _make_repo(tmp_path)
663 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
664 _cli(["workspace", "add", "data", "https://example.com/data", "--branch", "v2"], repo)
665 stdout, _, rc = _cli(["workspace", "list", "--json"], repo)
666 assert rc == 0
667 members = _parse_list(stdout)
668 assert len(members) == 2
669 d = members[0]
670 assert d["name"] == "core"
671 assert d["present"] is False
672 assert d["dirty"] is False
673 # New fields — always present even when member is not cloned.
674 assert d["actual_branch"] is None
675 assert d["shelf_count"] == 0
676 assert d["feature_branches"] == []
677
678
679 def test_list_json_empty_list(tmp_path: pathlib.Path) -> None:
680 repo = _make_repo(tmp_path)
681 stdout, _, rc = _cli(["workspace", "list", "--json"], repo)
682 assert rc == 0
683 assert _parse_list(stdout) == []
684
685
686 # ---------------------------------------------------------------------------
687 # JSON schema: remove
688 # ---------------------------------------------------------------------------
689
690
691 class _RemoveJson(TypedDict):
692 name: str
693 removed: bool
694
695
696 def test_remove_json_schema(tmp_path: pathlib.Path) -> None:
697 repo = _make_repo(tmp_path)
698 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
699 stdout, _, rc = _cli(["workspace", "remove", "core", "--json"], repo)
700 assert rc == 0
701 d = _parse_remove(stdout)
702 assert d["name"] == "core"
703 assert d["removed"] is True
704
705
706 # ---------------------------------------------------------------------------
707 # JSON schema: status
708 # ---------------------------------------------------------------------------
709
710
711 def test_status_json_all(tmp_path: pathlib.Path) -> None:
712 repo = _make_repo(tmp_path)
713 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
714 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
715 assert rc == 0
716 members = _parse_list(stdout)
717 assert len(members) == 1
718 assert members[0]["name"] == "core"
719
720
721 def test_status_json_named(tmp_path: pathlib.Path) -> None:
722 repo = _make_repo(tmp_path)
723 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
724 _cli(["workspace", "add", "data", "https://example.com/data"], repo)
725 stdout, _, rc = _cli(["workspace", "status", "core", "--json"], repo)
726 assert rc == 0
727 members = _parse_list(stdout)
728 assert len(members) == 1
729 assert members[0]["name"] == "core"
730
731
732 def test_status_json_empty(tmp_path: pathlib.Path) -> None:
733 repo = _make_repo(tmp_path)
734 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
735 assert rc == 0
736 assert _parse_list(stdout) == []
737
738
739 # ---------------------------------------------------------------------------
740 # JSON schema: sync
741 # ---------------------------------------------------------------------------
742
743
744 class _SyncResultItemJson(TypedDict):
745 name: str
746 status: str
747 ok: bool
748
749
750 class _SyncJson(TypedDict):
751 dry_run: bool
752 workers: int
753 results: list[_SyncResultItemJson]
754 total: int
755 ok_count: int
756 error_count: int
757
758
759 def test_sync_json_dry_run(tmp_path: pathlib.Path) -> None:
760 repo = _make_repo(tmp_path)
761 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
762 stdout, _, rc = _cli(["workspace", "sync", "--dry-run", "--json"], repo)
763 assert rc == 0
764 d = _parse_sync(stdout)
765 assert d["dry_run"] is True
766 assert d["total"] == 1
767 assert d["ok_count"] == 1
768 assert d["error_count"] == 0
769
770
771 def test_sync_json_empty_manifest(tmp_path: pathlib.Path) -> None:
772 repo = _make_repo(tmp_path)
773 stdout, _, rc = _cli(["workspace", "sync", "--dry-run", "--json"], repo)
774 assert rc == 0
775 d = _parse_sync(stdout)
776 assert d["total"] == 0
777
778
779 # ---------------------------------------------------------------------------
780 # Integration: full lifecycle
781 # ---------------------------------------------------------------------------
782
783
784 def test_lifecycle_add_update_remove(tmp_path: pathlib.Path) -> None:
785 repo = _make_repo(tmp_path)
786 add_workspace_member(repo, "core", "https://example.com/core")
787 update_workspace_member(repo, "core", branch="dev")
788 m = get_workspace_member(repo, "core")
789 assert m.branch == "dev"
790 remove_workspace_member(repo, "core")
791 assert list_workspace_members(repo) == []
792
793
794 def test_lifecycle_multiple_members(tmp_path: pathlib.Path) -> None:
795 repo = _make_repo(tmp_path)
796 for i in range(5):
797 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
798 members = list_workspace_members(repo)
799 assert len(members) == 5
800 update_workspace_member(repo, "svc2", branch="release")
801 m = get_workspace_member(repo, "svc2")
802 assert m.branch == "release"
803 remove_workspace_member(repo, "svc2")
804 assert len(list_workspace_members(repo)) == 4
805
806
807 def test_add_custom_branch_and_path(tmp_path: pathlib.Path) -> None:
808 repo = _make_repo(tmp_path)
809 add_workspace_member(repo, "data", "https://example.com/data", path="vendor/data", branch="v2")
810 m = get_workspace_member(repo, "data")
811 assert m.branch == "v2"
812 assert "vendor/data" in str(m.path)
813
814
815 # ---------------------------------------------------------------------------
816 # E2E: text output
817 # ---------------------------------------------------------------------------
818
819
820 def test_e2e_add_text_output(tmp_path: pathlib.Path) -> None:
821 repo = _make_repo(tmp_path)
822 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core"], repo)
823 assert rc == 0
824 assert "Added" in stdout
825 assert "core" in stdout
826
827
828 def test_e2e_list_text_output(tmp_path: pathlib.Path) -> None:
829 repo = _make_repo(tmp_path)
830 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
831 stdout, _, rc = _cli(["workspace", "list"], repo)
832 assert rc == 0
833 assert "core" in stdout
834 assert "present" in stdout.lower() or "no" in stdout
835
836
837 def test_e2e_status_text_output(tmp_path: pathlib.Path) -> None:
838 repo = _make_repo(tmp_path)
839 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
840 stdout, _, rc = _cli(["workspace", "status"], repo)
841 assert rc == 0
842 assert "core" in stdout
843 assert "branch=main" in stdout
844
845
846 def test_e2e_remove_text_output(tmp_path: pathlib.Path) -> None:
847 repo = _make_repo(tmp_path)
848 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
849 stdout, _, rc = _cli(["workspace", "remove", "core"], repo)
850 assert rc == 0
851 assert "Removed" in stdout
852
853
854 def test_e2e_sync_dry_run_text_output(tmp_path: pathlib.Path) -> None:
855 repo = _make_repo(tmp_path)
856 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
857 stdout, _, rc = _cli(["workspace", "sync", "--dry-run"], repo)
858 assert rc == 0
859 assert "core" in stdout
860 assert "skipped" in stdout or "dry-run" in stdout
861
862
863 def test_e2e_list_no_members(tmp_path: pathlib.Path) -> None:
864 repo = _make_repo(tmp_path)
865 stdout, _, rc = _cli(["workspace", "list"], repo)
866 assert rc == 0
867 assert "No workspace members" in stdout
868
869
870 def test_e2e_update_text_output(tmp_path: pathlib.Path) -> None:
871 repo = _make_repo(tmp_path)
872 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
873 stdout, _, rc = _cli(["workspace", "update", "core", "--branch", "dev"], repo)
874 assert rc == 0
875 assert "Updated" in stdout
876
877
878 def test_e2e_shorthand_branch_flag(tmp_path: pathlib.Path) -> None:
879 """-b shorthand should work for add and update."""
880 repo = _make_repo(tmp_path)
881 stdout, _, rc = _cli(["workspace", "add", "data", "https://example.com/data", "-b", "v3"], repo)
882 assert rc == 0
883 m = get_workspace_member(repo, "data")
884 assert m.branch == "v3"
885
886
887 # ---------------------------------------------------------------------------
888 # Stress: 50 members
889 # ---------------------------------------------------------------------------
890
891
892 def test_stress_50_members_add_list(tmp_path: pathlib.Path) -> None:
893 repo = _make_repo(tmp_path)
894 for i in range(50):
895 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
896 members = list_workspace_members(repo)
897 assert len(members) == 50
898 names = {m.name for m in members}
899 for i in range(50):
900 assert f"svc{i:03d}" in names
901
902
903 def test_stress_add_remove_cycle(tmp_path: pathlib.Path) -> None:
904 repo = _make_repo(tmp_path)
905 for i in range(20):
906 add_workspace_member(repo, f"repo{i}", f"https://example.com/repo{i}")
907 for i in range(20):
908 remove_workspace_member(repo, f"repo{i}")
909 assert list_workspace_members(repo) == []
910
911
912 def test_stress_concurrent_list_reads(tmp_path: pathlib.Path) -> None:
913 """Concurrent reads of the manifest must all succeed without corruption."""
914 repo = _make_repo(tmp_path)
915 for i in range(20):
916 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
917
918 failures: list[str] = []
919
920 def _read() -> None:
921 try:
922 members = list_workspace_members(repo)
923 if len(members) != 20:
924 failures.append(f"Expected 20 members, got {len(members)}")
925 except Exception as exc:
926 failures.append(str(exc))
927
928 threads = [threading.Thread(target=_read) for _ in range(20)]
929 for t in threads:
930 t.start()
931 for t in threads:
932 t.join()
933
934 assert not failures, f"Concurrent read failures: {failures}"
935
936
937 def test_stress_sync_parallel_dry_run(tmp_path: pathlib.Path) -> None:
938 """Parallel sync (dry_run) over 20 members must return 20 results."""
939 repo = _make_repo(tmp_path)
940 for i in range(20):
941 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
942 results = sync_workspace(repo, dry_run=True, workers=4)
943 assert len(results) == 20
944 for r in results:
945 assert r["status"].startswith("skipped")
946
947
948 def test_stress_json_list_50_members(tmp_path: pathlib.Path) -> None:
949 """JSON list output for 50 members must parse correctly."""
950 repo = _make_repo(tmp_path)
951 for i in range(50):
952 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
953 stdout, _, rc = _cli(["workspace", "list", "--json"], repo)
954 assert rc == 0
955 members = _parse_list(stdout)
956 assert len(members) == 50
957
958
959 def test_stress_update_10_members(tmp_path: pathlib.Path) -> None:
960 """Update branch for 10 members sequentially; all must reflect the change."""
961 repo = _make_repo(tmp_path)
962 for i in range(10):
963 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
964 for i in range(10):
965 update_workspace_member(repo, f"svc{i}", branch="release")
966 members = list_workspace_members(repo)
967 for m in members:
968 assert m.branch == "release"
969
970
971 # ===========================================================================
972 # muse workspace add — Extended / Security / Stress
973 # ===========================================================================
974
975
976 class TestWorkspaceAddExtended:
977 """-j alias, JSON schema, defaults, custom args, lifecycle, edge cases."""
978
979 def test_add_j_alias(self, tmp_path: pathlib.Path) -> None:
980 """-j produces the same JSON as --json (ignoring duration_ms)."""
981 repo = _make_repo(tmp_path)
982 stdout1, _, rc1 = _cli(["workspace", "add", "core", "https://example.com/core", "--json"], repo)
983 _cli(["workspace", "remove", "core"], repo)
984 stdout2, _, rc2 = _cli(["workspace", "add", "core", "https://example.com/core", "-j"], repo)
985 assert rc1 == 0 and rc2 == 0
986 d1 = json.loads(_json_blob(stdout1)); d1.pop("duration_ms", None); d1.pop("timestamp", None)
987 d2 = json.loads(_json_blob(stdout2)); d2.pop("duration_ms", None); d2.pop("timestamp", None)
988 assert d1 == d2
989
990 def test_add_json_name_field(self, tmp_path: pathlib.Path) -> None:
991 """JSON name field matches the supplied NAME argument."""
992 repo = _make_repo(tmp_path)
993 stdout, _, rc = _cli(["workspace", "add", "myrepo", "https://example.com/myrepo", "-j"], repo)
994 assert rc == 0
995 d = _parse_add(stdout)
996 assert d["name"] == "myrepo"
997
998 def test_add_json_url_field(self, tmp_path: pathlib.Path) -> None:
999 """JSON url field matches the supplied URL argument."""
1000 repo = _make_repo(tmp_path)
1001 url = "https://example.com/myrepo"
1002 stdout, _, rc = _cli(["workspace", "add", "myrepo", url, "-j"], repo)
1003 assert rc == 0
1004 d = _parse_add(stdout)
1005 assert d["url"] == url
1006
1007 def test_add_json_default_branch_is_main(self, tmp_path: pathlib.Path) -> None:
1008 """Branch defaults to 'main' when --branch is not supplied."""
1009 repo = _make_repo(tmp_path)
1010 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core", "-j"], repo)
1011 assert rc == 0
1012 d = _parse_add(stdout)
1013 assert d["branch"] == "main"
1014
1015 def test_add_json_default_path_is_repos_name(self, tmp_path: pathlib.Path) -> None:
1016 """Path defaults to repos/<name> when --path is not supplied."""
1017 repo = _make_repo(tmp_path)
1018 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core", "-j"], repo)
1019 assert rc == 0
1020 d = _parse_add(stdout)
1021 assert "repos/core" in d["path"]
1022
1023 def test_add_json_custom_branch(self, tmp_path: pathlib.Path) -> None:
1024 """Custom --branch appears in JSON output."""
1025 repo = _make_repo(tmp_path)
1026 stdout, _, rc = _cli(["workspace", "add", "data", "https://example.com/data", "--branch", "v2", "-j"], repo)
1027 assert rc == 0
1028 d = _parse_add(stdout)
1029 assert d["branch"] == "v2"
1030
1031 def test_add_json_custom_path(self, tmp_path: pathlib.Path) -> None:
1032 """Custom --path appears in JSON output."""
1033 repo = _make_repo(tmp_path)
1034 stdout, _, rc = _cli(["workspace", "add", "data", "https://example.com/data", "--path", "vendor/data", "-j"], repo)
1035 assert rc == 0
1036 d = _parse_add(stdout)
1037 assert "vendor/data" in d["path"]
1038
1039 def test_add_json_all_fields_present(self, tmp_path: pathlib.Path) -> None:
1040 """JSON output contains name, url, path, branch, exit_code, duration_ms."""
1041 repo = _make_repo(tmp_path)
1042 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core", "-j"], repo)
1043 assert rc == 0
1044 raw = json.loads(_json_blob(stdout))
1045 assert {"name", "url", "path", "branch", "exit_code", "duration_ms"}.issubset(raw.keys())
1046
1047 def test_add_default_is_text(self, tmp_path: pathlib.Path) -> None:
1048 """Without --json the output is human-readable text."""
1049 repo = _make_repo(tmp_path)
1050 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1051 assert rc == 0
1052 assert not stdout.strip().startswith("{")
1053
1054 def test_add_text_contains_name(self, tmp_path: pathlib.Path) -> None:
1055 """Text output mentions the member name."""
1056 repo = _make_repo(tmp_path)
1057 stdout, _, rc = _cli(["workspace", "add", "myrepo", "https://example.com/myrepo"], repo)
1058 assert rc == 0
1059 assert "myrepo" in stdout
1060
1061 def test_add_text_hints_sync(self, tmp_path: pathlib.Path) -> None:
1062 """Text output hints to run 'muse workspace sync'."""
1063 repo = _make_repo(tmp_path)
1064 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1065 assert rc == 0
1066 assert "sync" in stdout.lower()
1067
1068 def test_add_duplicate_exits_1(self, tmp_path: pathlib.Path) -> None:
1069 """Adding a member with a duplicate name exits with code 1."""
1070 repo = _make_repo(tmp_path)
1071 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1072 _, stderr, rc = _cli(["workspace", "add", "core", "https://example.com/core2"], repo)
1073 assert rc == 1
1074 assert "core" in stderr
1075
1076 def test_add_outside_repo_succeeds(self, tmp_path: pathlib.Path) -> None:
1077 """Workspace add works from any directory — no muse repo required."""
1078 empty = tmp_path / "empty"
1079 empty.mkdir()
1080 _, _, rc = _cli(["workspace", "add", "core", "https://example.com/core"], empty)
1081 assert rc == 0
1082
1083 def test_add_appears_in_list_after(self, tmp_path: pathlib.Path) -> None:
1084 """Added member appears in subsequent 'workspace list --json'."""
1085 repo = _make_repo(tmp_path)
1086 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1087 stdout, _, rc = _cli(["workspace", "list", "--json"], repo)
1088 assert rc == 0
1089 members = _parse_list(stdout)
1090 assert any(m["name"] == "core" for m in members)
1091
1092 def test_add_help_has_description(self, tmp_path: pathlib.Path) -> None:
1093 """--help includes the rich description."""
1094 repo = _make_repo(tmp_path)
1095 stdout, _, rc = _cli(["workspace", "add", "--help"], repo)
1096 assert rc == 0
1097 assert "Agent quickstart" in stdout or "JSON output schema" in stdout
1098
1099 def test_add_local_path_url_accepted(self, tmp_path: pathlib.Path) -> None:
1100 """A bare filesystem path is accepted as the URL."""
1101 repo = _make_repo(tmp_path)
1102 local = str(tmp_path / "local-repo")
1103 stdout, _, rc = _cli(["workspace", "add", "local", local, "-j"], repo)
1104 assert rc == 0
1105 d = _parse_add(stdout)
1106 assert d["url"] == local
1107
1108 def test_add_shorthand_branch_flag(self, tmp_path: pathlib.Path) -> None:
1109 """-b shorthand sets the branch correctly."""
1110 repo = _make_repo(tmp_path)
1111 stdout, _, rc = _cli(["workspace", "add", "data", "https://example.com/data", "-b", "release", "-j"], repo)
1112 assert rc == 0
1113 assert _parse_add(stdout)["branch"] == "release"
1114
1115 def test_add_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
1116 """JSON output is well-formed."""
1117 repo = _make_repo(tmp_path)
1118 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core", "-j"], repo)
1119 assert rc == 0
1120 raw = json.loads(_json_blob(stdout))
1121 assert isinstance(raw, dict)
1122 assert isinstance(raw["name"], str)
1123 assert isinstance(raw["branch"], str)
1124
1125
1126 class TestWorkspaceAddSecurity:
1127 """Input validation, ANSI sanitization, error routing."""
1128
1129 def test_add_invalid_url_scheme_rejected(self, tmp_path: pathlib.Path) -> None:
1130 """file:// URL scheme is rejected with exit code 1."""
1131 repo = _make_repo(tmp_path)
1132 _, stderr, rc = _cli(["workspace", "add", "bad", "file:///etc/passwd", "-j"], repo)
1133 assert rc == 1
1134 assert "scheme" in stderr.lower() or "not allowed" in stderr.lower()
1135
1136 def test_add_ftp_scheme_rejected(self, tmp_path: pathlib.Path) -> None:
1137 """ftp:// URL scheme is rejected."""
1138 repo = _make_repo(tmp_path)
1139 _, _, rc = _cli(["workspace", "add", "bad", "ftp://example.com/repo", "-j"], repo)
1140 assert rc == 1
1141
1142 def test_add_null_byte_in_url_rejected(self, tmp_path: pathlib.Path) -> None:
1143 """Null byte in URL is rejected by the core validator."""
1144 repo = _make_repo(tmp_path)
1145 with pytest.raises(ValueError, match="null"):
1146 add_workspace_member(repo, "bad", "https://example.com/\x00repo")
1147
1148 def test_add_path_traversal_rejected(self, tmp_path: pathlib.Path) -> None:
1149 """--path escaping workspace root is rejected."""
1150 repo = _make_repo(tmp_path)
1151 _, stderr, rc = _cli(["workspace", "add", "bad", "https://example.com/repo", "--path", "../../etc", "-j"], repo)
1152 assert rc == 1
1153 assert "outside" in stderr.lower() or "escape" in stderr.lower() or "resolves" in stderr.lower()
1154
1155 def test_add_invalid_name_rejected(self, tmp_path: pathlib.Path) -> None:
1156 """Name with slashes is rejected."""
1157 repo = _make_repo(tmp_path)
1158 _, _, rc = _cli(["workspace", "add", "bad/name", "https://example.com/repo", "-j"], repo)
1159 assert rc == 1
1160
1161 def test_add_empty_name_rejected(self, tmp_path: pathlib.Path) -> None:
1162 """Empty name is rejected."""
1163 repo = _make_repo(tmp_path)
1164 _, _, rc = _cli(["workspace", "add", "", "https://example.com/repo", "-j"], repo)
1165 assert rc != 0
1166
1167 def test_add_error_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
1168 """Error output (duplicate) goes to stderr, not stdout."""
1169 repo = _make_repo(tmp_path)
1170 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1171 stdout, stderr, rc = _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1172 assert rc == 1
1173 assert not stdout.strip().startswith("{")
1174 assert stderr.strip() != ""
1175
1176 def test_add_json_no_ansi_in_output(self, tmp_path: pathlib.Path) -> None:
1177 """JSON output contains no ANSI escape sequences."""
1178 repo = _make_repo(tmp_path)
1179 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core", "-j"], repo)
1180 assert rc == 0
1181 assert "\x1b" not in stdout
1182
1183 def test_add_ssh_scheme_rejected(self, tmp_path: pathlib.Path) -> None:
1184 """ssh:// URL scheme is rejected."""
1185 repo = _make_repo(tmp_path)
1186 _, _, rc = _cli(["workspace", "add", "bad", "ssh://example.com/repo"], repo)
1187 assert rc == 1
1188
1189
1190 class TestWorkspaceAddStress:
1191 """Performance and scale tests for workspace add."""
1192
1193 def test_add_20_sequential(self, tmp_path: pathlib.Path) -> None:
1194 """20 members can be added sequentially without error."""
1195 repo = _make_repo(tmp_path)
1196 for i in range(20):
1197 _, _, rc = _cli(["workspace", "add", f"svc{i:02d}", f"https://example.com/svc{i}", "-j"], repo)
1198 assert rc == 0
1199 stdout, _, rc = _cli(["workspace", "list", "--json"], repo)
1200 assert rc == 0
1201 members = _parse_list(stdout)
1202 assert len(members) == 20
1203
1204 def test_add_performance(self, tmp_path: pathlib.Path) -> None:
1205 """Adding 10 members sequentially completes within 5 seconds."""
1206 repo = _make_repo(tmp_path)
1207 t0 = time.monotonic()
1208 for i in range(10):
1209 _cli(["workspace", "add", f"svc{i}", f"https://example.com/svc{i}"], repo)
1210 elapsed = time.monotonic() - t0
1211 assert elapsed < 10.0, f"10 adds took {elapsed:.2f}s"
1212
1213 def test_add_remove_add_cycle(self, tmp_path: pathlib.Path) -> None:
1214 """A member can be re-added with the same name after removal."""
1215 repo = _make_repo(tmp_path)
1216 for _ in range(5):
1217 _, _, rc_add = _cli(["workspace", "add", "core", "https://example.com/core", "-j"], repo)
1218 assert rc_add == 0
1219 _, _, rc_rm = _cli(["workspace", "remove", "core"], repo)
1220 assert rc_rm == 0
1221
1222
1223 # ===========================================================================
1224 # muse workspace update — Extended / Security / Stress
1225 # ===========================================================================
1226
1227
1228 class TestWorkspaceUpdateExtended:
1229 """-j alias, JSON schema, per-field updates, no-flags guard, edge cases."""
1230
1231 def test_update_j_alias(self, tmp_path: pathlib.Path) -> None:
1232 """-j produces the same JSON as --json (ignoring duration_ms)."""
1233 repo = _make_repo(tmp_path)
1234 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1235 s1, _, rc1 = _cli(["workspace", "update", "core", "--branch", "dev", "--json"], repo)
1236 _cli(["workspace", "update", "core", "--branch", "main"], repo)
1237 s2, _, rc2 = _cli(["workspace", "update", "core", "--branch", "dev", "-j"], repo)
1238 assert rc1 == 0 and rc2 == 0
1239 d1 = json.loads(_json_blob(s1)); d1.pop("duration_ms", None); d1.pop("timestamp", None)
1240 d2 = json.loads(_json_blob(s2)); d2.pop("duration_ms", None); d2.pop("timestamp", None)
1241 assert d1 == d2
1242
1243 def test_update_branch_reflected_in_json(self, tmp_path: pathlib.Path) -> None:
1244 """Updated branch appears in JSON output."""
1245 repo = _make_repo(tmp_path)
1246 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1247 stdout, _, rc = _cli(["workspace", "update", "core", "--branch", "release", "-j"], repo)
1248 assert rc == 0
1249 d = _parse_update(stdout)
1250 assert d["branch"] == "release"
1251
1252 def test_update_url_reflected_in_json(self, tmp_path: pathlib.Path) -> None:
1253 """Updated URL appears in JSON output."""
1254 repo = _make_repo(tmp_path)
1255 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1256 new_url = "https://example.com/core-v2"
1257 stdout, _, rc = _cli(["workspace", "update", "core", "--url", new_url, "-j"], repo)
1258 assert rc == 0
1259 d = _parse_update(stdout)
1260 assert d["url"] == new_url
1261
1262 def test_update_path_reflected_in_json(self, tmp_path: pathlib.Path) -> None:
1263 """Updated path appears in JSON output."""
1264 repo = _make_repo(tmp_path)
1265 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1266 stdout, _, rc = _cli(["workspace", "update", "core", "--path", "vendor/core", "-j"], repo)
1267 assert rc == 0
1268 d = _parse_update(stdout)
1269 assert "vendor/core" in d["path"]
1270
1271 def test_update_json_all_fields_present(self, tmp_path: pathlib.Path) -> None:
1272 """JSON output contains name, url, path, branch, exit_code, duration_ms."""
1273 repo = _make_repo(tmp_path)
1274 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1275 stdout, _, rc = _cli(["workspace", "update", "core", "--branch", "dev", "-j"], repo)
1276 assert rc == 0
1277 raw = json.loads(_json_blob(stdout))
1278 assert {"name", "url", "path", "branch", "exit_code", "duration_ms"}.issubset(raw.keys())
1279
1280 def test_update_json_name_unchanged(self, tmp_path: pathlib.Path) -> None:
1281 """JSON name field matches the original member name."""
1282 repo = _make_repo(tmp_path)
1283 _cli(["workspace", "add", "myrepo", "https://example.com/myrepo"], repo)
1284 stdout, _, rc = _cli(["workspace", "update", "myrepo", "--branch", "dev", "-j"], repo)
1285 assert rc == 0
1286 d = _parse_update(stdout)
1287 assert d["name"] == "myrepo"
1288
1289 def test_update_omitted_fields_preserved(self, tmp_path: pathlib.Path) -> None:
1290 """Fields not supplied in --update are preserved from original."""
1291 repo = _make_repo(tmp_path)
1292 _cli(["workspace", "add", "core", "https://example.com/core", "--branch", "v1"], repo)
1293 stdout, _, rc = _cli(["workspace", "update", "core", "--path", "vendor/core", "-j"], repo)
1294 assert rc == 0
1295 d = _parse_update(stdout)
1296 assert d["branch"] == "v1" # unchanged
1297 assert d["url"] == "https://example.com/core" # unchanged
1298
1299 def test_update_no_flags_exits_1(self, tmp_path: pathlib.Path) -> None:
1300 """Supplying no --url/--path/--branch flags exits with code 1."""
1301 repo = _make_repo(tmp_path)
1302 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1303 _, stderr, rc = _cli(["workspace", "update", "core"], repo)
1304 assert rc == 1
1305 assert "url" in stderr.lower() or "path" in stderr.lower() or "branch" in stderr.lower()
1306
1307 def test_update_nonexistent_exits_1(self, tmp_path: pathlib.Path) -> None:
1308 """Updating a nonexistent member exits with code 1."""
1309 repo = _make_repo(tmp_path)
1310 _, stderr, rc = _cli(["workspace", "update", "ghost", "--branch", "dev"], repo)
1311 assert rc == 1
1312 assert "ghost" in stderr
1313
1314 def test_update_outside_repo_exits_1_member_not_found(self, tmp_path: pathlib.Path) -> None:
1315 """Workspace update from a non-repo dir exits 1 (member not found), not 2."""
1316 empty = tmp_path / "empty"
1317 empty.mkdir()
1318 _, _, rc = _cli(["workspace", "update", "core", "--branch", "dev"], empty)
1319 assert rc == 1
1320
1321 def test_update_default_is_text(self, tmp_path: pathlib.Path) -> None:
1322 """Without --json the output is human-readable text."""
1323 repo = _make_repo(tmp_path)
1324 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1325 stdout, _, rc = _cli(["workspace", "update", "core", "--branch", "dev"], repo)
1326 assert rc == 0
1327 assert not stdout.strip().startswith("{")
1328 assert "Updated" in stdout
1329
1330 def test_update_text_contains_name(self, tmp_path: pathlib.Path) -> None:
1331 """Text output mentions the member name."""
1332 repo = _make_repo(tmp_path)
1333 _cli(["workspace", "add", "myrepo", "https://example.com/myrepo"], repo)
1334 stdout, _, rc = _cli(["workspace", "update", "myrepo", "--branch", "dev"], repo)
1335 assert rc == 0
1336 assert "myrepo" in stdout
1337
1338 def test_update_help_has_description(self, tmp_path: pathlib.Path) -> None:
1339 """--help includes the rich description."""
1340 repo = _make_repo(tmp_path)
1341 stdout, _, rc = _cli(["workspace", "update", "--help"], repo)
1342 assert rc == 0
1343 assert "Agent quickstart" in stdout or "JSON output schema" in stdout
1344
1345 def test_update_multiple_flags_at_once(self, tmp_path: pathlib.Path) -> None:
1346 """All three fields can be updated in one command."""
1347 repo = _make_repo(tmp_path)
1348 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1349 stdout, _, rc = _cli([
1350 "workspace", "update", "core",
1351 "--url", "https://example.com/core-v2",
1352 "--path", "vendor/core",
1353 "--branch", "release",
1354 "-j",
1355 ], repo)
1356 assert rc == 0
1357 d = _parse_update(stdout)
1358 assert d["url"] == "https://example.com/core-v2"
1359 assert "vendor/core" in d["path"]
1360 assert d["branch"] == "release"
1361
1362 def test_update_reflected_in_list(self, tmp_path: pathlib.Path) -> None:
1363 """Updated branch appears in subsequent 'workspace list --json'."""
1364 repo = _make_repo(tmp_path)
1365 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1366 _cli(["workspace", "update", "core", "--branch", "release"], repo)
1367 stdout, _, rc = _cli(["workspace", "list", "--json"], repo)
1368 assert rc == 0
1369 members = _parse_list(stdout)
1370 core = next(m for m in members if m["name"] == "core")
1371 assert core["branch"] == "release"
1372
1373 def test_update_shorthand_branch_flag(self, tmp_path: pathlib.Path) -> None:
1374 """-b shorthand sets the branch correctly."""
1375 repo = _make_repo(tmp_path)
1376 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1377 stdout, _, rc = _cli(["workspace", "update", "core", "-b", "hotfix", "-j"], repo)
1378 assert rc == 0
1379 assert _parse_update(stdout)["branch"] == "hotfix"
1380
1381 def test_update_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
1382 """JSON output is well-formed."""
1383 repo = _make_repo(tmp_path)
1384 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1385 stdout, _, rc = _cli(["workspace", "update", "core", "--branch", "dev", "-j"], repo)
1386 assert rc == 0
1387 raw = json.loads(_json_blob(stdout))
1388 assert isinstance(raw, dict)
1389 for field in ("name", "url", "path", "branch"):
1390 assert isinstance(raw[field], str)
1391
1392
1393 class TestWorkspaceUpdateSecurity:
1394 """Input validation, ANSI sanitization, error routing."""
1395
1396 def test_update_invalid_url_scheme_rejected(self, tmp_path: pathlib.Path) -> None:
1397 """file:// URL scheme in --url is rejected."""
1398 repo = _make_repo(tmp_path)
1399 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1400 _, stderr, rc = _cli(["workspace", "update", "core", "--url", "file:///etc/passwd"], repo)
1401 assert rc == 1
1402 assert "scheme" in stderr.lower() or "not allowed" in stderr.lower()
1403
1404 def test_update_path_traversal_rejected(self, tmp_path: pathlib.Path) -> None:
1405 """--path escaping workspace root is rejected."""
1406 repo = _make_repo(tmp_path)
1407 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1408 _, stderr, rc = _cli(["workspace", "update", "core", "--path", "../../etc"], repo)
1409 assert rc == 1
1410 assert "outside" in stderr.lower() or "escape" in stderr.lower() or "resolves" in stderr.lower()
1411
1412 def test_update_null_byte_in_path_rejected(self, tmp_path: pathlib.Path) -> None:
1413 """Null byte in --path is rejected by the core validator."""
1414 repo = _make_repo(tmp_path)
1415 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1416 with pytest.raises(ValueError, match="null"):
1417 update_workspace_member(repo, "core", path="vendor/\x00core")
1418
1419 def test_update_error_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
1420 """Error output (member not found) goes to stderr, not stdout."""
1421 repo = _make_repo(tmp_path)
1422 stdout, stderr, rc = _cli(["workspace", "update", "ghost", "--branch", "dev"], repo)
1423 assert rc == 1
1424 assert not stdout.strip().startswith("{")
1425 assert stderr.strip() != ""
1426
1427 def test_update_json_no_ansi_in_output(self, tmp_path: pathlib.Path) -> None:
1428 """JSON output contains no ANSI escape sequences."""
1429 repo = _make_repo(tmp_path)
1430 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1431 stdout, _, rc = _cli(["workspace", "update", "core", "--branch", "dev", "-j"], repo)
1432 assert rc == 0
1433 assert "\x1b" not in stdout
1434
1435 def test_update_ftp_url_rejected(self, tmp_path: pathlib.Path) -> None:
1436 """ftp:// URL scheme is rejected."""
1437 repo = _make_repo(tmp_path)
1438 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1439 _, _, rc = _cli(["workspace", "update", "core", "--url", "ftp://example.com/repo"], repo)
1440 assert rc == 1
1441
1442 def test_update_no_flags_error_to_stderr(self, tmp_path: pathlib.Path) -> None:
1443 """No-flags error is on stderr; stdout has no JSON."""
1444 repo = _make_repo(tmp_path)
1445 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1446 stdout, stderr, rc = _cli(["workspace", "update", "core"], repo)
1447 assert rc == 1
1448 assert not stdout.strip().startswith("{")
1449 assert stderr.strip() != ""
1450
1451
1452 class TestWorkspaceUpdateStress:
1453 """Performance and scale tests for workspace update."""
1454
1455 def test_update_10_members_sequential(self, tmp_path: pathlib.Path) -> None:
1456 """10 members can each be updated sequentially."""
1457 repo = _make_repo(tmp_path)
1458 for i in range(10):
1459 _cli(["workspace", "add", f"svc{i}", f"https://example.com/svc{i}"], repo)
1460 failures = []
1461 for i in range(10):
1462 _, _, rc = _cli(["workspace", "update", f"svc{i}", "--branch", f"v{i}", "-j"], repo)
1463 if rc != 0:
1464 failures.append(f"svc{i}")
1465 assert not failures
1466 stdout, _, _ = _cli(["workspace", "list", "--json"], repo)
1467 members = {m["name"]: m for m in _parse_list(stdout)}
1468 for i in range(10):
1469 assert members[f"svc{i}"]["branch"] == f"v{i}"
1470
1471 def test_update_performance(self, tmp_path: pathlib.Path) -> None:
1472 """10 sequential updates complete within 5 seconds."""
1473 repo = _make_repo(tmp_path)
1474 for i in range(10):
1475 _cli(["workspace", "add", f"svc{i}", f"https://example.com/svc{i}"], repo)
1476 t0 = time.monotonic()
1477 for i in range(10):
1478 _cli(["workspace", "update", f"svc{i}", "--branch", "release"], repo)
1479 elapsed = time.monotonic() - t0
1480 assert elapsed < 15.0, f"10 updates took {elapsed:.2f}s"
1481
1482 def test_update_repeated_same_member(self, tmp_path: pathlib.Path) -> None:
1483 """A member can be updated 10 times in a row without error."""
1484 repo = _make_repo(tmp_path)
1485 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1486 for i in range(10):
1487 _, _, rc = _cli(["workspace", "update", "core", "--branch", f"v{i}", "-j"], repo)
1488 assert rc == 0
1489 stdout, _, _ = _cli(["workspace", "list", "--json"], repo)
1490 members = _parse_list(stdout)
1491 core = next(m for m in members if m["name"] == "core")
1492 assert core["branch"] == "v9"
1493
1494
1495 # ===========================================================================
1496 # muse workspace list — Extended / Security / Stress
1497 # ===========================================================================
1498
1499
1500 class TestWorkspaceListExtended:
1501 """-j alias, JSON schema, text output, ordering, edge cases."""
1502
1503 def test_list_j_alias(self, tmp_path: pathlib.Path) -> None:
1504 """-j produces the same JSON as --json (ignoring duration_ms)."""
1505 repo = _make_repo(tmp_path)
1506 add_workspace_member(repo, "core", "https://example.com/core")
1507 s1, _, rc1 = _cli(["workspace", "list", "--json"], repo)
1508 s2, _, rc2 = _cli(["workspace", "list", "-j"], repo)
1509 assert rc1 == 0 and rc2 == 0
1510 d1 = json.loads(_json_blob(s1)); d1.pop("duration_ms", None); d1.pop("timestamp", None)
1511 d2 = json.loads(_json_blob(s2)); d2.pop("duration_ms", None); d2.pop("timestamp", None)
1512 assert d1 == d2
1513
1514 def test_list_empty_exits_0(self, tmp_path: pathlib.Path) -> None:
1515 """List with no members exits 0 and returns empty members array."""
1516 repo = _make_repo(tmp_path)
1517 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1518 assert rc == 0
1519 assert json.loads(_json_blob(stdout))["members"] == []
1520
1521 def test_list_json_is_array(self, tmp_path: pathlib.Path) -> None:
1522 """JSON output is an envelope with a members array."""
1523 repo = _make_repo(tmp_path)
1524 add_workspace_member(repo, "core", "https://example.com/core")
1525 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1526 assert rc == 0
1527 raw = json.loads(_json_blob(stdout))
1528 assert isinstance(raw["members"], list)
1529
1530 def test_list_json_all_fields_present(self, tmp_path: pathlib.Path) -> None:
1531 """Every member entry has the required fields."""
1532 repo = _make_repo(tmp_path)
1533 add_workspace_member(repo, "core", "https://example.com/core")
1534 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1535 assert rc == 0
1536 raw = json.loads(_json_blob(stdout))
1537 assert len(raw["members"]) == 1
1538 entry = raw["members"][0]
1539 for field in ("name", "url", "path", "branch", "present", "head_commit", "dirty"):
1540 assert field in entry, f"field '{field}' missing"
1541
1542 def test_list_json_name_matches(self, tmp_path: pathlib.Path) -> None:
1543 """name field in JSON matches the registered member name."""
1544 repo = _make_repo(tmp_path)
1545 add_workspace_member(repo, "myrepo", "https://example.com/myrepo")
1546 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1547 assert any(m["name"] == "myrepo" for m in members)
1548
1549 def test_list_json_url_matches(self, tmp_path: pathlib.Path) -> None:
1550 """url field in JSON matches the registered URL."""
1551 repo = _make_repo(tmp_path)
1552 url = "https://example.com/myrepo"
1553 add_workspace_member(repo, "myrepo", url)
1554 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1555 assert members[0]["url"] == url
1556
1557 def test_list_json_branch_default_main(self, tmp_path: pathlib.Path) -> None:
1558 """branch field defaults to 'main'."""
1559 repo = _make_repo(tmp_path)
1560 add_workspace_member(repo, "core", "https://example.com/core")
1561 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1562 assert members[0]["branch"] == "main"
1563
1564 def test_list_json_present_false_when_not_cloned(self, tmp_path: pathlib.Path) -> None:
1565 """present=false when the checkout directory does not exist."""
1566 repo = _make_repo(tmp_path)
1567 add_workspace_member(repo, "core", "https://example.com/core")
1568 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1569 assert members[0]["present"] is False
1570
1571 def test_list_json_head_commit_null_when_not_cloned(self, tmp_path: pathlib.Path) -> None:
1572 """head_commit is null when the member is not yet cloned."""
1573 repo = _make_repo(tmp_path)
1574 add_workspace_member(repo, "core", "https://example.com/core")
1575 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1576 assert members[0]["head_commit"] is None
1577
1578 def test_list_json_count_matches_registered(self, tmp_path: pathlib.Path) -> None:
1579 """Array length equals number of registered members."""
1580 repo = _make_repo(tmp_path)
1581 for i in range(5):
1582 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
1583 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1584 assert len(members) == 5
1585
1586 def test_list_json_reflects_update(self, tmp_path: pathlib.Path) -> None:
1587 """Updated branch appears in list JSON after update."""
1588 repo = _make_repo(tmp_path)
1589 add_workspace_member(repo, "core", "https://example.com/core")
1590 update_workspace_member(repo, "core", branch="release")
1591 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1592 assert members[0]["branch"] == "release"
1593
1594 def test_list_json_member_removed_not_shown(self, tmp_path: pathlib.Path) -> None:
1595 """Removed member no longer appears in list."""
1596 repo = _make_repo(tmp_path)
1597 add_workspace_member(repo, "core", "https://example.com/core")
1598 add_workspace_member(repo, "data", "https://example.com/data")
1599 remove_workspace_member(repo, "core")
1600 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1601 assert all(m["name"] != "core" for m in members)
1602 assert any(m["name"] == "data" for m in members)
1603
1604 def test_list_default_is_text(self, tmp_path: pathlib.Path) -> None:
1605 """Without --json output is human-readable text."""
1606 repo = _make_repo(tmp_path)
1607 add_workspace_member(repo, "core", "https://example.com/core")
1608 stdout, _, rc = _cli(["workspace", "list"], repo)
1609 assert rc == 0
1610 assert not stdout.strip().startswith("[")
1611
1612 def test_list_text_empty_message(self, tmp_path: pathlib.Path) -> None:
1613 """Text output says 'No workspace members' when list is empty."""
1614 repo = _make_repo(tmp_path)
1615 stdout, _, rc = _cli(["workspace", "list"], repo)
1616 assert rc == 0
1617 assert "No workspace members" in stdout
1618
1619 def test_list_text_shows_member_name(self, tmp_path: pathlib.Path) -> None:
1620 """Text output includes the member name."""
1621 repo = _make_repo(tmp_path)
1622 add_workspace_member(repo, "myrepo", "https://example.com/myrepo")
1623 stdout, _, rc = _cli(["workspace", "list"], repo)
1624 assert rc == 0
1625 assert "myrepo" in stdout
1626
1627 def test_list_outside_repo_succeeds_empty(self, tmp_path: pathlib.Path) -> None:
1628 """Workspace list from a non-repo dir returns empty members — no muse repo required."""
1629 empty = tmp_path / "empty"
1630 empty.mkdir()
1631 stdout, _, rc = _cli(["workspace", "list", "--json"], empty)
1632 assert rc == 0
1633 assert json.loads(stdout)["members"] == []
1634
1635 def test_list_help_has_description(self, tmp_path: pathlib.Path) -> None:
1636 """--help includes the rich description."""
1637 repo = _make_repo(tmp_path)
1638 stdout, _, rc = _cli(["workspace", "list", "--help"], repo)
1639 assert rc == 0
1640 assert "Agent quickstart" in stdout or "JSON output schema" in stdout
1641
1642 def test_list_json_valid_types(self, tmp_path: pathlib.Path) -> None:
1643 """All JSON field types are correct."""
1644 repo = _make_repo(tmp_path)
1645 add_workspace_member(repo, "core", "https://example.com/core")
1646 raw = json.loads(_json_blob(_cli(["workspace", "list", "-j"], repo)[0]))
1647 entry = raw["members"][0]
1648 assert isinstance(entry["name"], str)
1649 assert isinstance(entry["url"], str)
1650 assert isinstance(entry["path"], str)
1651 assert isinstance(entry["branch"], str)
1652 assert isinstance(entry["present"], bool)
1653 assert entry["head_commit"] is None or isinstance(entry["head_commit"], str)
1654 assert isinstance(entry["dirty"], bool)
1655
1656
1657 class TestWorkspaceListSecurity:
1658 """ANSI sanitization and output integrity."""
1659
1660 def test_list_json_ansi_in_name_sanitized(self, tmp_path: pathlib.Path) -> None:
1661 """ANSI codes in stored member name are stripped from JSON output."""
1662 repo = _make_repo(tmp_path)
1663 # Inject ANSI directly into manifest via core (bypassing CLI validation)
1664 manifest_path = repo / ".muse" / "workspace.toml"
1665 manifest_path.parent.mkdir(parents=True, exist_ok=True)
1666 manifest_path.write_text(
1667 '[workspace]\n[[workspace.members]]\n'
1668 'name = "core\\u001b[31mred\\u001b[0m"\n'
1669 'url = "https://example.com/core"\n'
1670 'path = "repos/core"\n'
1671 'branch = "main"\n'
1672 )
1673 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1674 assert rc == 0
1675 assert "\x1b" not in stdout
1676
1677 def test_list_json_ansi_in_url_sanitized(self, tmp_path: pathlib.Path) -> None:
1678 """ANSI codes in stored URL are stripped from JSON output."""
1679 repo = _make_repo(tmp_path)
1680 manifest_path = repo / ".muse" / "workspace.toml"
1681 manifest_path.parent.mkdir(parents=True, exist_ok=True)
1682 manifest_path.write_text(
1683 '[workspace]\n[[workspace.members]]\n'
1684 'name = "core"\n'
1685 'url = "https://example.com/core\\u001b[31m"\n'
1686 'path = "repos/core"\n'
1687 'branch = "main"\n'
1688 )
1689 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1690 assert rc == 0
1691 assert "\x1b" not in stdout
1692
1693 def test_list_text_no_ansi_in_output(self, tmp_path: pathlib.Path) -> None:
1694 """Text output contains no ANSI escape sequences."""
1695 repo = _make_repo(tmp_path)
1696 add_workspace_member(repo, "core", "https://example.com/core")
1697 stdout, _, rc = _cli(["workspace", "list"], repo)
1698 assert rc == 0
1699 assert "\x1b" not in stdout
1700
1701 def test_list_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
1702 """JSON output is well-formed even with multiple members."""
1703 repo = _make_repo(tmp_path)
1704 for i in range(3):
1705 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
1706 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1707 assert rc == 0
1708 raw = json.loads(_json_blob(stdout))
1709 assert isinstance(raw["members"], list)
1710 assert len(raw["members"]) == 3
1711
1712 def test_list_json_dirty_is_bool(self, tmp_path: pathlib.Path) -> None:
1713 """dirty field is always a boolean, never a string or int."""
1714 repo = _make_repo(tmp_path)
1715 add_workspace_member(repo, "core", "https://example.com/core")
1716 raw = json.loads(_json_blob(_cli(["workspace", "list", "-j"], repo)[0]))
1717 assert isinstance(raw["members"][0]["dirty"], bool)
1718
1719
1720 class TestWorkspaceListStress:
1721 """Performance and scale tests for workspace list."""
1722
1723 def test_list_50_members(self, tmp_path: pathlib.Path) -> None:
1724 """List returns all 50 members when 50 are registered."""
1725 repo = _make_repo(tmp_path)
1726 for i in range(50):
1727 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
1728 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1729 assert len(members) == 50
1730
1731 def test_list_performance_50_members(self, tmp_path: pathlib.Path) -> None:
1732 """Listing 50 members completes within 5 seconds."""
1733 repo = _make_repo(tmp_path)
1734 for i in range(50):
1735 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
1736 t0 = time.monotonic()
1737 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1738 elapsed = time.monotonic() - t0
1739 assert rc == 0
1740 assert elapsed < 5.0, f"list of 50 took {elapsed:.2f}s"
1741
1742 def test_list_concurrent_reads_consistent(self, tmp_path: pathlib.Path) -> None:
1743 """Concurrent list reads all return the same member count."""
1744 repo = _make_repo(tmp_path)
1745 for i in range(20):
1746 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
1747 counts: list[int] = []
1748 errors: list[str] = []
1749 lock = threading.Lock()
1750
1751 def _run() -> None:
1752 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1753 with lock:
1754 if rc != 0:
1755 errors.append(stdout)
1756 else:
1757 counts.append(len(json.loads(_json_blob(stdout))["members"]))
1758
1759 threads = [threading.Thread(target=_run) for _ in range(8)]
1760 for t in threads:
1761 t.start()
1762 for t in threads:
1763 t.join()
1764 assert not errors, f"Concurrent list errors: {errors}"
1765 assert all(c == 20 for c in counts), f"Inconsistent counts: {counts}"
1766
1767
1768 # ---------------------------------------------------------------------------
1769 # workspace remove — Extended, Security, Stress
1770 # ---------------------------------------------------------------------------
1771
1772
1773 class TestWorkspaceRemoveExtended:
1774 """Extended unit / integration / e2e tests for muse workspace remove."""
1775
1776 def test_remove_exits_0_on_success(self, tmp_path: pathlib.Path) -> None:
1777 """Successful remove exits with code 0."""
1778 repo = _make_repo(tmp_path)
1779 add_workspace_member(repo, "core", "https://example.com/core")
1780 _, _, rc = _cli(["workspace", "remove", "core"], repo)
1781 assert rc == 0
1782
1783 def test_remove_j_alias_works(self, tmp_path: pathlib.Path) -> None:
1784 """-j is an accepted alias for --json."""
1785 repo = _make_repo(tmp_path)
1786 add_workspace_member(repo, "core", "https://example.com/core")
1787 stdout, _, rc = _cli(["workspace", "remove", "core", "-j"], repo)
1788 assert rc == 0
1789 d = _parse_remove(stdout)
1790 assert d["removed"] is True
1791
1792 def test_remove_json_name_matches(self, tmp_path: pathlib.Path) -> None:
1793 """JSON output name matches the removed member's name."""
1794 repo = _make_repo(tmp_path)
1795 add_workspace_member(repo, "sounds", "https://example.com/sounds")
1796 stdout, _, rc = _cli(["workspace", "remove", "sounds", "--json"], repo)
1797 assert rc == 0
1798 d = _parse_remove(stdout)
1799 assert d["name"] == "sounds"
1800
1801 def test_remove_json_removed_true(self, tmp_path: pathlib.Path) -> None:
1802 """JSON output always has removed=true on success."""
1803 repo = _make_repo(tmp_path)
1804 add_workspace_member(repo, "core", "https://example.com/core")
1805 stdout, _, rc = _cli(["workspace", "remove", "core", "--json"], repo)
1806 assert rc == 0
1807 assert json.loads(_json_blob(stdout))["removed"] is True
1808
1809 def test_remove_member_no_longer_in_list(self, tmp_path: pathlib.Path) -> None:
1810 """After remove, the member is absent from workspace list."""
1811 repo = _make_repo(tmp_path)
1812 add_workspace_member(repo, "core", "https://example.com/core")
1813 add_workspace_member(repo, "data", "https://example.com/data")
1814 _cli(["workspace", "remove", "core"], repo)
1815 members = _parse_list(_cli(["workspace", "list", "--json"], repo)[0])
1816 names = [m["name"] for m in members]
1817 assert "core" not in names
1818 assert "data" in names
1819
1820 def test_remove_only_named_member_removed(self, tmp_path: pathlib.Path) -> None:
1821 """Remove deletes exactly one member; others are untouched."""
1822 repo = _make_repo(tmp_path)
1823 for n in ("alpha", "beta", "gamma"):
1824 add_workspace_member(repo, n, f"https://example.com/{n}")
1825 _cli(["workspace", "remove", "beta"], repo)
1826 members = _parse_list(_cli(["workspace", "list", "--json"], repo)[0])
1827 names = [m["name"] for m in members]
1828 assert names == ["alpha", "gamma"]
1829
1830 def test_remove_idempotent_error_on_second_call(self, tmp_path: pathlib.Path) -> None:
1831 """Removing the same member twice returns an error on the second call."""
1832 repo = _make_repo(tmp_path)
1833 add_workspace_member(repo, "core", "https://example.com/core")
1834 _, _, rc1 = _cli(["workspace", "remove", "core"], repo)
1835 _, _, rc2 = _cli(["workspace", "remove", "core"], repo)
1836 assert rc1 == 0
1837 assert rc2 != 0
1838
1839 def test_remove_nonexistent_exits_1(self, tmp_path: pathlib.Path) -> None:
1840 """Removing a non-existent member exits with code 1."""
1841 repo = _make_repo(tmp_path)
1842 add_workspace_member(repo, "core", "https://example.com/core")
1843 _, _, rc = _cli(["workspace", "remove", "ghost"], repo)
1844 assert rc == 1
1845
1846 def test_remove_outside_repo_exits_1_member_not_found(self, tmp_path: pathlib.Path) -> None:
1847 """Workspace remove from a non-repo dir exits 1 (member not found), not 2."""
1848 empty = tmp_path / "not_a_repo"
1849 empty.mkdir()
1850 _, _, rc = _cli(["workspace", "remove", "core"], empty)
1851 assert rc == 1
1852
1853 def test_remove_text_output_contains_name(self, tmp_path: pathlib.Path) -> None:
1854 """Text output mentions the removed member's name."""
1855 repo = _make_repo(tmp_path)
1856 add_workspace_member(repo, "sounds", "https://example.com/sounds")
1857 stdout, _, rc = _cli(["workspace", "remove", "sounds"], repo)
1858 assert rc == 0
1859 assert "sounds" in stdout
1860
1861 def test_remove_text_success_marker(self, tmp_path: pathlib.Path) -> None:
1862 """Text output contains a success indicator."""
1863 repo = _make_repo(tmp_path)
1864 add_workspace_member(repo, "core", "https://example.com/core")
1865 stdout, _, _ = _cli(["workspace", "remove", "core"], repo)
1866 assert "Removed" in stdout or "✅" in stdout
1867
1868 def test_remove_error_to_stderr_not_stdout(self, tmp_path: pathlib.Path) -> None:
1869 """Error messages go to stderr; stdout is empty on failure."""
1870 repo = _make_repo(tmp_path)
1871 add_workspace_member(repo, "core", "https://example.com/core")
1872 stdout, stderr, rc = _cli(["workspace", "remove", "ghost"], repo)
1873 assert rc != 0
1874 assert "not found" in stderr
1875 assert "not found" not in stdout
1876
1877 def test_remove_text_no_json_on_success(self, tmp_path: pathlib.Path) -> None:
1878 """Without --json, stdout does not contain a JSON object."""
1879 repo = _make_repo(tmp_path)
1880 add_workspace_member(repo, "core", "https://example.com/core")
1881 stdout, _, rc = _cli(["workspace", "remove", "core"], repo)
1882 assert rc == 0
1883 assert not stdout.strip().startswith("{")
1884
1885 def test_remove_count_decreases(self, tmp_path: pathlib.Path) -> None:
1886 """Member count decreases by exactly one after remove."""
1887 repo = _make_repo(tmp_path)
1888 for n in ("a", "b", "c"):
1889 add_workspace_member(repo, n, f"https://example.com/{n}")
1890 before = len(_parse_list(_cli(["workspace", "list", "--json"], repo)[0]))
1891 _cli(["workspace", "remove", "b"], repo)
1892 after = len(_parse_list(_cli(["workspace", "list", "--json"], repo)[0]))
1893 assert after == before - 1
1894
1895 def test_remove_last_member_leaves_empty_manifest(self, tmp_path: pathlib.Path) -> None:
1896 """Removing the only member results in an empty list."""
1897 repo = _make_repo(tmp_path)
1898 add_workspace_member(repo, "only", "https://example.com/only")
1899 _cli(["workspace", "remove", "only"], repo)
1900 members = _parse_list(_cli(["workspace", "list", "--json"], repo)[0])
1901 assert members == []
1902
1903 def test_remove_help_description_present(self, tmp_path: pathlib.Path) -> None:
1904 """--help output contains the agent-friendly description."""
1905 repo = _make_repo(tmp_path)
1906 stdout, _, _ = _cli(["workspace", "remove", "--help"], repo)
1907 assert "Unregister" in stdout or "manifest" in stdout
1908
1909 def test_remove_json_schema_keys(self, tmp_path: pathlib.Path) -> None:
1910 """JSON output has exactly the keys: name, removed, exit_code, duration_ms."""
1911 repo = _make_repo(tmp_path)
1912 add_workspace_member(repo, "core", "https://example.com/core")
1913 stdout, _, rc = _cli(["workspace", "remove", "core", "--json"], repo)
1914 assert rc == 0
1915 d = json.loads(_json_blob(stdout))
1916 assert {"name", "removed", "exit_code", "duration_ms"}.issubset(d.keys())
1917
1918 def test_remove_no_manifest_exits_1(self, tmp_path: pathlib.Path) -> None:
1919 """Remove on a repo with no workspace manifest exits 1."""
1920 repo = _make_repo(tmp_path)
1921 # No workspace.toml — remove_workspace_member raises ValueError
1922 _, stderr, rc = _cli(["workspace", "remove", "core"], repo)
1923 assert rc == 1
1924 assert stderr.strip() != ""
1925
1926
1927 class TestWorkspaceRemoveSecurity:
1928 """Security hardening tests for muse workspace remove."""
1929
1930 def test_remove_ansi_in_name_arg_sanitized_in_json(self, tmp_path: pathlib.Path) -> None:
1931 """ANSI codes in a stored name are stripped from JSON output."""
1932 repo = _make_repo(tmp_path)
1933 # Write manifest directly with an ANSI-injected name so it bypasses validator
1934 manifest_path = repo / ".muse" / "workspace.toml"
1935 manifest_path.parent.mkdir(parents=True, exist_ok=True)
1936 manifest_path.write_text(
1937 '[workspace]\n[[workspace.members]]\n'
1938 'name = "evil\\u001b[31m"\n'
1939 'url = "https://example.com/evil"\n'
1940 'path = "repos/evil"\n'
1941 'branch = "main"\n'
1942 )
1943 # Use the raw stored name as the CLI arg to remove it
1944 stdout, _, rc = _cli(["workspace", "remove", "evil\x1b[31m", "--json"], repo)
1945 # Either succeeds (found) or fails (not found) — either way, no ANSI in stdout
1946 assert "\x1b" not in stdout
1947
1948 def test_remove_text_output_no_ansi(self, tmp_path: pathlib.Path) -> None:
1949 """Text output contains no ANSI escape sequences."""
1950 repo = _make_repo(tmp_path)
1951 add_workspace_member(repo, "core", "https://example.com/core")
1952 stdout, _, _ = _cli(["workspace", "remove", "core"], repo)
1953 assert "\x1b" not in stdout
1954
1955 def test_remove_json_valid_on_success(self, tmp_path: pathlib.Path) -> None:
1956 """JSON output is well-formed on success."""
1957 repo = _make_repo(tmp_path)
1958 add_workspace_member(repo, "core", "https://example.com/core")
1959 stdout, _, rc = _cli(["workspace", "remove", "core", "--json"], repo)
1960 assert rc == 0
1961 d = json.loads(_json_blob(stdout))
1962 assert isinstance(d["name"], str)
1963 assert d["removed"] is True
1964
1965 def test_remove_removed_field_is_bool(self, tmp_path: pathlib.Path) -> None:
1966 """removed field is a boolean, never a string or int."""
1967 repo = _make_repo(tmp_path)
1968 add_workspace_member(repo, "core", "https://example.com/core")
1969 stdout, _, rc = _cli(["workspace", "remove", "core", "--json"], repo)
1970 assert rc == 0
1971 d = json.loads(_json_blob(stdout))
1972 assert isinstance(d["removed"], bool)
1973
1974 def test_remove_symlink_manifest_fails_gracefully(self, tmp_path: pathlib.Path) -> None:
1975 """A symlinked manifest is refused — exits non-zero without crashing."""
1976 repo = _make_repo(tmp_path)
1977 add_workspace_member(repo, "core", "https://example.com/core")
1978 manifest_path = repo / ".muse" / "workspace.toml"
1979 real = tmp_path / "real_workspace.toml"
1980 real.write_text(manifest_path.read_text())
1981 manifest_path.unlink()
1982 manifest_path.symlink_to(real)
1983 _, _, rc = _cli(["workspace", "remove", "core"], repo)
1984 # The symlink guard in _load_manifest returns None → ValueError → rc 1
1985 assert rc != 0
1986
1987 def test_remove_null_byte_in_name_raises(self, tmp_path: pathlib.Path) -> None:
1988 """Null byte in name is rejected by the core validator."""
1989 repo = _make_repo(tmp_path)
1990 add_workspace_member(repo, "core", "https://example.com/core")
1991 # remove_workspace_member does a name-equality match; null byte won't match
1992 # any valid stored name — raises ValueError (not found)
1993 with pytest.raises(ValueError):
1994 remove_workspace_member(repo, "core\x00evil")
1995
1996
1997 class TestWorkspaceRemoveStress:
1998 """Performance and scale tests for muse workspace remove."""
1999
2000 def test_remove_from_50_member_manifest(self, tmp_path: pathlib.Path) -> None:
2001 """Remove works correctly when the manifest has 50 members."""
2002 repo = _make_repo(tmp_path)
2003 for i in range(50):
2004 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
2005 _, _, rc = _cli(["workspace", "remove", "svc025", "--json"], repo)
2006 assert rc == 0
2007 members = _parse_list(_cli(["workspace", "list", "--json"], repo)[0])
2008 assert len(members) == 49
2009 assert all(m["name"] != "svc025" for m in members)
2010
2011 def test_remove_performance_50_members(self, tmp_path: pathlib.Path) -> None:
2012 """Removing from a 50-member manifest completes within 5 seconds."""
2013 repo = _make_repo(tmp_path)
2014 for i in range(50):
2015 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
2016 t0 = time.monotonic()
2017 _, _, rc = _cli(["workspace", "remove", "svc000", "--json"], repo)
2018 elapsed = time.monotonic() - t0
2019 assert rc == 0
2020 assert elapsed < 5.0, f"remove from 50 took {elapsed:.2f}s"
2021
2022 def test_remove_sequential_removes_all(self, tmp_path: pathlib.Path) -> None:
2023 """Removing all 20 members one-by-one leaves an empty list."""
2024 repo = _make_repo(tmp_path)
2025 names = [f"svc{i:02d}" for i in range(20)]
2026 for n in names:
2027 add_workspace_member(repo, n, f"https://example.com/{n}")
2028 for n in names:
2029 _, _, rc = _cli(["workspace", "remove", n], repo)
2030 assert rc == 0
2031 members = _parse_list(_cli(["workspace", "list", "--json"], repo)[0])
2032 assert members == []
2033
2034
2035 # ---------------------------------------------------------------------------
2036 # workspace status — Extended, Security, Stress
2037 # ---------------------------------------------------------------------------
2038
2039
2040 class TestWorkspaceStatusExtended:
2041 """Extended unit / integration / e2e tests for muse workspace status."""
2042
2043 def test_status_exits_0_all_members(self, tmp_path: pathlib.Path) -> None:
2044 repo = _make_repo(tmp_path)
2045 add_workspace_member(repo, "core", "https://example.com/core")
2046 _, _, rc = _cli(["workspace", "status"], repo)
2047 assert rc == 0
2048
2049 def test_status_j_alias_works(self, tmp_path: pathlib.Path) -> None:
2050 repo = _make_repo(tmp_path)
2051 add_workspace_member(repo, "core", "https://example.com/core")
2052 stdout, _, rc = _cli(["workspace", "status", "-j"], repo)
2053 assert rc == 0
2054 members = _parse_list(stdout)
2055 assert len(members) == 1
2056
2057 def test_status_named_exits_0(self, tmp_path: pathlib.Path) -> None:
2058 repo = _make_repo(tmp_path)
2059 add_workspace_member(repo, "core", "https://example.com/core")
2060 _, _, rc = _cli(["workspace", "status", "core"], repo)
2061 assert rc == 0
2062
2063 def test_status_named_json_single_element(self, tmp_path: pathlib.Path) -> None:
2064 repo = _make_repo(tmp_path)
2065 add_workspace_member(repo, "core", "https://example.com/core")
2066 add_workspace_member(repo, "data", "https://example.com/data")
2067 stdout, _, rc = _cli(["workspace", "status", "core", "--json"], repo)
2068 assert rc == 0
2069 members = _parse_list(stdout)
2070 assert len(members) == 1
2071 assert members[0]["name"] == "core"
2072
2073 def test_status_all_json_all_members_returned(self, tmp_path: pathlib.Path) -> None:
2074 repo = _make_repo(tmp_path)
2075 for n in ("alpha", "beta", "gamma"):
2076 add_workspace_member(repo, n, f"https://example.com/{n}")
2077 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2078 assert rc == 0
2079 members = _parse_list(stdout)
2080 assert {m["name"] for m in members} == {"alpha", "beta", "gamma"}
2081
2082 def test_status_json_ten_fields(self, tmp_path: pathlib.Path) -> None:
2083 repo = _make_repo(tmp_path)
2084 add_workspace_member(repo, "core", "https://example.com/core")
2085 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2086 assert rc == 0
2087 d = json.loads(_json_blob(stdout))["members"][0]
2088 assert set(d.keys()) == {
2089 "name", "url", "path", "branch", "present", "head_commit", "dirty",
2090 "actual_branch", "shelf_count", "feature_branches", "branch_mismatch",
2091 }
2092
2093 def test_status_json_present_false_when_not_cloned(self, tmp_path: pathlib.Path) -> None:
2094 repo = _make_repo(tmp_path)
2095 add_workspace_member(repo, "core", "https://example.com/core")
2096 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2097 assert rc == 0
2098 assert _parse_list(stdout)[0]["present"] is False
2099
2100 def test_status_json_head_commit_null_when_not_cloned(self, tmp_path: pathlib.Path) -> None:
2101 repo = _make_repo(tmp_path)
2102 add_workspace_member(repo, "core", "https://example.com/core")
2103 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2104 assert rc == 0
2105 assert _parse_list(stdout)[0]["head_commit"] is None
2106
2107 def test_status_json_dirty_false_when_not_cloned(self, tmp_path: pathlib.Path) -> None:
2108 repo = _make_repo(tmp_path)
2109 add_workspace_member(repo, "core", "https://example.com/core")
2110 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2111 assert rc == 0
2112 assert _parse_list(stdout)[0]["dirty"] is False
2113
2114 def test_status_empty_exits_0(self, tmp_path: pathlib.Path) -> None:
2115 repo = _make_repo(tmp_path)
2116 _, _, rc = _cli(["workspace", "status", "--json"], repo)
2117 assert rc == 0
2118
2119 def test_status_empty_json_empty_array(self, tmp_path: pathlib.Path) -> None:
2120 repo = _make_repo(tmp_path)
2121 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2122 assert rc == 0
2123 assert _parse_list(stdout) == []
2124
2125 def test_status_nonexistent_name_exits_1(self, tmp_path: pathlib.Path) -> None:
2126 repo = _make_repo(tmp_path)
2127 add_workspace_member(repo, "core", "https://example.com/core")
2128 _, _, rc = _cli(["workspace", "status", "ghost"], repo)
2129 assert rc == 1
2130
2131 def test_status_nonexistent_error_to_stderr(self, tmp_path: pathlib.Path) -> None:
2132 repo = _make_repo(tmp_path)
2133 add_workspace_member(repo, "core", "https://example.com/core")
2134 stdout, stderr, rc = _cli(["workspace", "status", "ghost"], repo)
2135 assert rc != 0
2136 assert "not found" in stderr
2137 assert "not found" not in stdout
2138
2139 def test_status_outside_repo_succeeds_empty(self, tmp_path: pathlib.Path) -> None:
2140 """Workspace status from a non-repo dir returns empty members — no muse repo required."""
2141 empty = tmp_path / "not_a_repo"
2142 empty.mkdir()
2143 stdout, _, rc = _cli(["workspace", "status", "--json"], empty)
2144 assert rc == 0
2145 assert json.loads(stdout)["members"] == []
2146
2147 def test_status_text_contains_member_name(self, tmp_path: pathlib.Path) -> None:
2148 repo = _make_repo(tmp_path)
2149 add_workspace_member(repo, "sounds", "https://example.com/sounds")
2150 stdout, _, rc = _cli(["workspace", "status"], repo)
2151 assert rc == 0
2152 assert "sounds" in stdout
2153
2154 def test_status_text_empty_message(self, tmp_path: pathlib.Path) -> None:
2155 repo = _make_repo(tmp_path)
2156 stdout, _, rc = _cli(["workspace", "status"], repo)
2157 assert rc == 0
2158 assert "No workspace members" in stdout
2159
2160 def test_status_help_description_present(self, tmp_path: pathlib.Path) -> None:
2161 repo = _make_repo(tmp_path)
2162 stdout, _, _ = _cli(["workspace", "status", "--help"], repo)
2163 assert "Agent quickstart" in stdout or "present" in stdout
2164
2165 def test_status_json_url_matches_registered(self, tmp_path: pathlib.Path) -> None:
2166 repo = _make_repo(tmp_path)
2167 add_workspace_member(repo, "core", "https://example.com/core")
2168 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2169 assert rc == 0
2170 assert _parse_list(stdout)[0]["url"] == "https://example.com/core"
2171
2172
2173 class TestWorkspaceStatusSecurity:
2174 """Security hardening tests for muse workspace status."""
2175
2176 def test_status_json_no_ansi_in_name(self, tmp_path: pathlib.Path) -> None:
2177 repo = _make_repo(tmp_path)
2178 manifest_path = repo / ".muse" / "workspace.toml"
2179 manifest_path.parent.mkdir(parents=True, exist_ok=True)
2180 manifest_path.write_text(
2181 '[workspace]\n[[workspace.members]]\n'
2182 'name = "evil\\u001b[31m"\n'
2183 'url = "https://example.com/evil"\n'
2184 'path = "repos/evil"\n'
2185 'branch = "main"\n'
2186 )
2187 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2188 assert rc == 0
2189 assert "\x1b" not in stdout
2190
2191 def test_status_json_no_ansi_in_url(self, tmp_path: pathlib.Path) -> None:
2192 repo = _make_repo(tmp_path)
2193 manifest_path = repo / ".muse" / "workspace.toml"
2194 manifest_path.parent.mkdir(parents=True, exist_ok=True)
2195 manifest_path.write_text(
2196 '[workspace]\n[[workspace.members]]\n'
2197 'name = "core"\n'
2198 'url = "https://example.com/\\u001b[31mcore"\n'
2199 'path = "repos/core"\n'
2200 'branch = "main"\n'
2201 )
2202 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2203 assert rc == 0
2204 assert "\x1b" not in stdout
2205
2206 def test_status_text_no_ansi(self, tmp_path: pathlib.Path) -> None:
2207 repo = _make_repo(tmp_path)
2208 add_workspace_member(repo, "core", "https://example.com/core")
2209 stdout, _, _ = _cli(["workspace", "status"], repo)
2210 assert "\x1b" not in stdout
2211
2212 def test_status_json_valid_json(self, tmp_path: pathlib.Path) -> None:
2213 repo = _make_repo(tmp_path)
2214 for i in range(3):
2215 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
2216 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2217 assert rc == 0
2218 raw = json.loads(_json_blob(stdout))
2219 assert isinstance(raw["members"], list)
2220 assert len(raw["members"]) == 3
2221
2222 def test_status_json_bool_fields_are_bool(self, tmp_path: pathlib.Path) -> None:
2223 repo = _make_repo(tmp_path)
2224 add_workspace_member(repo, "core", "https://example.com/core")
2225 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2226 assert rc == 0
2227 d = json.loads(_json_blob(stdout))["members"][0]
2228 assert isinstance(d["present"], bool)
2229 assert isinstance(d["dirty"], bool)
2230
2231 def test_status_symlink_manifest_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
2232 repo = _make_repo(tmp_path)
2233 add_workspace_member(repo, "core", "https://example.com/core")
2234 manifest_path = repo / ".muse" / "workspace.toml"
2235 real = tmp_path / "real.toml"
2236 real.write_text(manifest_path.read_text())
2237 manifest_path.unlink()
2238 manifest_path.symlink_to(real)
2239 # symlink guard returns None → empty list (exits 0, empty array)
2240 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2241 assert rc == 0
2242 assert _parse_list(stdout) == []
2243
2244
2245 class TestWorkspaceStatusStress:
2246 """Performance and scale tests for muse workspace status."""
2247
2248 def test_status_50_members_all_returned(self, tmp_path: pathlib.Path) -> None:
2249 repo = _make_repo(tmp_path)
2250 for i in range(50):
2251 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
2252 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2253 assert rc == 0
2254 assert len(_parse_list(stdout)) == 50
2255
2256 def test_status_performance_50_members(self, tmp_path: pathlib.Path) -> None:
2257 repo = _make_repo(tmp_path)
2258 for i in range(50):
2259 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
2260 t0 = time.monotonic()
2261 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2262 elapsed = time.monotonic() - t0
2263 assert rc == 0
2264 assert elapsed < 5.0, f"status of 50 took {elapsed:.2f}s"
2265
2266 def test_status_concurrent_reads_consistent(self, tmp_path: pathlib.Path) -> None:
2267 repo = _make_repo(tmp_path)
2268 for i in range(20):
2269 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
2270 counts: list[int] = []
2271 errors: list[str] = []
2272 lock = threading.Lock()
2273
2274 def _run() -> None:
2275 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2276 with lock:
2277 if rc != 0:
2278 errors.append(stdout)
2279 else:
2280 counts.append(len(json.loads(_json_blob(stdout))["members"]))
2281
2282 threads = [threading.Thread(target=_run) for _ in range(8)]
2283 for t in threads:
2284 t.start()
2285 for t in threads:
2286 t.join()
2287 assert not errors
2288 assert all(c == 20 for c in counts)
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago