cgcardona / muse public
test_init.py python
450 lines 16.4 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse init`` — initialise a new Muse repository.
2
3 Covers every acceptance criterion:
4 - Creates .muse/ with all required files
5 - Idempotent: exits 1 without --force when .muse/ already exists
6 - --force reinitialises and preserves existing repo_id
7 - muse status after init shows "On branch main, no commits yet"
8
9 Covers acceptance criteria:
10 - --bare creates .muse/ without muse-work/ and writes bare = true in config.toml
11 - --template copies template directory contents into muse-work/
12 - --default-branch names the initial branch instead of "main"
13 - All flags are combinable
14
15 All filesystem operations use ``tmp_path`` + ``os.chdir`` or the
16 ``MUSE_REPO_ROOT`` env-var override so tests are fully isolated.
17 """
18 from __future__ import annotations
19
20 import json
21 import os
22 import pathlib
23 import uuid
24
25 import pytest
26 from click.testing import Result
27 from typer.testing import CliRunner
28
29 from maestro.muse_cli.app import cli
30 from maestro.muse_cli.errors import ExitCode
31
32 runner = CliRunner()
33
34
35 # ---------------------------------------------------------------------------
36 # Helpers
37 # ---------------------------------------------------------------------------
38
39
40 def _run_init(tmp_path: pathlib.Path, *extra_args: str) -> Result:
41 """``chdir`` into *tmp_path* and invoke ``muse init``."""
42 prev = os.getcwd()
43 try:
44 os.chdir(tmp_path)
45 return runner.invoke(cli, ["init", *extra_args])
46 finally:
47 os.chdir(prev)
48
49
50 def _run_cmd(tmp_path: pathlib.Path, *args: str) -> Result:
51 """``chdir`` into *tmp_path* and invoke the CLI with *args*."""
52 prev = os.getcwd()
53 try:
54 os.chdir(tmp_path)
55 return runner.invoke(cli, list(args))
56 finally:
57 os.chdir(prev)
58
59
60 # ---------------------------------------------------------------------------
61 # Directory structure
62 # ---------------------------------------------------------------------------
63
64
65 def test_init_creates_muse_directory(tmp_path: pathlib.Path) -> None:
66 """``.muse/`` directory is created after ``muse init``."""
67 result = _run_init(tmp_path)
68 assert result.exit_code == 0, result.output
69 assert (tmp_path / ".muse").is_dir()
70
71
72 def test_init_creates_refs_heads_directory(tmp_path: pathlib.Path) -> None:
73 """``.muse/refs/heads/`` sub-tree is created."""
74 _run_init(tmp_path)
75 assert (tmp_path / ".muse" / "refs" / "heads").is_dir()
76
77
78 # ---------------------------------------------------------------------------
79 # repo.json
80 # ---------------------------------------------------------------------------
81
82
83 def test_init_writes_repo_json(tmp_path: pathlib.Path) -> None:
84 """``repo.json`` contains ``repo_id`` (valid UUID), ``schema_version``, ``created_at``."""
85 _run_init(tmp_path)
86 repo_json_path = tmp_path / ".muse" / "repo.json"
87 assert repo_json_path.exists(), "repo.json missing"
88
89 data = json.loads(repo_json_path.read_text())
90 assert "repo_id" in data
91 assert "schema_version" in data
92 assert "created_at" in data
93
94 # repo_id must be a valid UUID
95 parsed = uuid.UUID(data["repo_id"])
96 assert str(parsed) == data["repo_id"]
97
98
99 def test_init_repo_json_schema_version_is_1(tmp_path: pathlib.Path) -> None:
100 """``schema_version`` is ``"1"`` in the initial repo.json."""
101 _run_init(tmp_path)
102 data = json.loads((tmp_path / ".muse" / "repo.json").read_text())
103 assert data["schema_version"] == "1"
104
105
106 # ---------------------------------------------------------------------------
107 # HEAD file
108 # ---------------------------------------------------------------------------
109
110
111 def test_init_writes_head_file(tmp_path: pathlib.Path) -> None:
112 """``.muse/HEAD`` is written and points to ``refs/heads/main``."""
113 _run_init(tmp_path)
114 head_path = tmp_path / ".muse" / "HEAD"
115 assert head_path.exists(), ".muse/HEAD missing"
116 assert head_path.read_text().strip() == "refs/heads/main"
117
118
119 def test_init_writes_main_ref(tmp_path: pathlib.Path) -> None:
120 """``.muse/refs/heads/main`` exists (empty — no commits yet)."""
121 _run_init(tmp_path)
122 ref_path = tmp_path / ".muse" / "refs" / "heads" / "main"
123 assert ref_path.exists(), ".muse/refs/heads/main missing"
124 # Empty content = no commits on this branch
125 assert ref_path.read_text().strip() == ""
126
127
128 # ---------------------------------------------------------------------------
129 # config.toml
130 # ---------------------------------------------------------------------------
131
132
133 def test_init_writes_config_toml(tmp_path: pathlib.Path) -> None:
134 """``config.toml`` is created with ``[user]``, ``[auth]``, ``[remotes]`` sections."""
135 _run_init(tmp_path)
136 config_path = tmp_path / ".muse" / "config.toml"
137 assert config_path.exists(), "config.toml missing"
138
139 content = config_path.read_text()
140 assert "[user]" in content
141 assert "[auth]" in content
142 assert "[remotes]" in content
143
144
145 def test_init_config_toml_is_valid_toml(tmp_path: pathlib.Path) -> None:
146 """``config.toml`` produced by ``muse init`` is valid TOML (parseable via stdlib)."""
147 import tomllib # Python 3.11+ stdlib
148
149 _run_init(tmp_path)
150 config_path = tmp_path / ".muse" / "config.toml"
151 with config_path.open("rb") as fh:
152 parsed = tomllib.load(fh)
153 assert "user" in parsed
154 assert "auth" in parsed
155
156
157 # ---------------------------------------------------------------------------
158 # Idempotency / --force behaviour
159 # ---------------------------------------------------------------------------
160
161
162 def test_init_idempotent_without_force_exits_1(tmp_path: pathlib.Path) -> None:
163 """Second ``muse init`` without ``--force`` exits 1 with an informative message."""
164 _run_init(tmp_path)
165 result = _run_init(tmp_path) # second call
166
167 assert result.exit_code == int(ExitCode.USER_ERROR), result.output
168 assert "Already a Muse repository" in result.output
169 assert "--force" in result.output
170
171
172 def test_init_force_reinitialises(tmp_path: pathlib.Path) -> None:
173 """``muse init --force`` succeeds even when ``.muse/`` already exists."""
174 _run_init(tmp_path)
175 result = _run_init(tmp_path, "--force")
176
177 assert result.exit_code == 0, result.output
178 assert "Reinitialised" in result.output
179
180
181 def test_init_force_preserves_repo_id(tmp_path: pathlib.Path) -> None:
182 """``muse init --force`` preserves the existing ``repo_id`` from ``repo.json``."""
183 _run_init(tmp_path)
184 first_id = json.loads((tmp_path / ".muse" / "repo.json").read_text())["repo_id"]
185
186 _run_init(tmp_path, "--force")
187 second_id = json.loads((tmp_path / ".muse" / "repo.json").read_text())["repo_id"]
188
189 assert first_id == second_id, "repo_id must survive --force reinitialise"
190
191
192 def test_init_force_does_not_overwrite_config_toml(tmp_path: pathlib.Path) -> None:
193 """``muse init --force`` does NOT overwrite an existing ``config.toml``."""
194 _run_init(tmp_path)
195 config_path = tmp_path / ".muse" / "config.toml"
196 config_path.write_text('[user]\nname = "Gabriel"\nemail = "g@example.com"\n\n[auth]\ntoken = "tok"\n\n[remotes]\n')
197
198 _run_init(tmp_path, "--force")
199 content = config_path.read_text()
200 assert 'name = "Gabriel"' in content, "--force must not overwrite config.toml"
201
202
203 def test_init_success_output_contains_path(tmp_path: pathlib.Path) -> None:
204 """Success message includes the ``.muse`` directory path."""
205 result = _run_init(tmp_path)
206 assert ".muse" in result.output
207
208
209 # ---------------------------------------------------------------------------
210 # muse status after muse init (acceptance criterion)
211 # ---------------------------------------------------------------------------
212
213
214 def test_status_shows_on_branch_main_no_commits(tmp_path: pathlib.Path) -> None:
215 """``muse status`` immediately after ``muse init`` shows 'On branch main, no commits yet'."""
216 _run_init(tmp_path)
217 result = _run_cmd(tmp_path, "status")
218
219 assert result.exit_code == 0, result.output
220 assert "On branch main" in result.output
221 assert "no commits yet" in result.output
222
223
224 # ---------------------------------------------------------------------------
225 # Error handling — filesystem permission failures (regression for permission bug)
226 # ---------------------------------------------------------------------------
227
228
229 def test_init_permission_error_exits_1(
230 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
231 ) -> None:
232 """``muse init`` exits 1 with a clean message when the CWD is not writable.
233
234 Regression test: previously a raw ``PermissionError`` traceback was shown
235 (reproduced by running ``docker compose exec maestro muse init`` from
236 ``/app/``, which is owned by root and not writable by the container user).
237 """
238
239 def _raise_permission(*args: object, **kwargs: object) -> None:
240 raise PermissionError("[Errno 13] Permission denied: '/app/.muse'")
241
242 monkeypatch.setattr(pathlib.Path, "mkdir", _raise_permission)
243
244 result = _run_init(tmp_path)
245
246 assert result.exit_code == int(ExitCode.USER_ERROR), result.output
247 assert "Permission denied" in result.output
248 assert "write access" in result.output
249 assert "mkdir -p" in result.output
250 # Must NOT produce a raw Python traceback.
251 assert "Traceback" not in result.output
252 assert "PermissionError" not in result.output
253
254
255 def test_init_oserror_exits_3(
256 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
257 ) -> None:
258 """``muse init`` exits 3 with a clean message on unexpected ``OSError``."""
259
260 def _raise_os(*args: object, **kwargs: object) -> None:
261 raise OSError("[Errno 28] No space left on device")
262
263 monkeypatch.setattr(pathlib.Path, "mkdir", _raise_os)
264
265 result = _run_init(tmp_path)
266
267 assert result.exit_code == int(ExitCode.INTERNAL_ERROR), result.output
268 assert "Failed to initialise" in result.output
269 assert "Traceback" not in result.output
270
271
272 # ---------------------------------------------------------------------------
273 # --bare flag
274 # ---------------------------------------------------------------------------
275
276
277 def test_bare_creates_muse_directory(tmp_path: pathlib.Path) -> None:
278 """``muse init --bare`` creates a ``.muse/`` directory."""
279 result = _run_init(tmp_path, "--bare")
280 assert result.exit_code == 0, result.output
281 assert (tmp_path / ".muse").is_dir()
282
283
284 def test_bare_does_not_create_muse_work(tmp_path: pathlib.Path) -> None:
285 """``muse init --bare`` must NOT create ``muse-work/``."""
286 _run_init(tmp_path, "--bare")
287 assert not (tmp_path / "muse-work").exists()
288
289
290 def test_bare_writes_bare_flag_in_repo_json(tmp_path: pathlib.Path) -> None:
291 """``muse init --bare`` writes ``bare = true`` into ``repo.json``."""
292 _run_init(tmp_path, "--bare")
293 data = json.loads((tmp_path / ".muse" / "repo.json").read_text())
294 assert data.get("bare") is True
295
296
297 def test_bare_writes_bare_flag_in_config_toml(tmp_path: pathlib.Path) -> None:
298 """``muse init --bare`` writes ``bare = true`` into ``config.toml``."""
299 _run_init(tmp_path, "--bare")
300 content = (tmp_path / ".muse" / "config.toml").read_text()
301 assert "bare = true" in content
302
303
304 def test_bare_output_mentions_bare(tmp_path: pathlib.Path) -> None:
305 """Success message for ``--bare`` includes the word 'bare'."""
306 result = _run_init(tmp_path, "--bare")
307 assert "bare" in result.output.lower()
308
309
310 def test_normal_init_creates_muse_work(tmp_path: pathlib.Path) -> None:
311 """A regular (non-bare) ``muse init`` creates the ``muse-work/`` directory."""
312 _run_init(tmp_path)
313 assert (tmp_path / "muse-work").is_dir()
314
315
316 def test_normal_init_repo_json_has_no_bare_flag(tmp_path: pathlib.Path) -> None:
317 """A non-bare ``muse init`` does not write ``bare`` into ``repo.json``."""
318 _run_init(tmp_path)
319 data = json.loads((tmp_path / ".muse" / "repo.json").read_text())
320 assert "bare" not in data
321
322
323 # ---------------------------------------------------------------------------
324 # --template flag
325 # ---------------------------------------------------------------------------
326
327
328 def test_template_copies_contents_into_muse_work(
329 tmp_path: pathlib.Path,
330 ) -> None:
331 """``muse init --template <path>`` copies template contents into ``muse-work/``."""
332 template_dir = tmp_path / "tmpl"
333 template_dir.mkdir()
334 (template_dir / "drums").mkdir()
335 (template_dir / "bass").mkdir()
336 (template_dir / "README.md").write_text("studio template\n")
337
338 work_dir = tmp_path / "project"
339 work_dir.mkdir()
340
341 result = _run_init(work_dir, "--template", str(template_dir))
342 assert result.exit_code == 0, result.output
343
344 muse_work = work_dir / "muse-work"
345 assert (muse_work / "drums").is_dir()
346 assert (muse_work / "bass").is_dir()
347 assert (muse_work / "README.md").read_text() == "studio template\n"
348
349
350 def test_template_nonexistent_path_exits_1(tmp_path: pathlib.Path) -> None:
351 """``muse init --template`` exits 1 when the template path does not exist."""
352 result = _run_init(tmp_path, "--template", str(tmp_path / "no_such_dir"))
353 assert result.exit_code == int(ExitCode.USER_ERROR), result.output
354 assert "does not exist" in result.output or "not a directory" in result.output
355
356
357 def test_template_ignored_for_bare_repos(tmp_path: pathlib.Path) -> None:
358 """``muse init --bare --template`` does not create ``muse-work/`` (bare takes priority)."""
359 template_dir = tmp_path / "tmpl"
360 template_dir.mkdir()
361 (template_dir / "keys").mkdir()
362
363 project_dir = tmp_path / "project"
364 project_dir.mkdir()
365
366 result = _run_init(project_dir, "--bare", "--template", str(template_dir))
367 assert result.exit_code == 0, result.output
368 assert not (project_dir / "muse-work").exists()
369
370
371 # ---------------------------------------------------------------------------
372 # --default-branch flag
373 # ---------------------------------------------------------------------------
374
375
376 def test_default_branch_sets_head_pointer(tmp_path: pathlib.Path) -> None:
377 """``muse init --default-branch develop`` writes ``refs/heads/develop`` into HEAD."""
378 result = _run_init(tmp_path, "--default-branch", "develop")
379 assert result.exit_code == 0, result.output
380 head = (tmp_path / ".muse" / "HEAD").read_text().strip()
381 assert head == "refs/heads/develop"
382
383
384 def test_default_branch_creates_ref_file(tmp_path: pathlib.Path) -> None:
385 """``muse init --default-branch release`` creates ``.muse/refs/heads/release``."""
386 _run_init(tmp_path, "--default-branch", "release")
387 assert (tmp_path / ".muse" / "refs" / "heads" / "release").exists()
388
389
390 def test_default_branch_default_is_main(tmp_path: pathlib.Path) -> None:
391 """Without ``--default-branch``, HEAD points to ``refs/heads/main`` (regression guard)."""
392 _run_init(tmp_path)
393 head = (tmp_path / ".muse" / "HEAD").read_text().strip()
394 assert head == "refs/heads/main"
395
396
397 def test_default_branch_combined_with_bare(tmp_path: pathlib.Path) -> None:
398 """``--default-branch`` and ``--bare`` are combinable."""
399 result = _run_init(tmp_path, "--bare", "--default-branch", "trunk")
400 assert result.exit_code == 0, result.output
401 head = (tmp_path / ".muse" / "HEAD").read_text().strip()
402 assert head == "refs/heads/trunk"
403 assert (tmp_path / ".muse" / "refs" / "heads" / "trunk").exists()
404 assert not (tmp_path / "muse-work").exists()
405
406
407 def test_default_branch_combined_with_template(tmp_path: pathlib.Path) -> None:
408 """``--default-branch`` and ``--template`` are combinable."""
409 template_dir = tmp_path / "tmpl"
410 template_dir.mkdir()
411 (template_dir / "vocals").mkdir()
412
413 project_dir = tmp_path / "project"
414 project_dir.mkdir()
415
416 result = _run_init(
417 project_dir, "--default-branch", "studio", "--template", str(template_dir)
418 )
419 assert result.exit_code == 0, result.output
420 head = (project_dir / ".muse" / "HEAD").read_text().strip()
421 assert head == "refs/heads/studio"
422 assert (project_dir / "muse-work" / "vocals").is_dir()
423
424
425 def test_all_three_flags_combined(tmp_path: pathlib.Path) -> None:
426 """``--bare``, ``--template``, and ``--default-branch`` can all be passed together.
427
428 When --bare is used, muse-work/ is never created even if --template is given.
429 """
430 template_dir = tmp_path / "tmpl"
431 template_dir.mkdir()
432 (template_dir / "drums").mkdir()
433
434 project_dir = tmp_path / "project"
435 project_dir.mkdir()
436
437 result = _run_init(
438 project_dir,
439 "--bare",
440 "--template",
441 str(template_dir),
442 "--default-branch",
443 "develop",
444 )
445 assert result.exit_code == 0, result.output
446 head = (project_dir / ".muse" / "HEAD").read_text().strip()
447 assert head == "refs/heads/develop"
448 data = json.loads((project_dir / ".muse" / "repo.json").read_text())
449 assert data.get("bare") is True
450 assert not (project_dir / "muse-work").exists()