gabriel / muse public
test_core_workspace.py python
313 lines 11.9 KB
Raw
sha256:84df9126d09aeec0b8f1b908f0b06c10913feec28f3514b382efb1ba6d619385 refactor: rename StructuredMergePlugin to AddressedMergePlu… Sonnet 4.6 minor ⚠ breaking 24 days 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:84df9126d09aeec0b8f1b908f0b06c10913feec28f3514b382efb1ba6d619385 refactor: rename StructuredMergePlugin to AddressedMergePlu… Sonnet 4.6 minor 24 days ago