gabriel / muse public

test_cmd_check_ignore.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:b adding issues docs to bust staging mpack prebuild cache. · gabriel · Jun 20, 2026
1 """Comprehensive tests for ``muse check-ignore``.
2
3 Audit findings addressed here
4 ------------------------------
5 Code quality
6 - ``_check_path`` and ``_posix_match`` private helpers in check_ignore.py
7 duplicated ``is_ignored`` and ``_matches`` from ``muse.core.ignore``.
8 Both have been deleted; the CLI now delegates to
9 ``check_path_with_pattern`` — the single authoritative matching function.
10 - ``is_ignored`` in core now delegates to ``check_path_with_pattern``,
11 guaranteeing the CLI and the snapshot engine always agree.
12
13 Security
14 - Format error now goes to stderr.
15 - ANSI injection in path / matching_pattern stripped in text mode.
16 - Null bytes in paths rejected with USER_ERROR.
17
18 Agent UX
19 - ``--stdin`` — read paths one-per-line from stdin.
20 - ``--patterns-only`` — emit resolved patterns without testing any path.
21 - ``formatter_class`` added for clean --help output.
22
23 Coverage tiers
24 --------------
25 - Unit: check_path_with_pattern (core), is_ignored delegation,
26 _PathResult schema
27 - Integration: JSON/text format, --quiet, --verbose, --stdin, --patterns-only,
28 empty ignore file, global patterns, domain patterns, negation,
29 directory patterns, anchored patterns
30 - Security: null byte paths rejected, ANSI stripped, format error→stderr,
31 no traceback on bad input
32 - Stress: 1 000-path batch, 200 sequential runs, 50-pattern rule set
33 """
34 from __future__ import annotations
35
36 import json
37 import pathlib
38
39 import pytest
40
41 from muse.core.errors import ExitCode
42 from muse.core.paths import muse_dir
43 from tests.cli_test_helper import CliRunner, InvokeResult
44
45 runner = CliRunner()
46
47
48 # ---------------------------------------------------------------------------
49 # Helpers
50 # ---------------------------------------------------------------------------
51
52 def _make_repo(tmp_path: pathlib.Path, domain: str = "code") -> pathlib.Path:
53 repo = tmp_path / "repo"
54 dot_muse = muse_dir(repo)
55 for sub in ("objects", "commits", "snapshots", "refs/heads"):
56 (dot_muse / sub).mkdir(parents=True)
57 (dot_muse / "HEAD").write_text("ref: refs/heads/main")
58 (dot_muse / "repo.json").write_text(json.dumps({"repo_id": "r1", "domain": domain}))
59 return repo
60
61
62 def _write_museignore(repo: pathlib.Path, content: str) -> None:
63 (repo / ".museignore").write_text(content)
64
65
66 def _ci(repo: pathlib.Path, *args: str, stdin: str | None = None) -> InvokeResult:
67 from muse.cli.app import main as cli
68 return runner.invoke(
69 cli,
70 ["check-ignore", *args],
71 env={"MUSE_REPO_ROOT": str(repo)},
72 input=stdin,
73 )
74
75
76 # ---------------------------------------------------------------------------
77 # Unit — check_path_with_pattern (core)
78 # ---------------------------------------------------------------------------
79
80
81 class TestCheckPathWithPattern:
82 """The single authoritative ignore-matching function lives in core."""
83
84 def test_no_patterns_not_ignored(self) -> None:
85 from muse.core.ignore import check_path_with_pattern
86 ignored, pat = check_path_with_pattern("tracks/drums.mid", [])
87 assert not ignored
88 assert pat is None
89
90 def test_simple_glob_match(self) -> None:
91 from muse.core.ignore import check_path_with_pattern
92 ignored, pat = check_path_with_pattern("build/out.bin", ["build/"])
93 assert ignored
94 assert pat == "build/"
95
96 def test_negation_un_ignores(self) -> None:
97 from muse.core.ignore import check_path_with_pattern
98 ignored, pat = check_path_with_pattern(
99 "build/keep.mid", ["build/", "!build/keep.mid"]
100 )
101 assert not ignored
102 assert pat is None # negated → no active pattern
103
104 def test_extension_glob(self) -> None:
105 from muse.core.ignore import check_path_with_pattern
106 ignored, pat = check_path_with_pattern("tracks/drums.tmp", ["*.tmp"])
107 assert ignored
108 assert pat == "*.tmp"
109
110 def test_last_match_wins(self) -> None:
111 from muse.core.ignore import check_path_with_pattern
112 ignored, pat = check_path_with_pattern(
113 "foo.log", ["*.log", "!foo.log", "foo.*"]
114 )
115 assert ignored
116 assert pat == "foo.*"
117
118 def test_anchored_pattern(self) -> None:
119 from muse.core.ignore import check_path_with_pattern
120 ignored, pat = check_path_with_pattern("dist/index.js", ["/dist/index.js"])
121 assert ignored
122 assert pat == "/dist/index.js"
123
124 def test_non_matching_returns_false(self) -> None:
125 from muse.core.ignore import check_path_with_pattern
126 ignored, pat = check_path_with_pattern("tracks/drums.mid", ["*.tmp"])
127 assert not ignored
128 assert pat is None
129
130
131 class TestIsIgnoredDelegates:
132 """is_ignored must agree with check_path_with_pattern on every case."""
133
134 def test_delegation_matches(self) -> None:
135 from muse.core.ignore import check_path_with_pattern, is_ignored
136 patterns = ["build/", "*.log", "!tracks/*.mid"]
137 paths = [
138 "build/out.bin",
139 "app.log",
140 "tracks/drums.mid",
141 "other.py",
142 ]
143 for p in paths:
144 ignored_via_delegate = is_ignored(p, patterns)
145 ignored_via_direct, _ = check_path_with_pattern(p, patterns)
146 assert ignored_via_delegate == ignored_via_direct, (
147 f"is_ignored and check_path_with_pattern disagree on {p!r}"
148 )
149
150
151 class TestPathResultSchema:
152 def test_fields(self) -> None:
153 from muse.cli.commands.check_ignore import _PathResult
154 fields = set(_PathResult.__annotations__)
155 assert fields == {"path", "ignored", "matching_pattern"}
156
157
158 # ---------------------------------------------------------------------------
159 # Integration — JSON output
160 # ---------------------------------------------------------------------------
161
162
163 class TestJsonOutput:
164 def test_no_ignore_file_returns_not_ignored(self, tmp_path: pathlib.Path) -> None:
165 repo = _make_repo(tmp_path)
166 result = _ci(repo, "--json", "tracks/drums.mid")
167 assert result.exit_code == 0
168 data = json.loads(result.output)
169 assert data["patterns_loaded"] == 0
170 assert data["results"][0]["ignored"] is False
171 assert data["results"][0]["matching_pattern"] is None
172
173 def test_ignored_path(self, tmp_path: pathlib.Path) -> None:
174 repo = _make_repo(tmp_path)
175 _write_museignore(repo, '[global]\npatterns = ["build/"]\n')
176 data = json.loads(_ci(repo, "--json", "build/out.bin").output)
177 assert data["results"][0]["ignored"] is True
178 assert data["results"][0]["matching_pattern"] == "build/"
179
180 def test_multiple_paths(self, tmp_path: pathlib.Path) -> None:
181 repo = _make_repo(tmp_path)
182 _write_museignore(repo, '[global]\npatterns = ["*.tmp"]\n')
183 data = json.loads(_ci(repo, "--json", "a.tmp", "b.mid").output)
184 assert len(data["results"]) == 2
185 assert data["results"][0]["ignored"] is True
186 assert data["results"][1]["ignored"] is False
187
188 def test_domain_in_output(self, tmp_path: pathlib.Path) -> None:
189 repo = _make_repo(tmp_path, domain="code")
190 data = json.loads(_ci(repo, "--json", "foo.py").output)
191 assert data["domain"] == "code"
192
193 def test_domain_specific_patterns(self, tmp_path: pathlib.Path) -> None:
194 repo = _make_repo(tmp_path, domain="midi")
195 _write_museignore(repo, '[domain.midi]\npatterns = ["*.log"]\n')
196 data = json.loads(_ci(repo, "--json", "debug.log").output)
197 assert data["results"][0]["ignored"] is True
198
199 def test_domain_patterns_not_applied_to_other_domain(self, tmp_path: pathlib.Path) -> None:
200 repo = _make_repo(tmp_path, domain="code")
201 _write_museignore(repo, '[domain.midi]\npatterns = ["*.log"]\n')
202 data = json.loads(_ci(repo, "--json", "debug.log").output)
203 assert data["results"][0]["ignored"] is False
204
205 def test_negation_pattern(self, tmp_path: pathlib.Path) -> None:
206 repo = _make_repo(tmp_path)
207 _write_museignore(
208 repo, '[global]\npatterns = ["build/", "!build/keep.mid"]\n'
209 )
210 data = json.loads(_ci(repo, "--json", "build/keep.mid").output)
211 assert data["results"][0]["ignored"] is False
212 assert data["results"][0]["matching_pattern"] is None
213
214 def test_json_shorthand(self, tmp_path: pathlib.Path) -> None:
215 repo = _make_repo(tmp_path)
216 result = _ci(repo, "--json", "foo.py")
217 assert result.exit_code == 0
218 assert "results" in json.loads(result.output)
219
220
221 # ---------------------------------------------------------------------------
222 # Integration — text output
223 # ---------------------------------------------------------------------------
224
225
226 class TestTextOutput:
227 def test_ignored_shows_ignored_label(self, tmp_path: pathlib.Path) -> None:
228 repo = _make_repo(tmp_path)
229 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
230 result = _ci(repo, "out.bin")
231 assert result.exit_code == 0
232 assert "ignored" in result.output
233
234 def test_not_ignored_shows_ok(self, tmp_path: pathlib.Path) -> None:
235 repo = _make_repo(tmp_path)
236 result = _ci(repo, "main.py")
237 assert "ok" in result.output
238
239 def test_verbose_shows_pattern(self, tmp_path: pathlib.Path) -> None:
240 repo = _make_repo(tmp_path)
241 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
242 result = _ci(repo, "--verbose", "out.bin")
243 assert "[*.bin]" in result.output
244
245 def test_verbose_no_pattern_when_not_ignored(self, tmp_path: pathlib.Path) -> None:
246 repo = _make_repo(tmp_path)
247 result = _ci(repo, "--verbose", "main.py")
248 assert "[" not in result.output
249
250
251 # ---------------------------------------------------------------------------
252 # Integration — --quiet mode
253 # ---------------------------------------------------------------------------
254
255
256 class TestQuietMode:
257 def test_all_ignored_exits_0(self, tmp_path: pathlib.Path) -> None:
258 repo = _make_repo(tmp_path)
259 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
260 result = _ci(repo, "--quiet", "a.bin", "b.bin")
261 assert result.exit_code == 0
262 assert result.output.strip() == ""
263
264 def test_some_not_ignored_exits_1(self, tmp_path: pathlib.Path) -> None:
265 repo = _make_repo(tmp_path)
266 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
267 result = _ci(repo, "--quiet", "a.bin", "main.py")
268 assert result.exit_code == ExitCode.USER_ERROR
269
270 def test_none_ignored_exits_1(self, tmp_path: pathlib.Path) -> None:
271 repo = _make_repo(tmp_path)
272 result = _ci(repo, "--quiet", "main.py")
273 assert result.exit_code == ExitCode.USER_ERROR
274
275
276 # ---------------------------------------------------------------------------
277 # Integration — --stdin (new agent UX)
278 # ---------------------------------------------------------------------------
279
280
281 class TestStdinMode:
282 def test_reads_paths_from_stdin(self, tmp_path: pathlib.Path) -> None:
283 repo = _make_repo(tmp_path)
284 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
285 result = _ci(repo, "--json", "--stdin", stdin="a.bin\nb.py\n")
286 assert result.exit_code == 0
287 data = json.loads(result.output)
288 assert len(data["results"]) == 2
289 assert data["results"][0]["ignored"] is True
290 assert data["results"][1]["ignored"] is False
291
292 def test_blank_lines_and_comments_skipped(self, tmp_path: pathlib.Path) -> None:
293 repo = _make_repo(tmp_path)
294 result = _ci(repo, "--json", "--stdin", stdin="\n# comment\nfoo.py\n\n")
295 data = json.loads(result.output)
296 assert len(data["results"]) == 1
297 assert data["results"][0]["path"] == "foo.py"
298
299 def test_stdin_combines_with_positional(self, tmp_path: pathlib.Path) -> None:
300 repo = _make_repo(tmp_path)
301 result = _ci(repo, "--json", "--stdin", "positional.py", stdin="from_stdin.py\n")
302 data = json.loads(result.output)
303 paths = [r["path"] for r in data["results"]]
304 assert "positional.py" in paths
305 assert "from_stdin.py" in paths
306
307 def test_no_paths_and_no_stdin_content_errors(self, tmp_path: pathlib.Path) -> None:
308 """--stdin with empty stdin and no positional args should error."""
309 repo = _make_repo(tmp_path)
310 result = _ci(repo, "--stdin", stdin="")
311 assert result.exit_code == ExitCode.USER_ERROR
312
313
314 # ---------------------------------------------------------------------------
315 # Integration — --patterns-only (new agent UX)
316 # ---------------------------------------------------------------------------
317
318
319 class TestPatternsOnly:
320 def test_json_patterns_list(self, tmp_path: pathlib.Path) -> None:
321 repo = _make_repo(tmp_path)
322 _write_museignore(repo, '[global]\npatterns = ["build/", "*.tmp"]\n')
323 data = json.loads(_ci(repo, "--json", "--patterns-only").output)
324 assert "patterns" in data
325 assert "build/" in data["patterns"]
326 assert "*.tmp" in data["patterns"]
327 assert data["domain"] == "code"
328
329 def test_text_patterns_one_per_line(self, tmp_path: pathlib.Path) -> None:
330 repo = _make_repo(tmp_path)
331 _write_museignore(repo, '[global]\npatterns = ["build/", "*.tmp"]\n')
332 result = _ci(repo, "--patterns-only")
333 assert result.exit_code == 0
334 lines = [l for l in result.output.splitlines() if l.strip()]
335 assert "build/" in lines
336 assert "*.tmp" in lines
337
338 def test_empty_museignore_empty_list(self, tmp_path: pathlib.Path) -> None:
339 repo = _make_repo(tmp_path)
340 data = json.loads(_ci(repo, "--json", "--patterns-only").output)
341 assert data["patterns"] == []
342
343 def test_no_path_required(self, tmp_path: pathlib.Path) -> None:
344 """--patterns-only should not require any path arguments."""
345 repo = _make_repo(tmp_path)
346 result = _ci(repo, "--patterns-only")
347 assert result.exit_code == 0
348
349
350 # ---------------------------------------------------------------------------
351 # Security
352 # ---------------------------------------------------------------------------
353
354
355 class TestSecurity:
356 def test_null_byte_in_path_rejected(self, tmp_path: pathlib.Path) -> None:
357 repo = _make_repo(tmp_path)
358 result = _ci(repo, "tracks/\x00malicious.mid")
359 assert result.exit_code == ExitCode.USER_ERROR
360 assert "null byte" in result.stderr.lower()
361
362 def test_ansi_in_path_stripped_text(self, tmp_path: pathlib.Path) -> None:
363 repo = _make_repo(tmp_path)
364 result = _ci(repo, "\x1b[31mmalicious\x1b[0m.py")
365 assert "\x1b" not in result.output
366
367 def test_ansi_in_pattern_stripped_text(self, tmp_path: pathlib.Path) -> None:
368 repo = _make_repo(tmp_path)
369 _write_museignore(repo, '[global]\npatterns = ["\\u001b[31m*.bin\\u001b[0m"]\n')
370 result = _ci(repo, "--verbose", "malicious.bin")
371 assert "\x1b" not in result.output
372
373 def test_no_traceback_on_bad_toml(self, tmp_path: pathlib.Path) -> None:
374 repo = _make_repo(tmp_path)
375 (repo / ".museignore").write_text("[broken toml !!!")
376 result = _ci(repo, "foo.py")
377 assert "Traceback" not in result.output
378
379
380 def test_no_paths_errors_to_stderr(self, tmp_path: pathlib.Path) -> None:
381 """Calling with no paths should report error on stderr."""
382 repo = _make_repo(tmp_path)
383 result = _ci(repo)
384 assert result.exit_code == ExitCode.USER_ERROR
385 assert "error" in result.stderr.lower()
386
387 def test_invalid_toml_errors(self, tmp_path: pathlib.Path) -> None:
388 repo = _make_repo(tmp_path)
389 (repo / ".museignore").write_text("[broken toml !!!")
390 result = _ci(repo, "foo.py")
391 assert result.exit_code == ExitCode.INTERNAL_ERROR
392 assert "Traceback" not in result.output
393
394 def test_path_traversal_is_safe(self, tmp_path: pathlib.Path) -> None:
395 """Path-traversal inputs are rejected with USER_ERROR — no crash, no file access."""
396 repo = _make_repo(tmp_path)
397 result = _ci(repo, "--json", "../../../etc/passwd")
398 assert result.exit_code == 1 # USER_ERROR — traversal blocked
399 # Error goes to stderr, not stdout
400 data = json.loads(result.stderr)
401 assert "error" in data
402 assert ".." in data["error"]
403
404 def test_very_long_path_no_crash(self, tmp_path: pathlib.Path) -> None:
405 repo = _make_repo(tmp_path)
406 long_path = "a/" * 200 + "file.py"
407 result = _ci(repo, long_path)
408 assert result.exit_code == 0
409
410
411 # ---------------------------------------------------------------------------
412 # duration_ms
413 # ---------------------------------------------------------------------------
414
415
416 class TestElapsed:
417 def test_elapsed_in_default_json(self, tmp_path: pathlib.Path) -> None:
418 repo = _make_repo(tmp_path)
419 data = json.loads(_ci(repo, "--json", "foo.py").output)
420 assert "duration_ms" in data
421 assert isinstance(data["duration_ms"], float)
422 assert data["duration_ms"] >= 0.0
423
424 def test_elapsed_in_patterns_only_json(self, tmp_path: pathlib.Path) -> None:
425 repo = _make_repo(tmp_path)
426 data = json.loads(_ci(repo, "--json", "--patterns-only").output)
427 assert "duration_ms" in data
428 assert isinstance(data["duration_ms"], float)
429
430 def test_elapsed_absent_from_text_output(self, tmp_path: pathlib.Path) -> None:
431 repo = _make_repo(tmp_path)
432 result = _ci(repo, "foo.py")
433 assert "duration_ms" not in result.output
434
435
436 # ---------------------------------------------------------------------------
437 # exit_code
438 # ---------------------------------------------------------------------------
439
440
441 class TestExitCode:
442 def test_exit_code_0_in_default_json(self, tmp_path: pathlib.Path) -> None:
443 repo = _make_repo(tmp_path)
444 data = json.loads(_ci(repo, "--json", "foo.py").output)
445 assert "exit_code" in data
446 assert data["exit_code"] == 0
447
448 def test_exit_code_in_patterns_only_json(self, tmp_path: pathlib.Path) -> None:
449 repo = _make_repo(tmp_path)
450 data = json.loads(_ci(repo, "--json", "--patterns-only").output)
451 assert "exit_code" in data
452 assert data["exit_code"] == 0
453
454
455 # ---------------------------------------------------------------------------
456 # summary block
457 # ---------------------------------------------------------------------------
458
459
460 class TestSummary:
461 def test_summary_present_in_default_json(self, tmp_path: pathlib.Path) -> None:
462 repo = _make_repo(tmp_path)
463 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
464 data = json.loads(_ci(repo, "--json", "a.bin", "b.bin", "main.py").output)
465 assert "summary" in data
466 s = data["summary"]
467 assert s["total"] == 3
468 assert s["ignored"] == 2
469 assert s["not_ignored"] == 1
470
471 def test_summary_all_ignored(self, tmp_path: pathlib.Path) -> None:
472 repo = _make_repo(tmp_path)
473 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
474 data = json.loads(_ci(repo, "--json", "a.bin", "b.bin").output)
475 s = data["summary"]
476 assert s["total"] == 2
477 assert s["ignored"] == 2
478 assert s["not_ignored"] == 0
479
480 def test_summary_none_ignored(self, tmp_path: pathlib.Path) -> None:
481 repo = _make_repo(tmp_path)
482 data = json.loads(_ci(repo, "--json", "foo.py", "bar.py").output)
483 s = data["summary"]
484 assert s["total"] == 2
485 assert s["ignored"] == 0
486 assert s["not_ignored"] == 2
487
488 def test_summary_absent_from_patterns_only(self, tmp_path: pathlib.Path) -> None:
489 repo = _make_repo(tmp_path)
490 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
491 data = json.loads(_ci(repo, "--json", "--patterns-only").output)
492 assert "summary" not in data
493
494
495 # ---------------------------------------------------------------------------
496 # --patterns-only: patterns_loaded count
497 # ---------------------------------------------------------------------------
498
499
500 class TestPatternsOnlyCount:
501 def test_patterns_loaded_present(self, tmp_path: pathlib.Path) -> None:
502 repo = _make_repo(tmp_path)
503 _write_museignore(repo, '[global]\npatterns = ["build/", "*.tmp", "*.log"]\n')
504 data = json.loads(_ci(repo, "--json", "--patterns-only").output)
505 assert "patterns_loaded" in data
506 assert data["patterns_loaded"] == 3
507
508 def test_patterns_loaded_zero_when_empty(self, tmp_path: pathlib.Path) -> None:
509 repo = _make_repo(tmp_path)
510 data = json.loads(_ci(repo, "--json", "--patterns-only").output)
511 assert data["patterns_loaded"] == 0
512
513 def test_patterns_loaded_matches_list_length(self, tmp_path: pathlib.Path) -> None:
514 repo = _make_repo(tmp_path)
515 _write_museignore(repo, '[global]\npatterns = ["a/", "b/", "c/"]\n')
516 data = json.loads(_ci(repo, "--json", "--patterns-only").output)
517 assert data["patterns_loaded"] == len(data["patterns"])
518
519
520 # ---------------------------------------------------------------------------
521 # --ignored-only
522 # ---------------------------------------------------------------------------
523
524
525 class TestIgnoredOnly:
526 def test_filters_to_ignored_paths(self, tmp_path: pathlib.Path) -> None:
527 repo = _make_repo(tmp_path)
528 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
529 data = json.loads(_ci(repo, "--json", "--ignored-only", "a.bin", "main.py").output)
530 assert len(data["results"]) == 1
531 assert data["results"][0]["path"] == "a.bin"
532 assert data["results"][0]["ignored"] is True
533
534 def test_empty_when_none_ignored(self, tmp_path: pathlib.Path) -> None:
535 repo = _make_repo(tmp_path)
536 data = json.loads(_ci(repo, "--json", "--ignored-only", "foo.py", "bar.py").output)
537 assert data["results"] == []
538
539 def test_all_when_all_ignored(self, tmp_path: pathlib.Path) -> None:
540 repo = _make_repo(tmp_path)
541 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
542 data = json.loads(_ci(repo, "--json", "--ignored-only", "a.bin", "b.bin").output)
543 assert len(data["results"]) == 2
544
545 def test_summary_reflects_filtered_count(self, tmp_path: pathlib.Path) -> None:
546 repo = _make_repo(tmp_path)
547 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
548 data = json.loads(_ci(repo, "--json", "--ignored-only", "a.bin", "main.py").output)
549 assert data["summary"]["total"] == 1
550 assert data["summary"]["ignored"] == 1
551 assert data["summary"]["not_ignored"] == 0
552
553 def test_text_format(self, tmp_path: pathlib.Path) -> None:
554 repo = _make_repo(tmp_path)
555 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
556 result = _ci(repo, "--ignored-only", "a.bin", "main.py")
557 assert result.exit_code == 0
558 assert "a.bin" in result.output
559 assert "main.py" not in result.output
560
561 def test_incompatible_with_patterns_only(self, tmp_path: pathlib.Path) -> None:
562 repo = _make_repo(tmp_path)
563 result = _ci(repo, "--json", "--ignored-only", "--patterns-only")
564 assert result.exit_code == ExitCode.USER_ERROR
565
566 def test_stdin_compatible(self, tmp_path: pathlib.Path) -> None:
567 repo = _make_repo(tmp_path)
568 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
569 result = _ci(repo, "--json", "--ignored-only", "--stdin", stdin="a.bin\nmain.py\n")
570 data = json.loads(result.output)
571 assert len(data["results"]) == 1
572 assert data["results"][0]["path"] == "a.bin"
573
574 def test_incompatible_with_quiet(self, tmp_path: pathlib.Path) -> None:
575 repo = _make_repo(tmp_path)
576 result = _ci(repo, "--ignored-only", "--quiet", "a.bin")
577 assert result.exit_code == ExitCode.USER_ERROR
578
579
580 # ---------------------------------------------------------------------------
581 # Stress
582 # ---------------------------------------------------------------------------
583
584
585 class TestStress:
586 def test_1000_paths(self, tmp_path: pathlib.Path) -> None:
587 repo = _make_repo(tmp_path)
588 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
589 paths = [f"file_{i:04d}.bin" for i in range(500)]
590 paths += [f"file_{i:04d}.py" for i in range(500)]
591 result = _ci(repo, "--json", *paths)
592 assert result.exit_code == 0
593 data = json.loads(result.output)
594 assert len(data["results"]) == 1000
595 ignored_count = sum(1 for r in data["results"] if r["ignored"])
596 assert ignored_count == 500
597
598 def test_50_pattern_rule_set(self, tmp_path: pathlib.Path) -> None:
599 repo = _make_repo(tmp_path)
600 patterns = [f"dir_{i}/" for i in range(25)] + [f"*.ext{i}" for i in range(25)]
601 patterns_toml = json.dumps(patterns)
602 _write_museignore(repo, f"[global]\npatterns = {patterns_toml}\n")
603 data = json.loads(_ci(repo, "--json", "dir_0/file.txt", "file.ext0", "clean.py").output)
604 assert data["patterns_loaded"] == 50
605 assert data["results"][0]["ignored"] is True
606 assert data["results"][1]["ignored"] is True
607 assert data["results"][2]["ignored"] is False
608
609 def test_200_sequential_runs(self, tmp_path: pathlib.Path) -> None:
610 repo = _make_repo(tmp_path)
611 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
612 for i in range(200):
613 result = _ci(repo, "--json", "out.bin")
614 assert result.exit_code == 0, f"failed at iteration {i}"
615 assert json.loads(result.output)["results"][0]["ignored"] is True
616
617 def test_stdin_1000_paths(self, tmp_path: pathlib.Path) -> None:
618 repo = _make_repo(tmp_path)
619 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
620 stdin_input = "\n".join(f"file_{i}.bin" for i in range(1000)) + "\n"
621 result = _ci(repo, "--json", "--stdin", stdin=stdin_input)
622 assert result.exit_code == 0
623 data = json.loads(result.output)
624 assert len(data["results"]) == 1000
625 assert all(r["ignored"] for r in data["results"])
626
627
628 # ---------------------------------------------------------------------------
629 # Flag tests
630 # ---------------------------------------------------------------------------
631
632
633 import argparse as _argparse
634
635
636 class TestRegisterFlags:
637 def _parse(self, *args: str) -> _argparse.Namespace:
638 from muse.cli.commands.check_ignore import register
639 p = _argparse.ArgumentParser()
640 sub = p.add_subparsers()
641 register(sub)
642 return p.parse_args(["check-ignore", *args])
643
644 def test_default_json_out_is_false(self) -> None:
645 ns = self._parse("foo.py")
646 assert ns.json_out is False
647
648 def test_json_flag_sets_json_out(self) -> None:
649 ns = self._parse("--json", "foo.py")
650 assert ns.json_out is True
651
652 def test_j_shorthand_sets_json_out(self) -> None:
653 ns = self._parse("-j", "foo.py")
654 assert ns.json_out is True