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