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