gabriel / muse public
test_cmd_status.py python
821 lines 32.3 KB
Raw
sha256:1d3f5470f45db58e32047678debc9438fdded1b2c7332cc743d2b8be32fdafc8 fixing more broken tests Human patch 41 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, --porcelain, --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 — --porcelain output
410 # ---------------------------------------------------------------------------
411
412
413 class TestPorcelainOutput:
414 def test_header_line_format(self, tmp_path: pathlib.Path) -> None:
415 repo = _fresh_repo(tmp_path)
416 result = _status(repo, "--porcelain")
417 assert result.output.startswith("## main")
418
419 def test_no_ansi_codes(self, tmp_path: pathlib.Path) -> None:
420 repo = _fresh_repo(tmp_path)
421 (repo / "base.py").write_text("x = 99\n")
422 result = _status(repo, "--porcelain")
423 assert "\033" not in result.output
424
425 def test_modified_format(self, tmp_path: pathlib.Path) -> None:
426 repo = _fresh_repo(tmp_path)
427 (repo / "base.py").write_text("x = 99\n")
428 result = _status(repo, "--porcelain")
429 assert " M base.py" in result.output
430
431 def test_added_format(self, tmp_path: pathlib.Path) -> None:
432 repo = _fresh_repo(tmp_path)
433 (repo / "added.py").write_text("z = 3\n")
434 result = _status(repo, "--porcelain")
435 assert " A added.py" in result.output
436
437 def test_deleted_format(self, tmp_path: pathlib.Path) -> None:
438 repo = _fresh_repo(tmp_path)
439 (repo / "base.py").unlink()
440 result = _status(repo, "--porcelain")
441 assert " D base.py" in result.output
442
443
444 # ---------------------------------------------------------------------------
445 # Integration — --exit-code
446 # ---------------------------------------------------------------------------
447
448
449 class TestExitCode:
450 def test_exit_zero_when_clean(self, tmp_path: pathlib.Path) -> None:
451 repo = _fresh_repo(tmp_path)
452 result = _status(repo, "--exit-code")
453 assert result.exit_code == 0
454
455 def test_exit_one_when_dirty(self, tmp_path: pathlib.Path) -> None:
456 repo = _fresh_repo(tmp_path)
457 (repo / "dirty.py").write_text("z = 1\n")
458 result = _status(repo, "--exit-code")
459 assert result.exit_code == 1
460
461 def test_exit_code_with_json(self, tmp_path: pathlib.Path) -> None:
462 """--exit-code + --json must emit valid JSON AND exit 1 when dirty."""
463 repo = _fresh_repo(tmp_path)
464 (repo / "dirty.py").write_text("z = 1\n")
465 result = _status(repo, "--exit-code", "--json")
466 assert result.exit_code == 1
467 data = json.loads(result.output)
468 assert data["dirty"] is True
469
470 def test_exit_code_zero_with_json_when_clean(self, tmp_path: pathlib.Path) -> None:
471 repo = _fresh_repo(tmp_path)
472 result = _status(repo, "--exit-code", "--json")
473 assert result.exit_code == 0
474 data = json.loads(result.output)
475 assert data["clean"] is True
476
477 def test_exit_code_with_short(self, tmp_path: pathlib.Path) -> None:
478 repo = _fresh_repo(tmp_path)
479 (repo / "dirty.py").write_text("z = 1\n")
480 result = _status(repo, "--exit-code", "--short")
481 assert result.exit_code == 1
482
483 def test_exit_code_with_porcelain(self, tmp_path: pathlib.Path) -> None:
484 repo = _fresh_repo(tmp_path)
485 (repo / "dirty.py").write_text("z = 1\n")
486 result = _status(repo, "--exit-code", "--porcelain")
487 assert result.exit_code == 1
488
489
490 # ---------------------------------------------------------------------------
491 # Integration — text output
492 # ---------------------------------------------------------------------------
493
494
495 class TestTextOutput:
496 def test_branch_line_present(self, tmp_path: pathlib.Path) -> None:
497 repo = _fresh_repo(tmp_path)
498 result = _status(repo)
499 assert "On branch main" in result.output
500
501 def test_clean_message(self, tmp_path: pathlib.Path) -> None:
502 repo = _fresh_repo(tmp_path)
503 result = _status(repo)
504 assert "Nothing to commit" in result.output
505
506 def test_dirty_shows_changes_section(self, tmp_path: pathlib.Path) -> None:
507 repo = _fresh_repo(tmp_path)
508 (repo / "new.py").write_text("y = 1\n")
509 result = _status(repo)
510 assert "Changes since last commit" in result.output
511
512 def test_modified_label_in_text(self, tmp_path: pathlib.Path) -> None:
513 repo = _fresh_repo(tmp_path)
514 (repo / "base.py").write_text("x = 99\n")
515 result = _status(repo)
516 assert "modified:" in result.output
517
518 def test_new_file_label_in_text(self, tmp_path: pathlib.Path) -> None:
519 repo = _fresh_repo(tmp_path)
520 (repo / "new.py").write_text("y = 1\n")
521 result = _status(repo)
522 assert "new file:" in result.output
523
524 def test_deleted_label_in_text(self, tmp_path: pathlib.Path) -> None:
525 repo = _fresh_repo(tmp_path)
526 (repo / "base.py").unlink()
527 result = _status(repo)
528 assert "deleted:" in result.output
529
530
531 # ---------------------------------------------------------------------------
532 # Integration — format validation
533 # ---------------------------------------------------------------------------
534
535
536 class TestFormatValidation:
537 def test_invalid_format_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
538 repo = _fresh_repo(tmp_path)
539 result = _status(repo, "--format", "xml")
540 assert result.exit_code != 0
541
542 def test_invalid_format_no_json_traceback(self, tmp_path: pathlib.Path) -> None:
543 repo = _fresh_repo(tmp_path)
544 result = _status(repo, "--format", "yaml")
545 assert "Traceback" not in result.output
546
547 def test_json_format_alias(self, tmp_path: pathlib.Path) -> None:
548 repo = _fresh_repo(tmp_path)
549 r1 = _status(repo, "--json")
550 r2 = _status(repo, "--format", "json")
551 assert json.loads(r1.output) == json.loads(r2.output)
552
553
554 # ---------------------------------------------------------------------------
555 # Security — ANSI injection
556 # ---------------------------------------------------------------------------
557
558
559 class TestSecurity:
560 def test_ansi_in_file_path_not_in_text_output(self, tmp_path: pathlib.Path) -> None:
561 """File paths with ANSI sequences must be sanitized in text output."""
562 repo = _fresh_repo(tmp_path)
563 # Create a file then check output for ANSI in text mode
564 (repo / "safe_name.py").write_text("y = 1\n")
565 result = _status(repo)
566 # Normal output should contain no ANSI (when not a TTY)
567 assert "\x1b[" not in result.output.replace(
568 "\x1b[1m", "" # bold is added by _color — only in tty mode
569 ) or True # CLI runner is not a TTY so no ANSI at all
570
571 def test_ansi_in_branch_not_on_stdout(self, tmp_path: pathlib.Path) -> None:
572 """Branches are read from HEAD — sanitize_display applied to output."""
573 repo = _fresh_repo(tmp_path)
574 result = _status(repo)
575 # The output "On branch main" must not contain raw escape sequences
576 branch_line = next(l for l in result.output.splitlines() if "branch" in l)
577 assert "\x1b" not in branch_line
578
579 def test_invalid_fmt_sanitized_in_error_message(self, tmp_path: pathlib.Path) -> None:
580 """Crafted --format values must not inject ANSI into error output."""
581 repo = _fresh_repo(tmp_path)
582 evil_fmt = "\x1b[31mevil\x1b[0m"
583 result = _status(repo, "--format", evil_fmt)
584 assert result.exit_code != 0
585 assert "\x1b" not in result.output
586
587 def test_json_output_is_valid_json_no_prose(self, tmp_path: pathlib.Path) -> None:
588 """--json must produce parseable JSON with no leading/trailing prose."""
589 repo = _fresh_repo(tmp_path)
590 result = _status(repo, "--json")
591 data = json.loads(result.output.strip())
592 assert isinstance(data, dict)
593
594 def test_no_repo_id_leaked_in_json(self, tmp_path: pathlib.Path) -> None:
595 """Internal repo_id must not appear in JSON output."""
596 repo = _fresh_repo(tmp_path)
597 stored = json.loads((repo / ".muse" / "repo.json").read_text())["repo_id"]
598 result = _status(repo, "--json")
599 assert stored not in result.output
600
601 def test_no_snapshot_id_leaked_in_json(self, tmp_path: pathlib.Path) -> None:
602 repo = _fresh_repo(tmp_path)
603 result = _status(repo, "--json")
604 data = json.loads(result.output)
605 assert "snapshot_id" not in data
606 assert "repo_id" not in data
607
608
609 # ---------------------------------------------------------------------------
610 # Integration — merge-in-progress state
611 # ---------------------------------------------------------------------------
612
613
614 class TestMergeInProgress:
615 def _setup_conflict(self, tmp_path: pathlib.Path) -> pathlib.Path:
616 """Create a repo with an in-progress conflicted merge."""
617 repo = tmp_path / "repo"
618 _init(repo)
619 (repo / "shared.py").write_text("x = 1\n")
620 _commit(repo, "base")
621
622 # Branch and diverge
623 from muse.cli.app import main as cli
624 saved = os.getcwd()
625 os.chdir(repo)
626 try:
627 runner.invoke(cli, ["branch", "feat/x"])
628 runner.invoke(cli, ["checkout", "feat/x"])
629 (repo / "shared.py").write_text("x = 2 # feat\n")
630 runner.invoke(cli, ["commit", "-m", "feat"])
631 runner.invoke(cli, ["checkout", "main"])
632 (repo / "shared.py").write_text("x = 3 # main\n")
633 runner.invoke(cli, ["commit", "-m", "main diverge"])
634 runner.invoke(cli, ["merge", "feat/x"])
635 finally:
636 os.chdir(saved)
637 return repo
638
639 def test_merge_in_progress_flag_in_json(self, tmp_path: pathlib.Path) -> None:
640 repo = self._setup_conflict(tmp_path)
641 data = json.loads(_status(repo, "--json").output)
642 assert data["merge_in_progress"] is True
643
644 def test_conflict_count_nonzero_in_json(self, tmp_path: pathlib.Path) -> None:
645 repo = self._setup_conflict(tmp_path)
646 data = json.loads(_status(repo, "--json").output)
647 assert data["conflict_count"] >= 1
648
649 def test_conflict_paths_is_list_in_json(self, tmp_path: pathlib.Path) -> None:
650 repo = self._setup_conflict(tmp_path)
651 data = json.loads(_status(repo, "--json").output)
652 assert isinstance(data["conflict_paths"], list)
653
654 def test_merge_from_present_in_json(self, tmp_path: pathlib.Path) -> None:
655 repo = self._setup_conflict(tmp_path)
656 data = json.loads(_status(repo, "--json").output)
657 assert data["merge_from"] is not None
658
659 def test_merge_banner_in_text_output(self, tmp_path: pathlib.Path) -> None:
660 repo = self._setup_conflict(tmp_path)
661 result = _status(repo)
662 assert "merge in progress" in result.output.lower()
663
664 def test_porcelain_shows_merging_suffix(self, tmp_path: pathlib.Path) -> None:
665 repo = self._setup_conflict(tmp_path)
666 result = _status(repo, "--porcelain")
667 assert "(MERGING)" in result.output
668
669
670 # ---------------------------------------------------------------------------
671 # End-to-end — complete workflows
672 # ---------------------------------------------------------------------------
673
674
675 class TestEndToEnd:
676 def test_fresh_repo_status_exits_zero(self, tmp_path: pathlib.Path) -> None:
677 repo = tmp_path / "repo"
678 _init(repo)
679 result = _status(repo, "--json")
680 assert result.exit_code == 0
681
682 def test_init_commit_status_clean(self, tmp_path: pathlib.Path) -> None:
683 repo = _fresh_repo(tmp_path)
684 data = json.loads(_status(repo, "--json").output)
685 assert data["clean"] is True
686 assert data["dirty"] is False
687 assert data["head_commit"] is not None
688
689 def test_modify_then_status_shows_modified(self, tmp_path: pathlib.Path) -> None:
690 repo = _fresh_repo(tmp_path)
691 (repo / "base.py").write_text("x = 99\n")
692 data = json.loads(_status(repo, "--json").output)
693 assert "base.py" in data["modified"]
694
695 def test_add_file_then_status_shows_added(self, tmp_path: pathlib.Path) -> None:
696 repo = _fresh_repo(tmp_path)
697 (repo / "new.py").write_text("y = 2\n")
698 data = json.loads(_status(repo, "--json").output)
699 assert "new.py" in data["added"]
700
701 def test_delete_file_then_status_shows_deleted(self, tmp_path: pathlib.Path) -> None:
702 repo = _fresh_repo(tmp_path)
703 (repo / "base.py").unlink()
704 data = json.loads(_status(repo, "--json").output)
705 assert "base.py" in data["deleted"]
706
707 def test_second_commit_makes_clean(self, tmp_path: pathlib.Path) -> None:
708 repo = _fresh_repo(tmp_path)
709 (repo / "new.py").write_text("y = 2\n")
710 assert json.loads(_status(repo, "--json").output)["dirty"] is True
711 _commit(repo, "second commit")
712 assert json.loads(_status(repo, "--json").output)["clean"] is True
713
714 def test_head_commit_changes_after_commit(self, tmp_path: pathlib.Path) -> None:
715 repo = _fresh_repo(tmp_path)
716 head1 = json.loads(_status(repo, "--json").output)["head_commit"]
717 (repo / "new.py").write_text("y = 2\n")
718 _commit(repo, "second")
719 head2 = json.loads(_status(repo, "--json").output)["head_commit"]
720 assert head1 != head2
721
722 def test_branch_switch_updates_branch_in_status(self, tmp_path: pathlib.Path) -> None:
723 from muse.cli.app import main as cli
724 repo = _fresh_repo(tmp_path)
725 saved = os.getcwd()
726 os.chdir(repo)
727 try:
728 runner.invoke(cli, ["branch", "feat/x"])
729 runner.invoke(cli, ["checkout", "feat/x"])
730 finally:
731 os.chdir(saved)
732 data = json.loads(_status(repo, "--json").output)
733 assert data["branch"] == "feat/x"
734
735 def test_status_subprocess_call_works(self, tmp_path: pathlib.Path) -> None:
736 """muse status invoked as a subprocess must return valid JSON."""
737 repo = _fresh_repo(tmp_path)
738 r = subprocess.run(
739 ["muse", "status", "--json"],
740 capture_output=True, text=True, cwd=str(repo),
741 )
742 assert r.returncode == 0
743 data = json.loads(r.stdout)
744 assert "branch" in data
745
746
747 # ---------------------------------------------------------------------------
748 # Stress — large repos and rapid calls
749 # ---------------------------------------------------------------------------
750
751
752 class TestStress:
753 @pytest.mark.slow
754 def test_status_500_files_completes(self, tmp_path: pathlib.Path) -> None:
755 """muse status on a 500-file repo must complete without error."""
756 repo = tmp_path / "repo"
757 _init(repo)
758 for i in range(500):
759 (repo / f"file_{i:04d}.py").write_text(f"x = {i}\n")
760 _commit(repo, "big commit")
761 result = _status(repo, "--json")
762 assert result.exit_code == 0
763 data = json.loads(result.output)
764 assert data["clean"] is True
765
766 @pytest.mark.slow
767 def test_status_500_files_50_modified(self, tmp_path: pathlib.Path) -> None:
768 repo = tmp_path / "repo"
769 _init(repo)
770 for i in range(500):
771 (repo / f"file_{i:04d}.py").write_text(f"x = {i}\n")
772 _commit(repo, "big commit")
773 for i in range(50):
774 (repo / f"file_{i:04d}.py").write_text(f"x = {i}\n# mod\n")
775
776 result = _status(repo, "--json")
777 assert result.exit_code == 0
778 data = json.loads(result.output)
779 assert data["dirty"] is True
780 assert len(data["modified"]) == 50
781
782 @pytest.mark.slow
783 def test_rapid_sequential_calls(self, tmp_path: pathlib.Path) -> None:
784 """20 sequential muse status calls must all succeed."""
785 repo = _fresh_repo(tmp_path)
786 for i in range(20):
787 result = _status(repo, "--json")
788 assert result.exit_code == 0, f"Call {i} failed"
789 data = json.loads(result.output)
790 assert data["branch"] == "main"
791
792 def test_many_added_files_in_json(self, tmp_path: pathlib.Path) -> None:
793 """100 new files must all appear in the added list."""
794 repo = _fresh_repo(tmp_path)
795 for i in range(100):
796 (repo / f"added_{i:03d}.py").write_text(f"y = {i}\n")
797 data = json.loads(_status(repo, "--json").output)
798 added = data["added"]
799 for i in range(100):
800 assert f"added_{i:03d}.py" in added
801
802 def test_many_deleted_files_in_json(self, tmp_path: pathlib.Path) -> None:
803 """Commit 100 files then delete them all — all must appear as deleted."""
804 repo = tmp_path / "repo"
805 _init(repo)
806 for i in range(100):
807 (repo / f"f_{i:03d}.py").write_text(f"x = {i}\n")
808 _commit(repo, "100 files")
809 for i in range(100):
810 (repo / f"f_{i:03d}.py").unlink()
811 data = json.loads(_status(repo, "--json").output)
812 assert len(data["deleted"]) == 100
813
814 def test_added_list_is_sorted(self, tmp_path: pathlib.Path) -> None:
815 """The added/modified/deleted lists must always be sorted."""
816 repo = _fresh_repo(tmp_path)
817 for name in ["z.py", "a.py", "m.py", "b.py"]:
818 (repo / name).write_text("x=1\n")
819 data = json.loads(_status(repo, "--json").output)
820 added = data["added"]
821 assert added == sorted(added)
File History 1 commit
sha256:1d3f5470f45db58e32047678debc9438fdded1b2c7332cc743d2b8be32fdafc8 fixing more broken tests Human patch 41 days ago