gabriel / muse public
test_cli_workflow.py python
417 lines 17.0 KB
a6a637a2 test(log): cover max_count early-stop at both unit and CLI level Gabriel Cardona <gabriel@tellurstori.com> 2d ago
1 """End-to-end CLI workflow tests — init, commit, log, status, branch, merge."""
2
3 import pathlib
4
5 import pytest
6 from typer.testing import CliRunner
7
8 from muse.cli.app import cli
9
10 runner = CliRunner()
11
12
13 @pytest.fixture
14 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
15 """Initialise a fresh Muse repo in tmp_path and set it as cwd."""
16 monkeypatch.chdir(tmp_path)
17 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
18 result = runner.invoke(cli, ["init"])
19 assert result.exit_code == 0, result.output
20 return tmp_path
21
22
23 def _write(repo: pathlib.Path, filename: str, content: str = "data") -> None:
24 (repo / filename).write_text(content)
25
26
27 class TestInit:
28 def test_creates_muse_dir(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
29 monkeypatch.chdir(tmp_path)
30 result = runner.invoke(cli, ["init"])
31 assert result.exit_code == 0
32 assert (tmp_path / ".muse").is_dir()
33 assert (tmp_path / ".muse" / "HEAD").exists()
34 assert (tmp_path / ".muse" / "repo.json").exists()
35 assert (tmp_path).is_dir()
36
37 def test_reinit_requires_force(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
38 monkeypatch.chdir(tmp_path)
39 runner.invoke(cli, ["init"])
40 result = runner.invoke(cli, ["init"])
41 assert result.exit_code != 0
42 assert "force" in result.output.lower()
43
44 def test_bare_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
45 monkeypatch.chdir(tmp_path)
46 result = runner.invoke(cli, ["init", "--bare"])
47 assert result.exit_code == 0
48 # Bare repos have the internal store but no template files are copied.
49 assert (tmp_path / ".muse").exists()
50
51 def test_creates_museignore(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
52 monkeypatch.chdir(tmp_path)
53 result = runner.invoke(cli, ["init"])
54 assert result.exit_code == 0
55 ignore_file = tmp_path / ".museignore"
56 assert ignore_file.exists(), ".museignore should be created by muse init"
57
58 def test_museignore_is_valid_toml(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
59 import tomllib
60
61 monkeypatch.chdir(tmp_path)
62 runner.invoke(cli, ["init"])
63 ignore_file = tmp_path / ".museignore"
64 with ignore_file.open("rb") as fh:
65 config = tomllib.load(fh)
66 assert isinstance(config, dict), ".museignore must be valid TOML"
67
68 def test_museignore_has_global_section(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
69 import tomllib
70
71 monkeypatch.chdir(tmp_path)
72 runner.invoke(cli, ["init"])
73 with (tmp_path / ".museignore").open("rb") as fh:
74 config = tomllib.load(fh)
75 assert "global" in config, ".museignore should have a [global] section"
76 assert isinstance(config["global"].get("patterns"), list)
77
78 def test_museignore_has_domain_section_for_midi(
79 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
80 ) -> None:
81 import tomllib
82
83 monkeypatch.chdir(tmp_path)
84 runner.invoke(cli, ["init", "--domain", "midi"])
85 with (tmp_path / ".museignore").open("rb") as fh:
86 config = tomllib.load(fh)
87 domain_map = config.get("domain", {})
88 assert "midi" in domain_map, "[domain.midi] section should be present for --domain midi"
89
90 def test_museignore_has_domain_section_for_code(
91 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
92 ) -> None:
93 import tomllib
94
95 monkeypatch.chdir(tmp_path)
96 runner.invoke(cli, ["init", "--domain", "code"])
97 with (tmp_path / ".museignore").open("rb") as fh:
98 config = tomllib.load(fh)
99 domain_map = config.get("domain", {})
100 assert "code" in domain_map, "[domain.code] section should be present for --domain code"
101
102 def test_museignore_not_overwritten_on_reinit(
103 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
104 ) -> None:
105 monkeypatch.chdir(tmp_path)
106 runner.invoke(cli, ["init"])
107 custom = '[global]\npatterns = ["custom.txt"]\n'
108 (tmp_path / ".museignore").write_text(custom)
109 runner.invoke(cli, ["init", "--force"])
110 assert (tmp_path / ".museignore").read_text() == custom
111
112 def test_museignore_parseable_by_load_ignore_config(
113 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
114 ) -> None:
115 from muse.core.ignore import load_ignore_config, resolve_patterns
116
117 monkeypatch.chdir(tmp_path)
118 runner.invoke(cli, ["init", "--domain", "midi"])
119 config = load_ignore_config(tmp_path)
120 patterns = resolve_patterns(config, "midi")
121 assert isinstance(patterns, list)
122 assert len(patterns) > 0, "midi init should produce non-empty pattern list"
123
124
125 class TestCommit:
126 def test_commit_with_message(self, repo: pathlib.Path) -> None:
127 _write(repo, "beat.mid")
128 result = runner.invoke(cli, ["commit", "-m", "Initial commit"])
129 assert result.exit_code == 0
130 assert "Initial commit" in result.output
131
132 def test_nothing_to_commit(self, repo: pathlib.Path) -> None:
133 _write(repo, "beat.mid")
134 runner.invoke(cli, ["commit", "-m", "First"])
135 result = runner.invoke(cli, ["commit", "-m", "Second"])
136 assert result.exit_code == 0
137 assert "Nothing to commit" in result.output
138
139 def test_allow_empty(self, repo: pathlib.Path) -> None:
140 result = runner.invoke(cli, ["commit", "-m", "Empty", "--allow-empty"])
141 assert result.exit_code == 0
142
143 def test_message_required(self, repo: pathlib.Path) -> None:
144 _write(repo, "beat.mid")
145 result = runner.invoke(cli, ["commit"])
146 assert result.exit_code != 0
147
148 def test_section_metadata(self, repo: pathlib.Path) -> None:
149 _write(repo, "beat.mid")
150 result = runner.invoke(cli, ["commit", "-m", "Chorus take", "--section", "chorus"])
151 assert result.exit_code == 0
152
153 from muse.core.store import get_head_commit_id, read_commit
154 import json
155 repo_id = json.loads((repo / ".muse" / "repo.json").read_text())["repo_id"]
156 commit_id = get_head_commit_id(repo, "main")
157 commit = read_commit(repo, commit_id)
158 assert commit is not None
159 assert commit.metadata.get("section") == "chorus"
160
161
162 class TestStatus:
163 def test_clean_after_commit(self, repo: pathlib.Path) -> None:
164 _write(repo, "beat.mid")
165 runner.invoke(cli, ["commit", "-m", "First"])
166 result = runner.invoke(cli, ["status"])
167 assert result.exit_code == 0
168 assert "Nothing to commit" in result.output
169
170 def test_shows_new_file(self, repo: pathlib.Path) -> None:
171 _write(repo, "beat.mid")
172 result = runner.invoke(cli, ["status"])
173 assert result.exit_code == 0
174 assert "beat.mid" in result.output
175
176 def test_short_flag(self, repo: pathlib.Path) -> None:
177 _write(repo, "beat.mid")
178 result = runner.invoke(cli, ["status", "--short"])
179 assert result.exit_code == 0
180 assert "A " in result.output
181
182 def test_porcelain_flag(self, repo: pathlib.Path) -> None:
183 _write(repo, "beat.mid")
184 result = runner.invoke(cli, ["status", "--porcelain"])
185 assert result.exit_code == 0
186 assert "## main" in result.output
187
188
189 class TestLog:
190 def test_empty_log(self, repo: pathlib.Path) -> None:
191 result = runner.invoke(cli, ["log"])
192 assert result.exit_code == 0
193 assert "no commits" in result.output
194
195 def test_shows_commit(self, repo: pathlib.Path) -> None:
196 _write(repo, "beat.mid")
197 runner.invoke(cli, ["commit", "-m", "First take"])
198 result = runner.invoke(cli, ["log"])
199 assert result.exit_code == 0
200 assert "First take" in result.output
201
202 def test_oneline(self, repo: pathlib.Path) -> None:
203 _write(repo, "beat.mid")
204 runner.invoke(cli, ["commit", "-m", "First take"])
205 result = runner.invoke(cli, ["log", "--oneline"])
206 assert result.exit_code == 0
207 assert "First take" in result.output
208 assert "Author:" not in result.output
209
210 def test_multiple_commits_newest_first(self, repo: pathlib.Path) -> None:
211 _write(repo, "a.mid")
212 runner.invoke(cli, ["commit", "-m", "First"])
213 _write(repo, "b.mid")
214 runner.invoke(cli, ["commit", "-m", "Second"])
215 result = runner.invoke(cli, ["log", "--oneline"])
216 lines = [l for l in result.output.strip().splitlines() if l.strip()]
217 assert "Second" in lines[0]
218 assert "First" in lines[1]
219
220 def test_max_count_limits_output(self, repo: pathlib.Path) -> None:
221 """muse log -n 2 returns only the two most recent commits from a longer chain."""
222 for i in range(1, 6):
223 _write(repo, f"track{i}.mid")
224 runner.invoke(cli, ["commit", "-m", f"Commit {i}"])
225
226 result = runner.invoke(cli, ["log", "--oneline", "-n", "2"])
227 assert result.exit_code == 0
228 lines = [l for l in result.output.strip().splitlines() if l.strip()]
229 assert len(lines) == 2
230 assert "Commit 5" in lines[0]
231 assert "Commit 4" in lines[1]
232
233 def test_max_count_one_returns_single_commit(self, repo: pathlib.Path) -> None:
234 """muse log -n 1 returns exactly the HEAD commit."""
235 for i in range(1, 4):
236 _write(repo, f"t{i}.mid")
237 runner.invoke(cli, ["commit", "-m", f"Take {i}"])
238
239 result = runner.invoke(cli, ["log", "--oneline", "-n", "1"])
240 assert result.exit_code == 0
241 lines = [l for l in result.output.strip().splitlines() if l.strip()]
242 assert len(lines) == 1
243 assert "Take 3" in lines[0]
244
245 def test_max_count_larger_than_history_returns_all(self, repo: pathlib.Path) -> None:
246 """muse log -n 100 on a 3-commit repo returns all 3 without error."""
247 for i in range(1, 4):
248 _write(repo, f"f{i}.mid")
249 runner.invoke(cli, ["commit", "-m", f"Track {i}"])
250
251 result = runner.invoke(cli, ["log", "--oneline", "-n", "100"])
252 assert result.exit_code == 0
253 lines = [l for l in result.output.strip().splitlines() if l.strip()]
254 assert len(lines) == 3
255
256
257 class TestBranch:
258 def test_list_shows_main(self, repo: pathlib.Path) -> None:
259 result = runner.invoke(cli, ["branch"])
260 assert result.exit_code == 0
261 assert "main" in result.output
262 assert "* " in result.output
263
264 def test_create_branch(self, repo: pathlib.Path) -> None:
265 result = runner.invoke(cli, ["branch", "feature/chorus"])
266 assert result.exit_code == 0
267 result = runner.invoke(cli, ["branch"])
268 assert "feature/chorus" in result.output
269
270 def test_delete_branch(self, repo: pathlib.Path) -> None:
271 runner.invoke(cli, ["branch", "feature/x"])
272 result = runner.invoke(cli, ["branch", "--delete", "feature/x"])
273 assert result.exit_code == 0
274 result = runner.invoke(cli, ["branch"])
275 assert "feature/x" not in result.output
276
277
278 class TestCheckout:
279 def test_create_and_switch(self, repo: pathlib.Path) -> None:
280 result = runner.invoke(cli, ["checkout", "-b", "feature/chorus"])
281 assert result.exit_code == 0
282 assert "feature/chorus" in result.output
283 status = runner.invoke(cli, ["status"])
284 assert "feature/chorus" in status.output
285
286 def test_switch_existing_branch(self, repo: pathlib.Path) -> None:
287 runner.invoke(cli, ["checkout", "-b", "feature/chorus"])
288 runner.invoke(cli, ["checkout", "main"])
289 result = runner.invoke(cli, ["status"])
290 assert "main" in result.output
291
292 def test_already_on_branch(self, repo: pathlib.Path) -> None:
293 result = runner.invoke(cli, ["checkout", "main"])
294 assert result.exit_code == 0
295 assert "Already on" in result.output
296
297
298 class TestMerge:
299 def test_fast_forward(self, repo: pathlib.Path) -> None:
300 _write(repo, "verse.mid")
301 runner.invoke(cli, ["commit", "-m", "Verse"])
302 runner.invoke(cli, ["checkout", "-b", "feature/chorus"])
303 _write(repo, "chorus.mid")
304 runner.invoke(cli, ["commit", "-m", "Add chorus"])
305 runner.invoke(cli, ["checkout", "main"])
306 result = runner.invoke(cli, ["merge", "feature/chorus"])
307 assert result.exit_code == 0
308 assert "Fast-forward" in result.output
309
310 def test_clean_three_way_merge(self, repo: pathlib.Path) -> None:
311 _write(repo, "base.mid")
312 runner.invoke(cli, ["commit", "-m", "Base"])
313 runner.invoke(cli, ["checkout", "-b", "branch-a"])
314 _write(repo, "a.mid")
315 runner.invoke(cli, ["commit", "-m", "Add A"])
316 runner.invoke(cli, ["checkout", "main"])
317 runner.invoke(cli, ["checkout", "-b", "branch-b"])
318 _write(repo, "b.mid")
319 runner.invoke(cli, ["commit", "-m", "Add B"])
320 runner.invoke(cli, ["checkout", "main"])
321 result = runner.invoke(cli, ["merge", "branch-a"])
322 assert result.exit_code == 0
323
324 def test_cannot_merge_self(self, repo: pathlib.Path) -> None:
325 result = runner.invoke(cli, ["merge", "main"])
326 assert result.exit_code != 0
327
328
329 class TestDiff:
330 def test_no_diff_clean(self, repo: pathlib.Path) -> None:
331 _write(repo, "beat.mid")
332 runner.invoke(cli, ["commit", "-m", "First"])
333 result = runner.invoke(cli, ["diff"])
334 assert result.exit_code == 0
335 assert "No differences" in result.output
336
337 def test_shows_new_file(self, repo: pathlib.Path) -> None:
338 _write(repo, "beat.mid")
339 runner.invoke(cli, ["commit", "-m", "First"])
340 _write(repo, "lead.mid")
341 result = runner.invoke(cli, ["diff"])
342 assert result.exit_code == 0
343 assert "lead.mid" in result.output
344
345
346 class TestTag:
347 def test_add_and_list(self, repo: pathlib.Path) -> None:
348 _write(repo, "beat.mid")
349 runner.invoke(cli, ["commit", "-m", "Tagged take"])
350 result = runner.invoke(cli, ["tag", "add", "emotion:joyful"])
351 assert result.exit_code == 0
352 result = runner.invoke(cli, ["tag", "list"])
353 assert "emotion:joyful" in result.output
354
355
356 class TestDiffWorkingTreeSymbols:
357 """Regression: muse diff must show semantic symbols for uncommitted files.
358
359 Before the fix, diff fell back to a plain ``A file.md`` when the blob
360 wasn't in the object store (only written on commit). After the fix, it
361 reads directly from disk (hash-verified) and extracts symbols via the
362 appropriate adapter.
363 """
364
365 def test_new_markdown_file_shows_sections(self, repo: pathlib.Path) -> None:
366 _write(repo, "first.py", "def setup(): pass")
367 runner.invoke(cli, ["commit", "-m", "init"])
368 _write(repo, "README.md", "# Overview\n\n## Installation\n\n## Usage\n")
369 result = runner.invoke(cli, ["diff"])
370 assert result.exit_code == 0
371 # Symbol-level output must list the heading sections.
372 assert "Overview" in result.output
373 assert "Installation" in result.output
374
375 def test_new_markdown_file_shows_A_prefix(self, repo: pathlib.Path) -> None:
376 _write(repo, "first.py", "def setup(): pass")
377 runner.invoke(cli, ["commit", "-m", "init"])
378 _write(repo, "README.md", "# Title\n\n## Intro\n")
379 result = runner.invoke(cli, ["diff"])
380 assert result.exit_code == 0
381 # The PatchOp for a newly-added file must use 'A' not 'M'.
382 lines = result.output.splitlines()
383 readme_line = next((l for l in lines if "README.md" in l), None)
384 assert readme_line is not None
385 assert readme_line.startswith("A"), f"Expected 'A README.md', got: {readme_line!r}"
386
387 def test_new_python_file_shows_functions(self, repo: pathlib.Path) -> None:
388 runner.invoke(cli, ["commit", "-m", "empty"])
389 _write(repo, "utils.py", "def add(a, b):\n return a + b\n\ndef sub(a, b):\n return a - b\n")
390 result = runner.invoke(cli, ["diff"])
391 assert result.exit_code == 0
392 assert "add" in result.output
393 assert "sub" in result.output
394
395 def test_modified_file_shows_M_prefix(self, repo: pathlib.Path) -> None:
396 _write(repo, "utils.py", "def foo(): pass\ndef bar(): pass\n")
397 runner.invoke(cli, ["commit", "-m", "First"])
398 _write(repo, "utils.py", "def foo(): pass\ndef bar(): return 1\n")
399 result = runner.invoke(cli, ["diff"])
400 assert result.exit_code == 0
401 lines = result.output.splitlines()
402 utils_line = next((l for l in lines if "utils.py" in l), None)
403 assert utils_line is not None
404 assert utils_line.startswith("M"), f"Expected 'M utils.py', got: {utils_line!r}"
405
406
407 class TestStash:
408 def test_stash_and_pop(self, repo: pathlib.Path) -> None:
409 _write(repo, "beat.mid")
410 runner.invoke(cli, ["commit", "-m", "First"])
411 _write(repo, "lead.mid")
412 result = runner.invoke(cli, ["stash"])
413 assert result.exit_code == 0
414 assert not (repo / "lead.mid").exists()
415 result = runner.invoke(cli, ["stash", "pop"])
416 assert result.exit_code == 0
417 assert (repo / "lead.mid").exists()