gabriel / muse public
test_cli_workflow.py python
425 lines 17.4 KB
1f065285 fix: guard all apply_manifest callers against dirty working tree Gabriel Cardona <gabriel@tellurstori.com> 20h ago
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
8 cli = None # argparse migration — CliRunner ignores this arg
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_force(self, repo: pathlib.Path) -> None:
271 """Force-delete an unmerged branch with -D."""
272 runner.invoke(cli, ["branch", "feature/x"])
273 result = runner.invoke(cli, ["branch", "-D", "feature/x"])
274 assert result.exit_code == 0
275 result = runner.invoke(cli, ["branch"])
276 assert "feature/x" not in result.output
277
278 def test_delete_branch_safe_blocks_unmerged(self, repo: pathlib.Path) -> None:
279 """Safe delete (-d) must reject a branch that has not been merged."""
280 runner.invoke(cli, ["branch", "feature/unmerged"])
281 result = runner.invoke(cli, ["branch", "-d", "feature/unmerged"])
282 assert result.exit_code != 0
283 assert "not fully merged" in result.output
284
285
286 class TestCheckout:
287 def test_create_and_switch(self, repo: pathlib.Path) -> None:
288 result = runner.invoke(cli, ["checkout", "-b", "feature/chorus"])
289 assert result.exit_code == 0
290 assert "feature/chorus" in result.output
291 status = runner.invoke(cli, ["status"])
292 assert "feature/chorus" in status.output
293
294 def test_switch_existing_branch(self, repo: pathlib.Path) -> None:
295 runner.invoke(cli, ["checkout", "-b", "feature/chorus"])
296 runner.invoke(cli, ["checkout", "main"])
297 result = runner.invoke(cli, ["status"])
298 assert "main" in result.output
299
300 def test_already_on_branch(self, repo: pathlib.Path) -> None:
301 result = runner.invoke(cli, ["checkout", "main"])
302 assert result.exit_code == 0
303 assert "Already on" in result.output
304
305
306 class TestMerge:
307 def test_fast_forward(self, repo: pathlib.Path) -> None:
308 _write(repo, "verse.mid")
309 runner.invoke(cli, ["commit", "-m", "Verse"])
310 runner.invoke(cli, ["checkout", "-b", "feature/chorus"])
311 _write(repo, "chorus.mid")
312 runner.invoke(cli, ["commit", "-m", "Add chorus"])
313 runner.invoke(cli, ["checkout", "main"])
314 result = runner.invoke(cli, ["merge", "feature/chorus"])
315 assert result.exit_code == 0
316 assert "Fast-forward" in result.output
317
318 def test_clean_three_way_merge(self, repo: pathlib.Path) -> None:
319 _write(repo, "base.mid")
320 runner.invoke(cli, ["commit", "-m", "Base"])
321 runner.invoke(cli, ["checkout", "-b", "branch-a"])
322 _write(repo, "a.mid")
323 runner.invoke(cli, ["commit", "-m", "Add A"])
324 runner.invoke(cli, ["checkout", "main"])
325 runner.invoke(cli, ["checkout", "-b", "branch-b"])
326 _write(repo, "b.mid")
327 runner.invoke(cli, ["commit", "-m", "Add B"])
328 runner.invoke(cli, ["checkout", "main"])
329 result = runner.invoke(cli, ["merge", "branch-a"])
330 assert result.exit_code == 0
331
332 def test_cannot_merge_self(self, repo: pathlib.Path) -> None:
333 result = runner.invoke(cli, ["merge", "main"])
334 assert result.exit_code != 0
335
336
337 class TestDiff:
338 def test_no_diff_clean(self, repo: pathlib.Path) -> None:
339 _write(repo, "beat.mid")
340 runner.invoke(cli, ["commit", "-m", "First"])
341 result = runner.invoke(cli, ["diff"])
342 assert result.exit_code == 0
343 assert "No differences" in result.output
344
345 def test_shows_new_file(self, repo: pathlib.Path) -> None:
346 _write(repo, "beat.mid")
347 runner.invoke(cli, ["commit", "-m", "First"])
348 _write(repo, "lead.mid")
349 result = runner.invoke(cli, ["diff"])
350 assert result.exit_code == 0
351 assert "lead.mid" in result.output
352
353
354 class TestTag:
355 def test_add_and_list(self, repo: pathlib.Path) -> None:
356 _write(repo, "beat.mid")
357 runner.invoke(cli, ["commit", "-m", "Tagged take"])
358 result = runner.invoke(cli, ["tag", "add", "emotion:joyful"])
359 assert result.exit_code == 0
360 result = runner.invoke(cli, ["tag", "list"])
361 assert "emotion:joyful" in result.output
362
363
364 class TestDiffWorkingTreeSymbols:
365 """Regression: muse diff must show semantic symbols for uncommitted files.
366
367 Before the fix, diff fell back to a plain ``A file.md`` when the blob
368 wasn't in the object store (only written on commit). After the fix, it
369 reads directly from disk (hash-verified) and extracts symbols via the
370 appropriate adapter.
371 """
372
373 def test_new_markdown_file_shows_sections(self, repo: pathlib.Path) -> None:
374 _write(repo, "first.py", "def setup(): pass")
375 runner.invoke(cli, ["commit", "-m", "init"])
376 _write(repo, "README.md", "# Overview\n\n## Installation\n\n## Usage\n")
377 result = runner.invoke(cli, ["diff"])
378 assert result.exit_code == 0
379 # Symbol-level output must list the heading sections.
380 assert "Overview" in result.output
381 assert "Installation" in result.output
382
383 def test_new_markdown_file_shows_A_prefix(self, repo: pathlib.Path) -> None:
384 _write(repo, "first.py", "def setup(): pass")
385 runner.invoke(cli, ["commit", "-m", "init"])
386 _write(repo, "README.md", "# Title\n\n## Intro\n")
387 result = runner.invoke(cli, ["diff"])
388 assert result.exit_code == 0
389 # The PatchOp for a newly-added file must use 'A' not 'M'.
390 lines = result.output.splitlines()
391 readme_line = next((l for l in lines if "README.md" in l), None)
392 assert readme_line is not None
393 assert readme_line.startswith("A"), f"Expected 'A README.md', got: {readme_line!r}"
394
395 def test_new_python_file_shows_functions(self, repo: pathlib.Path) -> None:
396 runner.invoke(cli, ["commit", "-m", "empty"])
397 _write(repo, "utils.py", "def add(a, b):\n return a + b\n\ndef sub(a, b):\n return a - b\n")
398 result = runner.invoke(cli, ["diff"])
399 assert result.exit_code == 0
400 assert "add" in result.output
401 assert "sub" in result.output
402
403 def test_modified_file_shows_M_prefix(self, repo: pathlib.Path) -> None:
404 _write(repo, "utils.py", "def foo(): pass\ndef bar(): pass\n")
405 runner.invoke(cli, ["commit", "-m", "First"])
406 _write(repo, "utils.py", "def foo(): pass\ndef bar(): return 1\n")
407 result = runner.invoke(cli, ["diff"])
408 assert result.exit_code == 0
409 lines = result.output.splitlines()
410 utils_line = next((l for l in lines if "utils.py" in l), None)
411 assert utils_line is not None
412 assert utils_line.startswith("M"), f"Expected 'M utils.py', got: {utils_line!r}"
413
414
415 class TestStash:
416 def test_stash_and_pop(self, repo: pathlib.Path) -> None:
417 _write(repo, "beat.mid")
418 runner.invoke(cli, ["commit", "-m", "First"])
419 _write(repo, "lead.mid")
420 result = runner.invoke(cli, ["stash"])
421 assert result.exit_code == 0
422 assert not (repo / "lead.mid").exists()
423 result = runner.invoke(cli, ["stash", "pop"])
424 assert result.exit_code == 0
425 assert (repo / "lead.mid").exists()