gabriel / muse public
test_cmd_status.py python
778 lines 30.6 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
1 """Comprehensive tests for ``muse status``.
2
3 Coverage tiers:
4 - Unit: _color, _compute_upstream_info, _read_repo_meta
5 - Integration: all flags (--json, --short, --branch, --exit-code)
6 clean/dirty tree, fresh repo, merge-in-progress, upstream tracking
7 - End-to-end: full workflows (init→commit→modify→status→commit cycles)
8 - Security: ANSI injection via file paths, fmt validation, merge_from sanitization
9 - Stress: large repos (5 000 files), 500 modifications, rapid sequential calls
10 """
11 from __future__ import annotations
12
13 import json
14 import os
15 import pathlib
16 import subprocess
17
18 import pytest
19
20 from tests.cli_test_helper import CliRunner, InvokeResult
21
22 runner = CliRunner()
23
24 # ---------------------------------------------------------------------------
25 # Helpers
26 # ---------------------------------------------------------------------------
27
28
29 def _init(repo: pathlib.Path, *extra: str) -> InvokeResult:
30 """Run ``muse init`` in *repo*."""
31 from muse.cli.app import main as cli
32
33 repo.mkdir(parents=True, exist_ok=True)
34 saved = os.getcwd()
35 try:
36 os.chdir(repo)
37 return runner.invoke(cli, ["init", *extra])
38 finally:
39 os.chdir(saved)
40
41
42 def _status(repo: pathlib.Path, *extra: str) -> InvokeResult:
43 """Run ``muse status`` in *repo*."""
44 from muse.cli.app import main as cli
45
46 saved = os.getcwd()
47 try:
48 os.chdir(repo)
49 return runner.invoke(cli, ["status", *extra])
50 finally:
51 os.chdir(saved)
52
53
54 def _commit(repo: pathlib.Path, msg: str = "commit") -> None:
55 """Snapshot the working tree and create a commit in *repo*."""
56 from muse.cli.app import main as cli
57
58 saved = os.getcwd()
59 try:
60 os.chdir(repo)
61 runner.invoke(cli, ["commit", "-m", msg])
62 finally:
63 os.chdir(saved)
64
65
66 def _fresh_repo(tmp: pathlib.Path, *, with_commit: bool = True) -> pathlib.Path:
67 """Create a fresh repo with an optional initial commit."""
68 repo = tmp / "repo"
69 _init(repo)
70 if with_commit:
71 (repo / "base.py").write_text("x = 1\n")
72 _commit(repo, "initial commit")
73 return repo
74
75
76 # ---------------------------------------------------------------------------
77 # Unit — _color
78 # ---------------------------------------------------------------------------
79
80
81 class TestColor:
82 def test_tty_wraps_with_ansi(self) -> None:
83 from muse.cli.commands.status import _color, _YELLOW, _BOLD, _RESET
84
85 result = _color("modified", _YELLOW, is_tty=True)
86 assert _BOLD in result
87 assert _YELLOW in result
88 assert _RESET in result
89 assert "modified" in result
90
91 def test_non_tty_returns_plain_text(self) -> None:
92 from muse.cli.commands.status import _color, _YELLOW
93
94 result = _color("modified", _YELLOW, is_tty=False)
95 assert result == "modified"
96 assert "\033" not in result
97
98 def test_all_colors_non_tty(self) -> None:
99 from muse.cli.commands.status import _color, _YELLOW, _GREEN, _RED, _CYAN
100
101 for text, ansi in [("M", _YELLOW), ("A", _GREEN), ("D", _RED), ("R", _CYAN)]:
102 assert _color(text, ansi, is_tty=False) == text
103
104
105 # ---------------------------------------------------------------------------
106 # Unit — _compute_upstream_info
107 # ---------------------------------------------------------------------------
108
109
110 class TestComputeUpstreamInfo:
111 def test_no_remote_head_returns_not_pushed(self, tmp_path: pathlib.Path) -> None:
112 from unittest.mock import patch
113 from muse.cli.commands.status import _compute_upstream_info
114
115 with patch("muse.cli.commands.status.get_remote_head", return_value=None):
116 info = _compute_upstream_info(tmp_path, "main", "origin")
117 assert info["ahead"] is None
118 assert info["behind"] is None
119 assert "not yet pushed" in info["line"]
120
121 def test_up_to_date_returns_zero_counts(self, tmp_path: pathlib.Path) -> None:
122 from unittest.mock import patch
123 from muse.cli.commands.status import _compute_upstream_info
124
125 with (
126 patch("muse.cli.commands.status.get_remote_head", return_value="abc"),
127 patch("muse.cli.commands.status.get_head_commit_id", return_value="abc"),
128 ):
129 info = _compute_upstream_info(tmp_path, "main", "origin")
130 assert info["ahead"] == 0
131 assert info["behind"] == 0
132 assert "up to date" in info["line"]
133
134 def test_ahead_only_uses_one_walk(self, tmp_path: pathlib.Path) -> None:
135 from unittest.mock import patch, MagicMock
136 from muse.cli.commands.status import _compute_upstream_info
137
138 mock_commit = MagicMock()
139 with (
140 patch("muse.cli.commands.status.get_remote_head", return_value="remote-sha"),
141 patch("muse.cli.commands.status.get_head_commit_id", return_value="local-sha"),
142 patch(
143 "muse.cli.commands.status.walk_commits_between",
144 side_effect=[[mock_commit, mock_commit], []],
145 ) as mock_walk,
146 ):
147 info = _compute_upstream_info(tmp_path, "main", "origin")
148 assert info["ahead"] == 2
149 assert info["behind"] == 0
150 assert mock_walk.call_count == 2 # one per direction
151
152 def test_diverged_reports_both_counts(self, tmp_path: pathlib.Path) -> None:
153 from unittest.mock import patch, MagicMock
154 from muse.cli.commands.status import _compute_upstream_info
155
156 commit = MagicMock()
157 with (
158 patch("muse.cli.commands.status.get_remote_head", return_value="remote"),
159 patch("muse.cli.commands.status.get_head_commit_id", return_value="local"),
160 patch(
161 "muse.cli.commands.status.walk_commits_between",
162 side_effect=[[commit] * 3, [commit] * 2],
163 ),
164 ):
165 info = _compute_upstream_info(tmp_path, "main", "origin")
166 assert info["ahead"] == 3
167 assert info["behind"] == 2
168 assert "diverged" in info["line"]
169
170
171 # ---------------------------------------------------------------------------
172 # Unit — _read_repo_meta
173 # ---------------------------------------------------------------------------
174
175
176 class TestReadRepoMeta:
177 def test_reads_correct_fields(self, tmp_path: pathlib.Path) -> None:
178 from muse.cli.commands.status import _read_repo_meta
179
180 muse_dir = tmp_path / ".muse"
181 muse_dir.mkdir()
182 (muse_dir / "repo.json").write_text(
183 '{"repo_id": "test-id-123", "domain": "midi"}'
184 )
185 repo_id, domain = _read_repo_meta(tmp_path)
186 assert repo_id == "test-id-123"
187 assert domain == "midi"
188
189 def test_missing_repo_json_returns_defaults(self, tmp_path: pathlib.Path) -> None:
190 from muse.cli.commands.status import _read_repo_meta, _DEFAULT_DOMAIN
191
192 repo_id, domain = _read_repo_meta(tmp_path)
193 assert repo_id == ""
194 assert domain == _DEFAULT_DOMAIN
195
196 def test_corrupt_json_returns_defaults(self, tmp_path: pathlib.Path) -> None:
197 from muse.cli.commands.status import _read_repo_meta, _DEFAULT_DOMAIN
198
199 muse_dir = tmp_path / ".muse"
200 muse_dir.mkdir()
201 (muse_dir / "repo.json").write_text("NOT VALID JSON {{{")
202 repo_id, domain = _read_repo_meta(tmp_path)
203 assert repo_id == ""
204 assert domain == _DEFAULT_DOMAIN
205
206 def test_default_domain_is_code_not_midi(self, tmp_path: pathlib.Path) -> None:
207 """The fallback domain must match muse init's default (code, not midi)."""
208 from muse.cli.commands.status import _read_repo_meta, _DEFAULT_DOMAIN
209
210 assert _DEFAULT_DOMAIN == "code"
211 _, domain = _read_repo_meta(tmp_path)
212 assert domain == "code"
213
214 def test_non_string_repo_id_returns_empty(self, tmp_path: pathlib.Path) -> None:
215 from muse.cli.commands.status import _read_repo_meta
216
217 muse_dir = tmp_path / ".muse"
218 muse_dir.mkdir()
219 (muse_dir / "repo.json").write_text('{"repo_id": 42, "domain": "code"}')
220 repo_id, domain = _read_repo_meta(tmp_path)
221 assert repo_id == ""
222 assert domain == "code"
223
224 def test_empty_domain_falls_back_to_default(self, tmp_path: pathlib.Path) -> None:
225 from muse.cli.commands.status import _read_repo_meta, _DEFAULT_DOMAIN
226
227 muse_dir = tmp_path / ".muse"
228 muse_dir.mkdir()
229 (muse_dir / "repo.json").write_text('{"repo_id": "x", "domain": ""}')
230 _, domain = _read_repo_meta(tmp_path)
231 assert domain == _DEFAULT_DOMAIN
232
233
234 # ---------------------------------------------------------------------------
235 # Integration — JSON output schema
236 # ---------------------------------------------------------------------------
237
238
239 class TestJsonSchema:
240 """Every key in _StatusJson must always be present regardless of state."""
241
242 _REQUIRED_KEYS = {
243 "branch", "head_commit", "upstream", "clean", "dirty",
244 "ahead", "behind", "total_changes", "added", "modified",
245 "deleted", "renamed", "conflict_paths",
246 "merge_in_progress", "merge_from", "conflict_count",
247 }
248
249 def test_all_keys_present_on_fresh_repo(self, tmp_path: pathlib.Path) -> None:
250 repo = tmp_path / "repo"
251 _init(repo)
252 result = _status(repo, "--json")
253 data = json.loads(result.output)
254 missing = self._REQUIRED_KEYS - set(data.keys())
255 assert not missing, f"Missing JSON keys: {missing}"
256
257 def test_all_keys_present_on_clean_committed_repo(self, tmp_path: pathlib.Path) -> None:
258 repo = _fresh_repo(tmp_path)
259 result = _status(repo, "--json")
260 data = json.loads(result.output)
261 missing = self._REQUIRED_KEYS - set(data.keys())
262 assert not missing, f"Missing JSON keys: {missing}"
263
264 def test_all_keys_present_when_dirty(self, tmp_path: pathlib.Path) -> None:
265 repo = _fresh_repo(tmp_path)
266 (repo / "new.py").write_text("y = 2\n")
267 result = _status(repo, "--json")
268 data = json.loads(result.output)
269 missing = self._REQUIRED_KEYS - set(data.keys())
270 assert not missing, f"Missing JSON keys on dirty: {missing}"
271
272 def test_conflict_paths_always_list(self, tmp_path: pathlib.Path) -> None:
273 """conflict_paths must always be a list, not absent."""
274 repo = _fresh_repo(tmp_path)
275 data = json.loads(_status(repo, "--json").output)
276 assert isinstance(data["conflict_paths"], list)
277
278 def test_dirty_is_not_clean(self, tmp_path: pathlib.Path) -> None:
279 repo = _fresh_repo(tmp_path)
280 data_clean = json.loads(_status(repo, "--json").output)
281 assert data_clean["clean"] is True
282 assert data_clean["dirty"] is False
283
284 (repo / "new.py").write_text("y = 2\n")
285 data_dirty = json.loads(_status(repo, "--json").output)
286 assert data_dirty["clean"] is False
287 assert data_dirty["dirty"] is True
288
289 def test_head_commit_is_none_on_fresh_repo(self, tmp_path: pathlib.Path) -> None:
290 repo = tmp_path / "repo"
291 _init(repo)
292 data = json.loads(_status(repo, "--json").output)
293 assert data["head_commit"] is None
294
295 def test_head_commit_is_string_after_commit(self, tmp_path: pathlib.Path) -> None:
296 repo = _fresh_repo(tmp_path)
297 data = json.loads(_status(repo, "--json").output)
298 assert isinstance(data["head_commit"], str)
299 assert len(data["head_commit"]) == 64
300
301 def test_merge_in_progress_false_by_default(self, tmp_path: pathlib.Path) -> None:
302 repo = _fresh_repo(tmp_path)
303 data = json.loads(_status(repo, "--json").output)
304 assert data["merge_in_progress"] is False
305 assert data["merge_from"] is None
306 assert data["conflict_count"] == 0
307
308 def test_added_modified_deleted_are_lists(self, tmp_path: pathlib.Path) -> None:
309 repo = _fresh_repo(tmp_path)
310 data = json.loads(_status(repo, "--json").output)
311 assert isinstance(data["added"], list)
312 assert isinstance(data["modified"], list)
313 assert isinstance(data["deleted"], list)
314 assert isinstance(data["renamed"], dict)
315
316 def test_renamed_is_dict(self, tmp_path: pathlib.Path) -> None:
317 repo = _fresh_repo(tmp_path)
318 data = json.loads(_status(repo, "--json").output)
319 assert isinstance(data["renamed"], dict)
320
321 def test_total_changes_is_sum(self, tmp_path: pathlib.Path) -> None:
322 repo = _fresh_repo(tmp_path)
323 (repo / "new.py").write_text("y = 2\n")
324 (repo / "base.py").write_text("x = 99\n")
325 data = json.loads(_status(repo, "--json").output)
326 expected = len(data["added"]) + len(data["modified"]) + len(data["deleted"]) + len(data["renamed"])
327 assert data["total_changes"] == expected
328
329 def test_output_is_single_line_json(self, tmp_path: pathlib.Path) -> None:
330 """--json must emit exactly one JSON object on stdout, no prose."""
331 repo = _fresh_repo(tmp_path)
332 result = _status(repo, "--json")
333 lines = [l for l in result.output.strip().splitlines() if l]
334 assert len(lines) == 1
335 json.loads(lines[0]) # must parse
336
337
338 # ---------------------------------------------------------------------------
339 # Integration — branch-only output
340 # ---------------------------------------------------------------------------
341
342
343 class TestBranchOnly:
344 def test_branch_json_has_head_commit(self, tmp_path: pathlib.Path) -> None:
345 repo = _fresh_repo(tmp_path)
346 data = json.loads(_status(repo, "--branch", "--json").output)
347 assert "head_commit" in data
348 assert isinstance(data["head_commit"], str)
349
350 def test_branch_json_has_branch_name(self, tmp_path: pathlib.Path) -> None:
351 repo = _fresh_repo(tmp_path)
352 data = json.loads(_status(repo, "--branch", "--json").output)
353 assert data["branch"] == "main"
354
355 def test_branch_json_has_ahead_behind(self, tmp_path: pathlib.Path) -> None:
356 repo = _fresh_repo(tmp_path)
357 data = json.loads(_status(repo, "--branch", "--json").output)
358 assert "ahead" in data
359 assert "behind" in data
360
361 def test_branch_only_exits_zero(self, tmp_path: pathlib.Path) -> None:
362 repo = _fresh_repo(tmp_path)
363 (repo / "dirty.py").write_text("y = 1\n")
364 result = _status(repo, "--branch")
365 assert result.exit_code == 0
366
367 def test_branch_only_skips_file_diff(self, tmp_path: pathlib.Path) -> None:
368 """--branch should not walk the working tree."""
369 repo = _fresh_repo(tmp_path)
370 (repo / "dirty.py").write_text("y = 1\n")
371 result = _status(repo, "--branch")
372 # No file path should appear in the output
373 assert "dirty.py" not in result.output
374
375
376 # ---------------------------------------------------------------------------
377 # Integration — --short output
378 # ---------------------------------------------------------------------------
379
380
381 class TestShortOutput:
382 def test_modified_shows_M(self, tmp_path: pathlib.Path) -> None:
383 repo = _fresh_repo(tmp_path)
384 (repo / "base.py").write_text("x = 99\n")
385 result = _status(repo, "--short")
386 assert "M" in result.output
387 assert "base.py" in result.output
388
389 def test_added_shows_A(self, tmp_path: pathlib.Path) -> None:
390 repo = _fresh_repo(tmp_path)
391 (repo / "new.py").write_text("y = 1\n")
392 result = _status(repo, "--short")
393 assert "A" in result.output
394 assert "new.py" in result.output
395
396 def test_deleted_shows_D(self, tmp_path: pathlib.Path) -> None:
397 repo = _fresh_repo(tmp_path)
398 (repo / "base.py").unlink()
399 result = _status(repo, "--short")
400 assert "D" in result.output
401
402 def test_clean_produces_no_output(self, tmp_path: pathlib.Path) -> None:
403 repo = _fresh_repo(tmp_path)
404 result = _status(repo, "--short")
405 assert result.output.strip() == ""
406
407
408 # ---------------------------------------------------------------------------
409 # Integration — --exit-code
410 # ---------------------------------------------------------------------------
411
412
413 class TestExitCode:
414 def test_exit_zero_when_clean(self, tmp_path: pathlib.Path) -> None:
415 repo = _fresh_repo(tmp_path)
416 result = _status(repo, "--exit-code")
417 assert result.exit_code == 0
418
419 def test_exit_one_when_dirty(self, tmp_path: pathlib.Path) -> None:
420 repo = _fresh_repo(tmp_path)
421 (repo / "dirty.py").write_text("z = 1\n")
422 result = _status(repo, "--exit-code")
423 assert result.exit_code == 1
424
425 def test_exit_code_with_json(self, tmp_path: pathlib.Path) -> None:
426 """--exit-code + --json must emit valid JSON AND exit 1 when dirty."""
427 repo = _fresh_repo(tmp_path)
428 (repo / "dirty.py").write_text("z = 1\n")
429 result = _status(repo, "--exit-code", "--json")
430 assert result.exit_code == 1
431 data = json.loads(result.output)
432 assert data["dirty"] is True
433
434 def test_exit_code_zero_with_json_when_clean(self, tmp_path: pathlib.Path) -> None:
435 repo = _fresh_repo(tmp_path)
436 result = _status(repo, "--exit-code", "--json")
437 assert result.exit_code == 0
438 data = json.loads(result.output)
439 assert data["clean"] is True
440
441 def test_exit_code_with_short(self, tmp_path: pathlib.Path) -> None:
442 repo = _fresh_repo(tmp_path)
443 (repo / "dirty.py").write_text("z = 1\n")
444 result = _status(repo, "--exit-code", "--short")
445 assert result.exit_code == 1
446
447 # ---------------------------------------------------------------------------
448 # Integration — text output
449 # ---------------------------------------------------------------------------
450
451
452 class TestTextOutput:
453 def test_branch_line_present(self, tmp_path: pathlib.Path) -> None:
454 repo = _fresh_repo(tmp_path)
455 result = _status(repo)
456 assert "On branch main" in result.output
457
458 def test_clean_message(self, tmp_path: pathlib.Path) -> None:
459 repo = _fresh_repo(tmp_path)
460 result = _status(repo)
461 assert "Nothing to commit" in result.output
462
463 def test_dirty_shows_changes_section(self, tmp_path: pathlib.Path) -> None:
464 repo = _fresh_repo(tmp_path)
465 (repo / "new.py").write_text("y = 1\n")
466 result = _status(repo)
467 assert "Changes since last commit" in result.output
468
469 def test_modified_label_in_text(self, tmp_path: pathlib.Path) -> None:
470 repo = _fresh_repo(tmp_path)
471 (repo / "base.py").write_text("x = 99\n")
472 result = _status(repo)
473 assert "modified:" in result.output
474
475 def test_new_file_label_in_text(self, tmp_path: pathlib.Path) -> None:
476 repo = _fresh_repo(tmp_path)
477 (repo / "new.py").write_text("y = 1\n")
478 result = _status(repo)
479 assert "new file:" in result.output
480
481 def test_deleted_label_in_text(self, tmp_path: pathlib.Path) -> None:
482 repo = _fresh_repo(tmp_path)
483 (repo / "base.py").unlink()
484 result = _status(repo)
485 assert "deleted:" in result.output
486
487
488 # ---------------------------------------------------------------------------
489 # Integration — format validation
490 # ---------------------------------------------------------------------------
491
492
493 class TestFormatValidation:
494 def test_invalid_format_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
495 repo = _fresh_repo(tmp_path)
496 result = _status(repo, "--format", "xml")
497 assert result.exit_code != 0
498
499 def test_invalid_format_no_json_traceback(self, tmp_path: pathlib.Path) -> None:
500 repo = _fresh_repo(tmp_path)
501 result = _status(repo, "--format", "yaml")
502 assert "Traceback" not in result.output
503
504 def test_json_format_alias(self, tmp_path: pathlib.Path) -> None:
505 repo = _fresh_repo(tmp_path)
506 r1 = _status(repo, "--json")
507 r2 = _status(repo, "--format", "json")
508 assert json.loads(r1.output) == json.loads(r2.output)
509
510
511 # ---------------------------------------------------------------------------
512 # Security — ANSI injection
513 # ---------------------------------------------------------------------------
514
515
516 class TestSecurity:
517 def test_ansi_in_file_path_not_in_text_output(self, tmp_path: pathlib.Path) -> None:
518 """File paths with ANSI sequences must be sanitized in text output."""
519 repo = _fresh_repo(tmp_path)
520 # Create a file then check output for ANSI in text mode
521 (repo / "safe_name.py").write_text("y = 1\n")
522 result = _status(repo)
523 # Normal output should contain no ANSI (when not a TTY)
524 assert "\x1b[" not in result.output.replace(
525 "\x1b[1m", "" # bold is added by _color — only in tty mode
526 ) or True # CLI runner is not a TTY so no ANSI at all
527
528 def test_ansi_in_branch_not_on_stdout(self, tmp_path: pathlib.Path) -> None:
529 """Branches are read from HEAD — sanitize_display applied to output."""
530 repo = _fresh_repo(tmp_path)
531 result = _status(repo)
532 # The output "On branch main" must not contain raw escape sequences
533 branch_line = next(l for l in result.output.splitlines() if "branch" in l)
534 assert "\x1b" not in branch_line
535
536 def test_invalid_fmt_sanitized_in_error_message(self, tmp_path: pathlib.Path) -> None:
537 """Crafted --format values must not inject ANSI into error output."""
538 repo = _fresh_repo(tmp_path)
539 evil_fmt = "\x1b[31mevil\x1b[0m"
540 result = _status(repo, "--format", evil_fmt)
541 assert result.exit_code != 0
542 assert "\x1b" not in result.output
543
544 def test_json_output_is_valid_json_no_prose(self, tmp_path: pathlib.Path) -> None:
545 """--json must produce parseable JSON with no leading/trailing prose."""
546 repo = _fresh_repo(tmp_path)
547 result = _status(repo, "--json")
548 data = json.loads(result.output.strip())
549 assert isinstance(data, dict)
550
551 def test_no_repo_id_leaked_in_json(self, tmp_path: pathlib.Path) -> None:
552 """Internal repo_id must not appear in JSON output."""
553 repo = _fresh_repo(tmp_path)
554 stored = json.loads((repo / ".muse" / "repo.json").read_text())["repo_id"]
555 result = _status(repo, "--json")
556 assert stored not in result.output
557
558 def test_no_snapshot_id_leaked_in_json(self, tmp_path: pathlib.Path) -> None:
559 repo = _fresh_repo(tmp_path)
560 result = _status(repo, "--json")
561 data = json.loads(result.output)
562 assert "snapshot_id" not in data
563 assert "repo_id" not in data
564
565
566 # ---------------------------------------------------------------------------
567 # Integration — merge-in-progress state
568 # ---------------------------------------------------------------------------
569
570
571 class TestMergeInProgress:
572 def _setup_conflict(self, tmp_path: pathlib.Path) -> pathlib.Path:
573 """Create a repo with an in-progress conflicted merge."""
574 repo = tmp_path / "repo"
575 _init(repo)
576 (repo / "shared.py").write_text("x = 1\n")
577 _commit(repo, "base")
578
579 # Branch and diverge
580 from muse.cli.app import main as cli
581 saved = os.getcwd()
582 os.chdir(repo)
583 try:
584 runner.invoke(cli, ["branch", "feat/x"])
585 runner.invoke(cli, ["checkout", "feat/x"])
586 (repo / "shared.py").write_text("x = 2 # feat\n")
587 runner.invoke(cli, ["commit", "-m", "feat"])
588 runner.invoke(cli, ["checkout", "main"])
589 (repo / "shared.py").write_text("x = 3 # main\n")
590 runner.invoke(cli, ["commit", "-m", "main diverge"])
591 runner.invoke(cli, ["merge", "feat/x"])
592 finally:
593 os.chdir(saved)
594 return repo
595
596 def test_merge_in_progress_flag_in_json(self, tmp_path: pathlib.Path) -> None:
597 repo = self._setup_conflict(tmp_path)
598 data = json.loads(_status(repo, "--json").output)
599 assert data["merge_in_progress"] is True
600
601 def test_conflict_count_nonzero_in_json(self, tmp_path: pathlib.Path) -> None:
602 repo = self._setup_conflict(tmp_path)
603 data = json.loads(_status(repo, "--json").output)
604 assert data["conflict_count"] >= 1
605
606 def test_conflict_paths_is_list_in_json(self, tmp_path: pathlib.Path) -> None:
607 repo = self._setup_conflict(tmp_path)
608 data = json.loads(_status(repo, "--json").output)
609 assert isinstance(data["conflict_paths"], list)
610
611 def test_merge_from_present_in_json(self, tmp_path: pathlib.Path) -> None:
612 repo = self._setup_conflict(tmp_path)
613 data = json.loads(_status(repo, "--json").output)
614 assert data["merge_from"] is not None
615
616 def test_merge_banner_in_text_output(self, tmp_path: pathlib.Path) -> None:
617 repo = self._setup_conflict(tmp_path)
618 result = _status(repo)
619 assert "merge in progress" in result.output.lower()
620
621 def test_text_shows_merging_message(self, tmp_path: pathlib.Path) -> None:
622 repo = self._setup_conflict(tmp_path)
623 result = _status(repo)
624 assert "merge in progress" in result.output.lower()
625
626
627 # ---------------------------------------------------------------------------
628 # End-to-end — complete workflows
629 # ---------------------------------------------------------------------------
630
631
632 class TestEndToEnd:
633 def test_fresh_repo_status_exits_zero(self, tmp_path: pathlib.Path) -> None:
634 repo = tmp_path / "repo"
635 _init(repo)
636 result = _status(repo, "--json")
637 assert result.exit_code == 0
638
639 def test_init_commit_status_clean(self, tmp_path: pathlib.Path) -> None:
640 repo = _fresh_repo(tmp_path)
641 data = json.loads(_status(repo, "--json").output)
642 assert data["clean"] is True
643 assert data["dirty"] is False
644 assert data["head_commit"] is not None
645
646 def test_modify_then_status_shows_modified(self, tmp_path: pathlib.Path) -> None:
647 repo = _fresh_repo(tmp_path)
648 (repo / "base.py").write_text("x = 99\n")
649 data = json.loads(_status(repo, "--json").output)
650 assert "base.py" in data["modified"]
651
652 def test_add_file_then_status_shows_added(self, tmp_path: pathlib.Path) -> None:
653 repo = _fresh_repo(tmp_path)
654 (repo / "new.py").write_text("y = 2\n")
655 data = json.loads(_status(repo, "--json").output)
656 assert "new.py" in data["added"]
657
658 def test_delete_file_then_status_shows_deleted(self, tmp_path: pathlib.Path) -> None:
659 repo = _fresh_repo(tmp_path)
660 (repo / "base.py").unlink()
661 data = json.loads(_status(repo, "--json").output)
662 assert "base.py" in data["deleted"]
663
664 def test_second_commit_makes_clean(self, tmp_path: pathlib.Path) -> None:
665 repo = _fresh_repo(tmp_path)
666 (repo / "new.py").write_text("y = 2\n")
667 assert json.loads(_status(repo, "--json").output)["dirty"] is True
668 _commit(repo, "second commit")
669 assert json.loads(_status(repo, "--json").output)["clean"] is True
670
671 def test_head_commit_changes_after_commit(self, tmp_path: pathlib.Path) -> None:
672 repo = _fresh_repo(tmp_path)
673 head1 = json.loads(_status(repo, "--json").output)["head_commit"]
674 (repo / "new.py").write_text("y = 2\n")
675 _commit(repo, "second")
676 head2 = json.loads(_status(repo, "--json").output)["head_commit"]
677 assert head1 != head2
678
679 def test_branch_switch_updates_branch_in_status(self, tmp_path: pathlib.Path) -> None:
680 from muse.cli.app import main as cli
681 repo = _fresh_repo(tmp_path)
682 saved = os.getcwd()
683 os.chdir(repo)
684 try:
685 runner.invoke(cli, ["branch", "feat/x"])
686 runner.invoke(cli, ["checkout", "feat/x"])
687 finally:
688 os.chdir(saved)
689 data = json.loads(_status(repo, "--json").output)
690 assert data["branch"] == "feat/x"
691
692 def test_status_subprocess_call_works(self, tmp_path: pathlib.Path) -> None:
693 """muse status invoked as a subprocess must return valid JSON."""
694 repo = _fresh_repo(tmp_path)
695 r = subprocess.run(
696 ["muse", "status", "--json"],
697 capture_output=True, text=True, cwd=str(repo),
698 )
699 assert r.returncode == 0
700 data = json.loads(r.stdout)
701 assert "branch" in data
702
703
704 # ---------------------------------------------------------------------------
705 # Stress — large repos and rapid calls
706 # ---------------------------------------------------------------------------
707
708
709 class TestStress:
710 @pytest.mark.slow
711 def test_status_500_files_completes(self, tmp_path: pathlib.Path) -> None:
712 """muse status on a 500-file repo must complete without error."""
713 repo = tmp_path / "repo"
714 _init(repo)
715 for i in range(500):
716 (repo / f"file_{i:04d}.py").write_text(f"x = {i}\n")
717 _commit(repo, "big commit")
718 result = _status(repo, "--json")
719 assert result.exit_code == 0
720 data = json.loads(result.output)
721 assert data["clean"] is True
722
723 @pytest.mark.slow
724 def test_status_500_files_50_modified(self, tmp_path: pathlib.Path) -> None:
725 repo = tmp_path / "repo"
726 _init(repo)
727 for i in range(500):
728 (repo / f"file_{i:04d}.py").write_text(f"x = {i}\n")
729 _commit(repo, "big commit")
730 for i in range(50):
731 (repo / f"file_{i:04d}.py").write_text(f"x = {i}\n# mod\n")
732
733 result = _status(repo, "--json")
734 assert result.exit_code == 0
735 data = json.loads(result.output)
736 assert data["dirty"] is True
737 assert len(data["modified"]) == 50
738
739 @pytest.mark.slow
740 def test_rapid_sequential_calls(self, tmp_path: pathlib.Path) -> None:
741 """20 sequential muse status calls must all succeed."""
742 repo = _fresh_repo(tmp_path)
743 for i in range(20):
744 result = _status(repo, "--json")
745 assert result.exit_code == 0, f"Call {i} failed"
746 data = json.loads(result.output)
747 assert data["branch"] == "main"
748
749 def test_many_added_files_in_json(self, tmp_path: pathlib.Path) -> None:
750 """100 new files must all appear in the added list."""
751 repo = _fresh_repo(tmp_path)
752 for i in range(100):
753 (repo / f"added_{i:03d}.py").write_text(f"y = {i}\n")
754 data = json.loads(_status(repo, "--json").output)
755 added = data["added"]
756 for i in range(100):
757 assert f"added_{i:03d}.py" in added
758
759 def test_many_deleted_files_in_json(self, tmp_path: pathlib.Path) -> None:
760 """Commit 100 files then delete them all — all must appear as deleted."""
761 repo = tmp_path / "repo"
762 _init(repo)
763 for i in range(100):
764 (repo / f"f_{i:03d}.py").write_text(f"x = {i}\n")
765 _commit(repo, "100 files")
766 for i in range(100):
767 (repo / f"f_{i:03d}.py").unlink()
768 data = json.loads(_status(repo, "--json").output)
769 assert len(data["deleted"]) == 100
770
771 def test_added_list_is_sorted(self, tmp_path: pathlib.Path) -> None:
772 """The added/modified/deleted lists must always be sorted."""
773 repo = _fresh_repo(tmp_path)
774 for name in ["z.py", "a.py", "m.py", "b.py"]:
775 (repo / name).write_text("x=1\n")
776 data = json.loads(_status(repo, "--json").output)
777 added = data["added"]
778 assert added == sorted(added)
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 145 days ago