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