gabriel / muse public
test_cli_workflow.py python
428 lines 17.6 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days 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 from muse.core._types import load_json_file
155 repo_id = load_json_file(repo / ".muse" / "repo.json")["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 runner.invoke(cli, ["code", "add", "beat.mid"])
179 result = runner.invoke(cli, ["status", "--short"])
180 assert result.exit_code == 0
181 assert "A " in result.output
182
183 def test_json_flag(self, repo: pathlib.Path) -> None:
184 _write(repo, "beat.mid")
185 result = runner.invoke(cli, ["status", "--json"])
186 assert result.exit_code == 0
187 import json as _json
188 d = _json.loads(result.output)
189 assert d["branch"] == "main"
190
191
192 class TestLog:
193 def test_empty_log(self, repo: pathlib.Path) -> None:
194 result = runner.invoke(cli, ["log"])
195 assert result.exit_code == 0
196 assert "no commits" in result.output
197
198 def test_shows_commit(self, repo: pathlib.Path) -> None:
199 _write(repo, "beat.mid")
200 runner.invoke(cli, ["commit", "-m", "First take"])
201 result = runner.invoke(cli, ["log"])
202 assert result.exit_code == 0
203 assert "First take" in result.output
204
205 def test_oneline(self, repo: pathlib.Path) -> None:
206 _write(repo, "beat.mid")
207 runner.invoke(cli, ["commit", "-m", "First take"])
208 result = runner.invoke(cli, ["log", "--oneline"])
209 assert result.exit_code == 0
210 assert "First take" in result.output
211 assert "Author:" not in result.output
212
213 def test_multiple_commits_newest_first(self, repo: pathlib.Path) -> None:
214 _write(repo, "a.mid")
215 runner.invoke(cli, ["commit", "-m", "First"])
216 _write(repo, "b.mid")
217 runner.invoke(cli, ["commit", "-m", "Second"])
218 result = runner.invoke(cli, ["log", "--oneline"])
219 lines = [l for l in result.output.strip().splitlines() if l.strip()]
220 assert "Second" in lines[0]
221 assert "First" in lines[1]
222
223 def test_max_count_limits_output(self, repo: pathlib.Path) -> None:
224 """muse log -n 2 returns only the two most recent commits from a longer chain."""
225 for i in range(1, 6):
226 _write(repo, f"track{i}.mid")
227 runner.invoke(cli, ["commit", "-m", f"Commit {i}"])
228
229 result = runner.invoke(cli, ["log", "--oneline", "--limit", "2"])
230 assert result.exit_code == 0
231 lines = [l for l in result.output.strip().splitlines() if l.strip()]
232 assert len(lines) == 2
233 assert "Commit 5" in lines[0]
234 assert "Commit 4" in lines[1]
235
236 def test_max_count_one_returns_single_commit(self, repo: pathlib.Path) -> None:
237 """muse log -n 1 returns exactly the HEAD commit."""
238 for i in range(1, 4):
239 _write(repo, f"t{i}.mid")
240 runner.invoke(cli, ["commit", "-m", f"Take {i}"])
241
242 result = runner.invoke(cli, ["log", "--oneline", "--limit", "1"])
243 assert result.exit_code == 0
244 lines = [l for l in result.output.strip().splitlines() if l.strip()]
245 assert len(lines) == 1
246 assert "Take 3" in lines[0]
247
248 def test_max_count_larger_than_history_returns_all(self, repo: pathlib.Path) -> None:
249 """muse log -n 100 on a 3-commit repo returns all 3 without error."""
250 for i in range(1, 4):
251 _write(repo, f"f{i}.mid")
252 runner.invoke(cli, ["commit", "-m", f"Track {i}"])
253
254 result = runner.invoke(cli, ["log", "--oneline", "--limit", "100"])
255 assert result.exit_code == 0
256 lines = [l for l in result.output.strip().splitlines() if l.strip()]
257 assert len(lines) == 3
258
259
260 class TestBranch:
261 def test_list_shows_main(self, repo: pathlib.Path) -> None:
262 result = runner.invoke(cli, ["branch"])
263 assert result.exit_code == 0
264 assert "main" in result.output
265 assert "* " in result.output
266
267 def test_create_branch(self, repo: pathlib.Path) -> None:
268 result = runner.invoke(cli, ["branch", "feature/chorus"])
269 assert result.exit_code == 0
270 result = runner.invoke(cli, ["branch"])
271 assert "feature/chorus" in result.output
272
273 def test_delete_branch_force(self, repo: pathlib.Path) -> None:
274 """Force-delete an unmerged branch with -D."""
275 runner.invoke(cli, ["branch", "feature/x"])
276 result = runner.invoke(cli, ["branch", "-D", "feature/x"])
277 assert result.exit_code == 0
278 result = runner.invoke(cli, ["branch"])
279 assert "feature/x" not in result.output
280
281 def test_delete_branch_safe_blocks_unmerged(self, repo: pathlib.Path) -> None:
282 """Safe delete (-d) must reject a branch that has not been merged."""
283 runner.invoke(cli, ["branch", "feature/unmerged"])
284 result = runner.invoke(cli, ["branch", "-d", "feature/unmerged"])
285 assert result.exit_code != 0
286 assert "not fully merged" in result.output
287
288
289 class TestCheckout:
290 def test_create_and_switch(self, repo: pathlib.Path) -> None:
291 result = runner.invoke(cli, ["checkout", "-b", "feature/chorus"])
292 assert result.exit_code == 0
293 assert "feature/chorus" in result.output
294 status = runner.invoke(cli, ["status"])
295 assert "feature/chorus" in status.output
296
297 def test_switch_existing_branch(self, repo: pathlib.Path) -> None:
298 runner.invoke(cli, ["checkout", "-b", "feature/chorus"])
299 runner.invoke(cli, ["checkout", "main"])
300 result = runner.invoke(cli, ["status"])
301 assert "main" in result.output
302
303 def test_already_on_branch(self, repo: pathlib.Path) -> None:
304 result = runner.invoke(cli, ["checkout", "main"])
305 assert result.exit_code == 0
306 assert "Already on" in result.output
307
308
309 class TestMerge:
310 def test_fast_forward(self, repo: pathlib.Path) -> None:
311 _write(repo, "verse.mid")
312 runner.invoke(cli, ["commit", "-m", "Verse"])
313 runner.invoke(cli, ["checkout", "-b", "feature/chorus"])
314 _write(repo, "chorus.mid")
315 runner.invoke(cli, ["commit", "-m", "Add chorus"])
316 runner.invoke(cli, ["checkout", "main"])
317 result = runner.invoke(cli, ["merge", "feature/chorus"])
318 assert result.exit_code == 0
319 assert "Fast-forward" in result.output
320
321 def test_clean_three_way_merge(self, repo: pathlib.Path) -> None:
322 _write(repo, "base.mid")
323 runner.invoke(cli, ["commit", "-m", "Base"])
324 runner.invoke(cli, ["checkout", "-b", "branch-a"])
325 _write(repo, "a.mid")
326 runner.invoke(cli, ["commit", "-m", "Add A"])
327 runner.invoke(cli, ["checkout", "main"])
328 runner.invoke(cli, ["checkout", "-b", "branch-b"])
329 _write(repo, "b.mid")
330 runner.invoke(cli, ["commit", "-m", "Add B"])
331 runner.invoke(cli, ["checkout", "main"])
332 result = runner.invoke(cli, ["merge", "branch-a"])
333 assert result.exit_code == 0
334
335 def test_cannot_merge_self(self, repo: pathlib.Path) -> None:
336 result = runner.invoke(cli, ["merge", "main"])
337 assert result.exit_code != 0
338
339
340 class TestDiff:
341 def test_no_diff_clean(self, repo: pathlib.Path) -> None:
342 _write(repo, "beat.mid")
343 runner.invoke(cli, ["commit", "-m", "First"])
344 result = runner.invoke(cli, ["diff"])
345 assert result.exit_code == 0
346 assert "No differences" in result.output
347
348 def test_shows_new_file(self, repo: pathlib.Path) -> None:
349 _write(repo, "beat.mid")
350 runner.invoke(cli, ["commit", "-m", "First"])
351 _write(repo, "lead.mid")
352 result = runner.invoke(cli, ["diff"])
353 assert result.exit_code == 0
354 assert "lead.mid" in result.output
355
356
357 class TestTag:
358 def test_add_and_list(self, repo: pathlib.Path) -> None:
359 _write(repo, "beat.mid")
360 runner.invoke(cli, ["commit", "-m", "Tagged take"])
361 result = runner.invoke(cli, ["tag", "add", "emotion:joyful"])
362 assert result.exit_code == 0
363 result = runner.invoke(cli, ["tag", "list"])
364 assert "emotion:joyful" in result.output
365
366
367 class TestDiffWorkingTreeSymbols:
368 """Regression: muse diff must show semantic symbols for uncommitted files.
369
370 Before the fix, diff fell back to a plain ``A file.md`` when the blob
371 wasn't in the object store (only written on commit). After the fix, it
372 reads directly from disk (hash-verified) and extracts symbols via the
373 appropriate adapter.
374 """
375
376 def test_new_markdown_file_shows_sections(self, repo: pathlib.Path) -> None:
377 _write(repo, "first.py", "def setup(): pass")
378 runner.invoke(cli, ["commit", "-m", "init"])
379 _write(repo, "README.md", "# Overview\n\n## Installation\n\n## Usage\n")
380 result = runner.invoke(cli, ["diff"])
381 assert result.exit_code == 0
382 # Symbol-level output must list the heading sections.
383 assert "Overview" in result.output
384 assert "Installation" in result.output
385
386 def test_new_markdown_file_shows_A_prefix(self, repo: pathlib.Path) -> None:
387 _write(repo, "first.py", "def setup(): pass")
388 runner.invoke(cli, ["commit", "-m", "init"])
389 _write(repo, "README.md", "# Title\n\n## Intro\n")
390 result = runner.invoke(cli, ["diff"])
391 assert result.exit_code == 0
392 # The PatchOp for a newly-added file must use 'A' not 'M'.
393 lines = result.output.splitlines()
394 readme_line = next((l for l in lines if "README.md" in l), None)
395 assert readme_line is not None
396 assert readme_line.startswith("A"), f"Expected 'A README.md', got: {readme_line!r}"
397
398 def test_new_python_file_shows_functions(self, repo: pathlib.Path) -> None:
399 runner.invoke(cli, ["commit", "-m", "empty"])
400 _write(repo, "utils.py", "def add(a, b):\n return a + b\n\ndef sub(a, b):\n return a - b\n")
401 result = runner.invoke(cli, ["diff"])
402 assert result.exit_code == 0
403 assert "add" in result.output
404 assert "sub" in result.output
405
406 def test_modified_file_shows_M_prefix(self, repo: pathlib.Path) -> None:
407 _write(repo, "utils.py", "def foo(): pass\ndef bar(): pass\n")
408 runner.invoke(cli, ["commit", "-m", "First"])
409 _write(repo, "utils.py", "def foo(): pass\ndef bar(): return 1\n")
410 result = runner.invoke(cli, ["diff"])
411 assert result.exit_code == 0
412 lines = result.output.splitlines()
413 utils_line = next((l for l in lines if "utils.py" in l), None)
414 assert utils_line is not None
415 assert utils_line.startswith("M"), f"Expected 'M utils.py', got: {utils_line!r}"
416
417
418 class TestShelf:
419 def test_shelf_save_and_pop(self, repo: pathlib.Path) -> None:
420 _write(repo, "beat.mid")
421 runner.invoke(cli, ["commit", "-m", "First"])
422 _write(repo, "lead.mid")
423 result = runner.invoke(cli, ["shelf", "save"])
424 assert result.exit_code == 0
425 assert not (repo / "lead.mid").exists()
426 result = runner.invoke(cli, ["shelf", "pop"])
427 assert result.exit_code == 0
428 assert (repo / "lead.mid").exists()
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago