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