test_core_workspace.py
python
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf
chore: bump version to 0.2.0rc14
Sonnet 4.6
patch
15 hours ago
| 1 | """Tests for muse/core/workspace.py — multi-repository workspace management.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import inspect |
| 6 | import json |
| 7 | import pathlib |
| 8 | import time |
| 9 | |
| 10 | import pytest |
| 11 | |
| 12 | from muse.core.types import NULL_COMMIT_ID |
| 13 | from muse.core.workspace import ( |
| 14 | WorkspaceMemberStatus, |
| 15 | add_workspace_member, |
| 16 | list_workspace_members, |
| 17 | remove_workspace_member, |
| 18 | ) |
| 19 | from muse.core.paths import muse_dir, workspace_toml_path |
| 20 | |
| 21 | |
| 22 | # --------------------------------------------------------------------------- |
| 23 | # Helpers |
| 24 | # --------------------------------------------------------------------------- |
| 25 | |
| 26 | |
| 27 | def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path: |
| 28 | muse = muse_dir(tmp_path) |
| 29 | for d in ("objects", "commits", "snapshots", "refs/heads"): |
| 30 | (muse / d).mkdir(parents=True, exist_ok=True) |
| 31 | (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"})) |
| 32 | (muse / "HEAD").write_text("ref: refs/heads/main\n") |
| 33 | (muse / "refs" / "heads" / "main").write_text(NULL_COMMIT_ID) |
| 34 | return tmp_path |
| 35 | |
| 36 | |
| 37 | # --------------------------------------------------------------------------- |
| 38 | # add_workspace_member |
| 39 | # --------------------------------------------------------------------------- |
| 40 | |
| 41 | |
| 42 | def test_add_member_creates_manifest(tmp_path: pathlib.Path) -> None: |
| 43 | repo = _make_repo(tmp_path) |
| 44 | add_workspace_member(repo, "core", "https://musehub.ai/acme/core") |
| 45 | manifest_path = workspace_toml_path(repo) |
| 46 | assert manifest_path.exists() |
| 47 | |
| 48 | |
| 49 | def test_add_member_stores_name_and_url(tmp_path: pathlib.Path) -> None: |
| 50 | repo = _make_repo(tmp_path) |
| 51 | add_workspace_member(repo, "sounds", "https://musehub.ai/acme/sounds") |
| 52 | members = list_workspace_members(repo) |
| 53 | assert len(members) == 1 |
| 54 | assert members[0].name == "sounds" |
| 55 | assert members[0].url == "https://musehub.ai/acme/sounds" |
| 56 | |
| 57 | |
| 58 | def test_add_member_default_path(tmp_path: pathlib.Path) -> None: |
| 59 | repo = _make_repo(tmp_path) |
| 60 | add_workspace_member(repo, "core", "https://example.com/core") |
| 61 | members = list_workspace_members(repo) |
| 62 | assert "repos/core" in str(members[0].path) |
| 63 | |
| 64 | |
| 65 | def test_add_member_custom_path(tmp_path: pathlib.Path) -> None: |
| 66 | repo = _make_repo(tmp_path) |
| 67 | add_workspace_member(repo, "core", "https://example.com/core", path="vendor/core") |
| 68 | members = list_workspace_members(repo) |
| 69 | assert "vendor/core" in str(members[0].path) |
| 70 | |
| 71 | |
| 72 | def test_add_member_default_branch_is_main(tmp_path: pathlib.Path) -> None: |
| 73 | repo = _make_repo(tmp_path) |
| 74 | add_workspace_member(repo, "core", "https://example.com/core") |
| 75 | members = list_workspace_members(repo) |
| 76 | assert members[0].branch == "main" |
| 77 | |
| 78 | |
| 79 | def test_add_member_custom_branch(tmp_path: pathlib.Path) -> None: |
| 80 | repo = _make_repo(tmp_path) |
| 81 | add_workspace_member(repo, "data", "https://example.com/data", branch="v2") |
| 82 | members = list_workspace_members(repo) |
| 83 | assert members[0].branch == "v2" |
| 84 | |
| 85 | |
| 86 | def test_add_duplicate_member_raises(tmp_path: pathlib.Path) -> None: |
| 87 | repo = _make_repo(tmp_path) |
| 88 | add_workspace_member(repo, "core", "https://example.com/core") |
| 89 | with pytest.raises(ValueError, match="already exists"): |
| 90 | add_workspace_member(repo, "core", "https://example.com/other") |
| 91 | |
| 92 | |
| 93 | def test_add_multiple_members(tmp_path: pathlib.Path) -> None: |
| 94 | repo = _make_repo(tmp_path) |
| 95 | add_workspace_member(repo, "core", "https://example.com/core") |
| 96 | add_workspace_member(repo, "sounds", "https://example.com/sounds") |
| 97 | add_workspace_member(repo, "docs", "https://example.com/docs") |
| 98 | members = list_workspace_members(repo) |
| 99 | assert len(members) == 3 |
| 100 | |
| 101 | |
| 102 | # --------------------------------------------------------------------------- |
| 103 | # remove_workspace_member |
| 104 | # --------------------------------------------------------------------------- |
| 105 | |
| 106 | |
| 107 | def test_remove_member_removes_from_manifest(tmp_path: pathlib.Path) -> None: |
| 108 | repo = _make_repo(tmp_path) |
| 109 | add_workspace_member(repo, "core", "https://example.com/core") |
| 110 | remove_workspace_member(repo, "core") |
| 111 | members = list_workspace_members(repo) |
| 112 | assert len(members) == 0 |
| 113 | |
| 114 | |
| 115 | def test_remove_nonexistent_member_raises(tmp_path: pathlib.Path) -> None: |
| 116 | repo = _make_repo(tmp_path) |
| 117 | # First add a member so the manifest exists, then try to remove a nonexistent one. |
| 118 | add_workspace_member(repo, "core", "https://example.com/core") |
| 119 | with pytest.raises(ValueError, match="not found"): |
| 120 | remove_workspace_member(repo, "nonexistent") |
| 121 | |
| 122 | |
| 123 | def test_remove_no_manifest_raises(tmp_path: pathlib.Path) -> None: |
| 124 | repo = _make_repo(tmp_path) |
| 125 | with pytest.raises(ValueError, match="No workspace manifest"): |
| 126 | remove_workspace_member(repo, "anything") |
| 127 | |
| 128 | |
| 129 | def test_remove_only_removes_named_member(tmp_path: pathlib.Path) -> None: |
| 130 | repo = _make_repo(tmp_path) |
| 131 | add_workspace_member(repo, "core", "https://example.com/core") |
| 132 | add_workspace_member(repo, "sounds", "https://example.com/sounds") |
| 133 | remove_workspace_member(repo, "core") |
| 134 | members = list_workspace_members(repo) |
| 135 | assert len(members) == 1 |
| 136 | assert members[0].name == "sounds" |
| 137 | |
| 138 | |
| 139 | # --------------------------------------------------------------------------- |
| 140 | # list_workspace_members |
| 141 | # --------------------------------------------------------------------------- |
| 142 | |
| 143 | |
| 144 | def test_list_returns_empty_when_no_manifest(tmp_path: pathlib.Path) -> None: |
| 145 | repo = _make_repo(tmp_path) |
| 146 | assert list_workspace_members(repo) == [] |
| 147 | |
| 148 | |
| 149 | def test_list_present_false_when_not_cloned(tmp_path: pathlib.Path) -> None: |
| 150 | repo = _make_repo(tmp_path) |
| 151 | add_workspace_member(repo, "core", "https://example.com/core") |
| 152 | members = list_workspace_members(repo) |
| 153 | assert members[0].present is False |
| 154 | |
| 155 | |
| 156 | def test_list_present_true_when_cloned(tmp_path: pathlib.Path) -> None: |
| 157 | repo = _make_repo(tmp_path) |
| 158 | add_workspace_member(repo, "local", str(tmp_path / "local_clone"), path="local_clone") |
| 159 | # Simulate a cloned repo at the expected path. |
| 160 | clone_path = tmp_path / "local_clone" |
| 161 | muse_dir(clone_path).mkdir(parents=True, exist_ok=True) |
| 162 | members = list_workspace_members(repo) |
| 163 | assert members[0].present is True |
| 164 | |
| 165 | |
| 166 | def test_list_head_none_when_not_cloned(tmp_path: pathlib.Path) -> None: |
| 167 | repo = _make_repo(tmp_path) |
| 168 | add_workspace_member(repo, "core", "https://example.com/core") |
| 169 | members = list_workspace_members(repo) |
| 170 | assert members[0].head_commit is None |
| 171 | |
| 172 | |
| 173 | def test_list_returns_workspace_member_status(tmp_path: pathlib.Path) -> None: |
| 174 | repo = _make_repo(tmp_path) |
| 175 | add_workspace_member(repo, "core", "https://example.com/core") |
| 176 | members = list_workspace_members(repo) |
| 177 | assert isinstance(members[0], WorkspaceMemberStatus) |
| 178 | |
| 179 | |
| 180 | # --------------------------------------------------------------------------- |
| 181 | # Performance — parallelism and no-subprocess shelf count |
| 182 | # --------------------------------------------------------------------------- |
| 183 | |
| 184 | |
| 185 | def _make_present_repo(parent: pathlib.Path, name: str) -> pathlib.Path: |
| 186 | """Create a minimal present repo inside *parent* and return its path.""" |
| 187 | repo = parent / name |
| 188 | muse = muse_dir(repo) |
| 189 | for d in ("objects", "commits", "snapshots", "refs/heads"): |
| 190 | (muse / d).mkdir(parents=True, exist_ok=True) |
| 191 | (muse / "repo.json").write_text(json.dumps({"repo_id": f"test-{name}"})) |
| 192 | (muse / "HEAD").write_text("ref: refs/heads/main\n") |
| 193 | (muse / "refs" / "heads" / "main").write_text(NULL_COMMIT_ID) |
| 194 | (muse / "shelf.json").write_text("[]") |
| 195 | return repo |
| 196 | |
| 197 | |
| 198 | def test_list_workspace_members_runs_in_parallel(tmp_path: pathlib.Path) -> None: |
| 199 | """list_workspace_members must dispatch per-member work concurrently. |
| 200 | |
| 201 | We verify this structurally: list_workspace_members (or the helper it |
| 202 | calls) must use concurrent.futures.ThreadPoolExecutor — a sequential |
| 203 | implementation would be unacceptably slow for large workspaces. |
| 204 | """ |
| 205 | import concurrent.futures |
| 206 | from muse.core import workspace as ws_module |
| 207 | |
| 208 | source = inspect.getsource(ws_module) |
| 209 | assert "ThreadPoolExecutor" in source, ( |
| 210 | "list_workspace_members must use ThreadPoolExecutor for parallelism" |
| 211 | ) |
| 212 | |
| 213 | |
| 214 | def test_shelf_count_does_not_spawn_subprocess(tmp_path: pathlib.Path) -> None: |
| 215 | """Shelf count must be read from .muse/shelf.json, not via subprocess. |
| 216 | |
| 217 | A subprocess for every member is the single largest performance cost. |
| 218 | The shelf count is a simple list-length read — no subprocess needed. |
| 219 | """ |
| 220 | from muse.core import workspace as ws_module |
| 221 | |
| 222 | source = inspect.getsource(ws_module) |
| 223 | # No subprocess spawning for shelf count. |
| 224 | assert '"muse", "shelf"' not in source, ( |
| 225 | "_member_status must not spawn 'muse shelf list' — read shelf.json directly" |
| 226 | ) |
| 227 | |
| 228 | |
| 229 | def test_shelf_count_read_from_file(tmp_path: pathlib.Path) -> None: |
| 230 | """shelf_count reflects entries in .muse/shelf.json without any subprocess.""" |
| 231 | workspace = _make_repo(tmp_path) |
| 232 | member_path = tmp_path / "member_a" |
| 233 | _make_present_repo(tmp_path, "member_a") |
| 234 | |
| 235 | # Write two shelf entries directly to shelf.json. |
| 236 | shelf_data = [{"id": "aaa", "intent": "wip1"}, {"id": "bbb", "intent": "wip2"}] |
| 237 | (muse_dir(member_path) / "shelf.json").write_text(json.dumps(shelf_data)) |
| 238 | |
| 239 | add_workspace_member(workspace, "member_a", "https://example.com/a", path="member_a") |
| 240 | members = list_workspace_members(workspace) |
| 241 | assert members[0].shelf_count == 2, ( |
| 242 | f"Expected shelf_count=2, got {members[0].shelf_count}" |
| 243 | ) |
| 244 | |
| 245 | |
| 246 | def test_shelf_count_zero_when_file_empty(tmp_path: pathlib.Path) -> None: |
| 247 | """shelf_count is 0 when shelf.json holds an empty list.""" |
| 248 | workspace = _make_repo(tmp_path) |
| 249 | _make_present_repo(tmp_path, "member_b") |
| 250 | add_workspace_member(workspace, "member_b", "https://example.com/b", path="member_b") |
| 251 | members = list_workspace_members(workspace) |
| 252 | assert members[0].shelf_count == 0 |
| 253 | |
| 254 | |
| 255 | def test_shelf_count_zero_when_file_absent(tmp_path: pathlib.Path) -> None: |
| 256 | """shelf_count is 0 when .muse/shelf.json does not exist.""" |
| 257 | workspace = _make_repo(tmp_path) |
| 258 | member_path = _make_present_repo(tmp_path, "member_c") |
| 259 | (muse_dir(member_path) / "shelf.json").unlink() |
| 260 | add_workspace_member(workspace, "member_c", "https://example.com/c", path="member_c") |
| 261 | members = list_workspace_members(workspace) |
| 262 | assert members[0].shelf_count == 0 |
| 263 | |
| 264 | |
| 265 | def test_list_workspace_members_wall_time(tmp_path: pathlib.Path) -> None: |
| 266 | """Seven present members must complete in under 2 s wall time. |
| 267 | |
| 268 | The sequential implementation with two subprocesses per member takes |
| 269 | ~3–4 s. Parallelism plus file-I/O shelf counting brings this well |
| 270 | under 1 s; 2 s is a generous upper bound that rules out regression |
| 271 | without being fragile on slow CI machines. |
| 272 | """ |
| 273 | workspace = _make_repo(tmp_path) |
| 274 | for i in range(7): |
| 275 | _make_present_repo(tmp_path, f"repo{i}") |
| 276 | add_workspace_member( |
| 277 | workspace, f"repo{i}", f"https://example.com/repo{i}", path=f"repo{i}" |
| 278 | ) |
| 279 | t0 = time.perf_counter() |
| 280 | members = list_workspace_members(workspace) |
| 281 | elapsed = time.perf_counter() - t0 |
| 282 | assert len(members) == 7 |
| 283 | assert elapsed < 2.0, ( |
| 284 | f"list_workspace_members took {elapsed:.2f}s for 7 members (limit 2.0s). " |
| 285 | "Check that members are processed in parallel and shelf uses file I/O." |
| 286 | ) |
| 287 | |
| 288 | |
| 289 | # --------------------------------------------------------------------------- |
| 290 | # Stress |
| 291 | # --------------------------------------------------------------------------- |
| 292 | |
| 293 | |
| 294 | def test_stress_50_members(tmp_path: pathlib.Path) -> None: |
| 295 | """Adding 50 members should all be preserved and listed correctly.""" |
| 296 | repo = _make_repo(tmp_path) |
| 297 | for i in range(50): |
| 298 | add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}") |
| 299 | members = list_workspace_members(repo) |
| 300 | assert len(members) == 50 |
| 301 | names = {m.name for m in members} |
| 302 | for i in range(50): |
| 303 | assert f"svc{i}" in names |
| 304 | |
| 305 | |
| 306 | def test_stress_add_remove_cycle(tmp_path: pathlib.Path) -> None: |
| 307 | """Add and remove 20 members; manifest should be empty at the end.""" |
| 308 | repo = _make_repo(tmp_path) |
| 309 | for i in range(20): |
| 310 | add_workspace_member(repo, f"repo{i}", f"https://example.com/repo{i}") |
| 311 | for i in range(20): |
| 312 | remove_workspace_member(repo, f"repo{i}") |
| 313 | assert list_workspace_members(repo) == [] |
File History
1 commit
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf
chore: bump version to 0.2.0rc14
Sonnet 4.6
patch
15 hours ago