cgcardona / muse public
test_core_ignore.py python
501 lines 19.5 KB
de2a25b0 fix: two-bug root-cause for ghost files in muse status 5h ago
1 """Tests for muse/core/ignore.py — .museignore TOML parser and path filter."""
2
3 import pathlib
4
5 import pytest
6
7 from muse.core.ignore import (
8 MuseIgnoreConfig,
9 _matches,
10 is_ignored,
11 load_ignore_config,
12 resolve_patterns,
13 )
14
15
16 # ---------------------------------------------------------------------------
17 # load_ignore_config
18 # ---------------------------------------------------------------------------
19
20
21 class TestLoadIgnoreConfig:
22 def test_returns_empty_when_no_file(self, tmp_path: pathlib.Path) -> None:
23 assert load_ignore_config(tmp_path) == {}
24
25 def test_empty_toml_file(self, tmp_path: pathlib.Path) -> None:
26 (tmp_path / ".museignore").write_text("")
27 assert load_ignore_config(tmp_path) == {}
28
29 def test_toml_comments_only(self, tmp_path: pathlib.Path) -> None:
30 (tmp_path / ".museignore").write_text("# just a comment\n")
31 assert load_ignore_config(tmp_path) == {}
32
33 def test_global_section_parsed(self, tmp_path: pathlib.Path) -> None:
34 (tmp_path / ".museignore").write_text(
35 '[global]\npatterns = ["*.tmp", "*.bak"]\n'
36 )
37 config = load_ignore_config(tmp_path)
38 assert config.get("global", {}).get("patterns") == ["*.tmp", "*.bak"]
39
40 def test_domain_section_parsed(self, tmp_path: pathlib.Path) -> None:
41 (tmp_path / ".museignore").write_text(
42 '[domain.midi]\npatterns = ["*.bak"]\n'
43 )
44 config = load_ignore_config(tmp_path)
45 domain_map = config.get("domain", {})
46 assert domain_map.get("midi", {}).get("patterns") == ["*.bak"]
47
48 def test_multiple_domain_sections_parsed(self, tmp_path: pathlib.Path) -> None:
49 content = (
50 '[domain.midi]\npatterns = ["*.bak"]\n'
51 '[domain.code]\npatterns = ["__pycache__/"]\n'
52 )
53 (tmp_path / ".museignore").write_text(content)
54 config = load_ignore_config(tmp_path)
55 domain_map = config.get("domain", {})
56 assert domain_map.get("midi", {}).get("patterns") == ["*.bak"]
57 assert domain_map.get("code", {}).get("patterns") == ["__pycache__/"]
58
59 def test_global_and_domain_sections_parsed(self, tmp_path: pathlib.Path) -> None:
60 content = (
61 '[global]\npatterns = ["*.tmp"]\n'
62 '[domain.midi]\npatterns = ["*.bak"]\n'
63 )
64 (tmp_path / ".museignore").write_text(content)
65 config = load_ignore_config(tmp_path)
66 assert config.get("global", {}).get("patterns") == ["*.tmp"]
67 domain_map = config.get("domain", {})
68 assert domain_map.get("midi", {}).get("patterns") == ["*.bak"]
69
70 def test_negation_pattern_preserved(self, tmp_path: pathlib.Path) -> None:
71 (tmp_path / ".museignore").write_text(
72 '[global]\npatterns = ["*.bak", "!keep.bak"]\n'
73 )
74 config = load_ignore_config(tmp_path)
75 assert config.get("global", {}).get("patterns") == ["*.bak", "!keep.bak"]
76
77 def test_invalid_toml_raises_value_error(self, tmp_path: pathlib.Path) -> None:
78 (tmp_path / ".museignore").write_text("this is not valid toml ][")
79 with pytest.raises(ValueError, match=".museignore"):
80 load_ignore_config(tmp_path)
81
82 def test_section_without_patterns_key(self, tmp_path: pathlib.Path) -> None:
83 # A section with no patterns key produces an empty DomainSection.
84 (tmp_path / ".museignore").write_text("[global]\n")
85 config = load_ignore_config(tmp_path)
86 assert config.get("global") == {}
87
88 def test_non_string_patterns_silently_dropped(
89 self, tmp_path: pathlib.Path
90 ) -> None:
91 # Non-string items in the patterns array are silently skipped.
92 (tmp_path / ".museignore").write_text(
93 '[global]\npatterns = ["*.tmp", 42, true, "*.bak"]\n'
94 )
95 config = load_ignore_config(tmp_path)
96 assert config.get("global", {}).get("patterns") == ["*.tmp", "*.bak"]
97
98
99 # ---------------------------------------------------------------------------
100 # resolve_patterns
101 # ---------------------------------------------------------------------------
102
103
104 class TestResolvePatterns:
105 def test_empty_config_returns_empty(self) -> None:
106 config: MuseIgnoreConfig = {}
107 assert resolve_patterns(config, "midi") == []
108
109 def test_global_only(self) -> None:
110 config: MuseIgnoreConfig = {"global": {"patterns": ["*.tmp", ".DS_Store"]}}
111 assert resolve_patterns(config, "midi") == ["*.tmp", ".DS_Store"]
112
113 def test_domain_only(self) -> None:
114 config: MuseIgnoreConfig = {"domain": {"midi": {"patterns": ["*.bak"]}}}
115 assert resolve_patterns(config, "midi") == ["*.bak"]
116
117 def test_global_and_matching_domain_merged(self) -> None:
118 config: MuseIgnoreConfig = {
119 "global": {"patterns": ["*.tmp"]},
120 "domain": {"midi": {"patterns": ["*.bak"]}},
121 }
122 result = resolve_patterns(config, "midi")
123 # Global comes first, then domain-specific.
124 assert result == ["*.tmp", "*.bak"]
125
126 def test_other_domain_patterns_excluded(self) -> None:
127 config: MuseIgnoreConfig = {
128 "global": {"patterns": ["*.tmp"]},
129 "domain": {
130 "midi": {"patterns": ["*.bak"]},
131 "code": {"patterns": ["node_modules/"]},
132 },
133 }
134 # Asking for "midi" — code patterns must not appear.
135 result = resolve_patterns(config, "midi")
136 assert "*.bak" in result
137 assert "node_modules/" not in result
138
139 def test_active_domain_not_in_config_returns_global_only(self) -> None:
140 config: MuseIgnoreConfig = {
141 "global": {"patterns": ["*.tmp"]},
142 "domain": {"midi": {"patterns": ["*.bak"]}},
143 }
144 # Active domain "genomics" has no section — only global patterns.
145 result = resolve_patterns(config, "genomics")
146 assert result == ["*.tmp"]
147
148 def test_global_section_without_patterns_key(self) -> None:
149 config: MuseIgnoreConfig = {"global": {}}
150 assert resolve_patterns(config, "midi") == []
151
152 def test_domain_section_without_patterns_key(self) -> None:
153 config: MuseIgnoreConfig = {"domain": {"midi": {}}}
154 assert resolve_patterns(config, "midi") == []
155
156 def test_order_preserved(self) -> None:
157 config: MuseIgnoreConfig = {
158 "global": {"patterns": ["a", "b", "c"]},
159 "domain": {"midi": {"patterns": ["d", "e"]}},
160 }
161 assert resolve_patterns(config, "midi") == ["a", "b", "c", "d", "e"]
162
163 def test_negation_in_global_preserved(self) -> None:
164 config: MuseIgnoreConfig = {
165 "global": {"patterns": ["*.bak", "!keep.bak"]},
166 }
167 patterns = resolve_patterns(config, "midi")
168 assert patterns == ["*.bak", "!keep.bak"]
169
170 def test_negation_in_domain_overrides_global(self) -> None:
171 # A negation in the domain section can un-ignore a globally ignored path.
172 config: MuseIgnoreConfig = {
173 "global": {"patterns": ["*.bak"]},
174 "domain": {"midi": {"patterns": ["!session.bak"]}},
175 }
176 patterns = resolve_patterns(config, "midi")
177 # session.bak is globally ignored but negated by domain section.
178 assert not is_ignored("session.bak", patterns)
179 # other.bak is globally ignored and not negated.
180 assert is_ignored("other.bak", patterns)
181
182
183 # ---------------------------------------------------------------------------
184 # _matches (internal — gitignore path semantics, unchanged)
185 # ---------------------------------------------------------------------------
186
187
188 class TestMatchesInternal:
189 """Verify the core matching logic in isolation."""
190
191 # ---- Patterns without slash: match any component ----
192
193 def test_ext_pattern_matches_top_level(self) -> None:
194 import pathlib as pl
195 assert _matches(pl.PurePosixPath("drums.tmp"), "*.tmp")
196
197 def test_ext_pattern_matches_nested(self) -> None:
198 import pathlib as pl
199 assert _matches(pl.PurePosixPath("tracks/drums.tmp"), "*.tmp")
200
201 def test_ext_pattern_matches_deep_nested(self) -> None:
202 import pathlib as pl
203 assert _matches(pl.PurePosixPath("a/b/c/drums.tmp"), "*.tmp")
204
205 def test_ext_pattern_no_false_positive(self) -> None:
206 import pathlib as pl
207 assert not _matches(pl.PurePosixPath("tracks/drums.mid"), "*.tmp")
208
209 def test_exact_name_matches_any_depth(self) -> None:
210 import pathlib as pl
211 assert _matches(pl.PurePosixPath("a/b/.DS_Store"), ".DS_Store")
212
213 def test_exact_name_top_level(self) -> None:
214 import pathlib as pl
215 assert _matches(pl.PurePosixPath(".DS_Store"), ".DS_Store")
216
217 # ---- Patterns with slash: match full path from right ----
218
219 def test_dir_ext_matches_direct_child(self) -> None:
220 import pathlib as pl
221 assert _matches(pl.PurePosixPath("tracks/drums.bak"), "tracks/*.bak")
222
223 def test_dir_ext_no_match_different_dir(self) -> None:
224 import pathlib as pl
225 assert not _matches(pl.PurePosixPath("exports/drums.bak"), "tracks/*.bak")
226
227 def test_double_star_matches_nested(self) -> None:
228 import pathlib as pl
229 assert _matches(pl.PurePosixPath("a/b/cache/index.dat"), "**/cache/*.dat")
230
231 def test_double_star_matches_shallow(self) -> None:
232 import pathlib as pl
233 # **/cache/*.dat should match cache/index.dat (** = zero components)
234 assert _matches(pl.PurePosixPath("cache/index.dat"), "**/cache/*.dat")
235
236 # ---- Anchored patterns (leading /) ----
237
238 def test_anchored_matches_root_level(self) -> None:
239 import pathlib as pl
240 assert _matches(pl.PurePosixPath("scratch.mid"), "/scratch.mid")
241
242 def test_anchored_no_match_nested(self) -> None:
243 import pathlib as pl
244 assert not _matches(pl.PurePosixPath("tracks/scratch.mid"), "/scratch.mid")
245
246 def test_anchored_dir_pattern_no_match_file(self) -> None:
247 import pathlib as pl
248 # /renders/*.wav anchored to root
249 assert _matches(pl.PurePosixPath("renders/mix.wav"), "/renders/*.wav")
250 assert not _matches(pl.PurePosixPath("exports/renders/mix.wav"), "/renders/*.wav")
251
252
253 # ---------------------------------------------------------------------------
254 # is_ignored — full rule evaluation with negation (unchanged layer)
255 # ---------------------------------------------------------------------------
256
257
258 class TestIsIgnored:
259 def test_empty_patterns_ignores_nothing(self) -> None:
260 assert not is_ignored("tracks/drums.mid", [])
261
262 def test_simple_ext_ignored(self) -> None:
263 assert is_ignored("drums.tmp", ["*.tmp"])
264
265 def test_simple_ext_nested_ignored(self) -> None:
266 assert is_ignored("tracks/drums.tmp", ["*.tmp"])
267
268 def test_non_matching_not_ignored(self) -> None:
269 assert not is_ignored("drums.mid", ["*.tmp"])
270
271 def test_directory_pattern_ignores_files_inside(self) -> None:
272 # Trailing / means "this directory and all its contents" — files inside
273 # the directory are ignored, matching gitignore semantics.
274 assert is_ignored("renders/mix.wav", ["renders/"])
275 assert is_ignored("renders/deep/session.mid", ["renders/"])
276 assert not is_ignored("other/mix.wav", ["renders/"])
277
278 def test_negation_un_ignores(self) -> None:
279 patterns = ["*.bak", "!keep.bak"]
280 assert is_ignored("session.bak", patterns)
281 assert not is_ignored("keep.bak", patterns)
282
283 def test_negation_nested_un_ignores(self) -> None:
284 patterns = ["*.bak", "!tracks/keeper.bak"]
285 assert is_ignored("tracks/session.bak", patterns)
286 assert not is_ignored("tracks/keeper.bak", patterns)
287
288 def test_last_rule_wins(self) -> None:
289 # First rule ignores, second negates, third re-ignores.
290 patterns = ["*.bak", "!session.bak", "*.bak"]
291 assert is_ignored("session.bak", patterns)
292
293 def test_anchored_pattern_root_only(self) -> None:
294 patterns = ["/scratch.mid"]
295 assert is_ignored("scratch.mid", patterns)
296 assert not is_ignored("tracks/scratch.mid", patterns)
297
298 def test_ds_store_at_any_depth(self) -> None:
299 patterns = [".DS_Store"]
300 assert is_ignored(".DS_Store", patterns)
301 assert is_ignored("tracks/.DS_Store", patterns)
302 assert is_ignored("a/b/c/.DS_Store", patterns)
303
304 def test_double_star_glob(self) -> None:
305 # Match *.pyc at any depth using a no-slash pattern.
306 assert is_ignored("__pycache__/foo.pyc", ["*.pyc"])
307 assert is_ignored("tracks/__pycache__/foo.pyc", ["*.pyc"])
308 # Pattern with embedded slash + ** at start.
309 assert is_ignored("cache/index.dat", ["**/cache/*.dat"])
310 assert is_ignored("a/b/cache/index.dat", ["**/cache/*.dat"])
311
312 def test_multiple_patterns_first_matches(self) -> None:
313 patterns = ["*.tmp", "*.bak"]
314 assert is_ignored("drums.tmp", patterns)
315 assert is_ignored("drums.bak", patterns)
316 assert not is_ignored("drums.mid", patterns)
317
318 def test_negation_before_rule_has_no_effect(self) -> None:
319 # Negation appears before the rule it would override — last rule wins,
320 # so the file ends up ignored.
321 patterns = ["!session.bak", "*.bak"]
322 assert is_ignored("session.bak", patterns)
323
324
325 # ---------------------------------------------------------------------------
326 # Integration: MidiPlugin.snapshot() honours .museignore TOML format
327 # ---------------------------------------------------------------------------
328
329
330 class TestMidiPluginSnapshotIgnore:
331 """End-to-end: .museignore TOML format filters paths during snapshot()."""
332
333 def _make_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
334 """Create a minimal repo structure with a state/ directory."""
335 workdir = tmp_path
336 return tmp_path
337
338 def test_snapshot_without_museignore_includes_all(
339 self, tmp_path: pathlib.Path
340 ) -> None:
341 from muse.plugins.midi.plugin import MidiPlugin
342
343 root = self._make_repo(tmp_path)
344 workdir = root
345 (workdir / "beat.mid").write_text("data")
346 (workdir / "session.tmp").write_text("temp")
347
348 plugin = MidiPlugin()
349 snap = plugin.snapshot(workdir)
350 assert "beat.mid" in snap["files"]
351 assert "session.tmp" in snap["files"]
352
353 def test_snapshot_excludes_global_pattern(self, tmp_path: pathlib.Path) -> None:
354 from muse.plugins.midi.plugin import MidiPlugin
355
356 root = self._make_repo(tmp_path)
357 workdir = root
358 (workdir / "beat.mid").write_text("data")
359 (workdir / "session.tmp").write_text("temp")
360 (root / ".museignore").write_text('[global]\npatterns = ["*.tmp"]\n')
361
362 plugin = MidiPlugin()
363 snap = plugin.snapshot(workdir)
364 assert "beat.mid" in snap["files"]
365 assert "session.tmp" not in snap["files"]
366
367 def test_snapshot_excludes_domain_specific_pattern(
368 self, tmp_path: pathlib.Path
369 ) -> None:
370 from muse.plugins.midi.plugin import MidiPlugin
371
372 root = self._make_repo(tmp_path)
373 workdir = root
374 (workdir / "beat.mid").write_text("data")
375 (workdir / "session.bak").write_text("backup")
376 (root / ".museignore").write_text(
377 '[domain.midi]\npatterns = ["*.bak"]\n'
378 )
379
380 plugin = MidiPlugin()
381 snap = plugin.snapshot(workdir)
382 assert "beat.mid" in snap["files"]
383 assert "session.bak" not in snap["files"]
384
385 def test_snapshot_domain_isolation_other_domain_ignored(
386 self, tmp_path: pathlib.Path
387 ) -> None:
388 from muse.plugins.midi.plugin import MidiPlugin
389
390 root = self._make_repo(tmp_path)
391 workdir = root
392 (workdir / "beat.mid").write_text("data")
393 (workdir / "requirements.txt").write_text("pytest\n")
394 # code-only ignore — must NOT apply to the midi plugin.
395 (root / ".museignore").write_text(
396 '[domain.code]\npatterns = ["requirements.txt"]\n'
397 )
398
399 plugin = MidiPlugin()
400 snap = plugin.snapshot(workdir)
401 # requirements.txt should remain because the [domain.code] section
402 # does not apply when the active domain is "midi".
403 assert "requirements.txt" in snap["files"]
404 assert "beat.mid" in snap["files"]
405
406 def test_snapshot_negation_keeps_file(self, tmp_path: pathlib.Path) -> None:
407 from muse.plugins.midi.plugin import MidiPlugin
408
409 root = self._make_repo(tmp_path)
410 workdir = root
411 (workdir / "session.tmp").write_text("temp")
412 (workdir / "important.tmp").write_text("keep me")
413 (root / ".museignore").write_text(
414 '[global]\npatterns = ["*.tmp", "!important.tmp"]\n'
415 )
416
417 plugin = MidiPlugin()
418 snap = plugin.snapshot(workdir)
419 assert "session.tmp" not in snap["files"]
420 assert "important.tmp" in snap["files"]
421
422 def test_snapshot_domain_negation_overrides_global(
423 self, tmp_path: pathlib.Path
424 ) -> None:
425 from muse.plugins.midi.plugin import MidiPlugin
426
427 root = self._make_repo(tmp_path)
428 workdir = root
429 (workdir / "session.bak").write_text("backup")
430 content = (
431 '[global]\npatterns = ["*.bak"]\n'
432 '[domain.midi]\npatterns = ["!session.bak"]\n'
433 )
434 (root / ".museignore").write_text(content)
435
436 plugin = MidiPlugin()
437 snap = plugin.snapshot(workdir)
438 # session.bak is globally ignored but un-ignored by the midi domain section.
439 assert "session.bak" in snap["files"]
440
441 def test_snapshot_nested_pattern(self, tmp_path: pathlib.Path) -> None:
442 from muse.plugins.midi.plugin import MidiPlugin
443
444 root = self._make_repo(tmp_path)
445 workdir = root
446 renders = workdir / "renders"
447 renders.mkdir()
448 (workdir / "beat.mid").write_text("data")
449 (renders / "preview.wav").write_text("audio")
450 (root / ".museignore").write_text(
451 '[global]\npatterns = ["renders/*.wav"]\n'
452 )
453
454 plugin = MidiPlugin()
455 snap = plugin.snapshot(workdir)
456 assert "beat.mid" in snap["files"]
457 assert "renders/preview.wav" not in snap["files"]
458
459 def test_snapshot_dotfiles_always_excluded(self, tmp_path: pathlib.Path) -> None:
460 from muse.plugins.midi.plugin import MidiPlugin
461
462 root = self._make_repo(tmp_path)
463 workdir = root
464 (workdir / "beat.mid").write_text("data")
465 (workdir / ".DS_Store").write_bytes(b"\x00" * 16)
466 # No .museignore — dotfiles excluded by the built-in plugin rule.
467
468 plugin = MidiPlugin()
469 snap = plugin.snapshot(workdir)
470 assert "beat.mid" in snap["files"]
471 assert ".DS_Store" not in snap["files"]
472
473 def test_snapshot_with_empty_museignore(self, tmp_path: pathlib.Path) -> None:
474 from muse.plugins.midi.plugin import MidiPlugin
475
476 root = self._make_repo(tmp_path)
477 workdir = root
478 (workdir / "beat.mid").write_text("data")
479 # Valid TOML — just a comment, no sections.
480 (root / ".museignore").write_text("# empty config\n")
481
482 plugin = MidiPlugin()
483 snap = plugin.snapshot(workdir)
484 assert "beat.mid" in snap["files"]
485
486 def test_snapshot_directory_pattern_excludes_files_inside(
487 self, tmp_path: pathlib.Path
488 ) -> None:
489 from muse.plugins.midi.plugin import MidiPlugin
490
491 root = self._make_repo(tmp_path)
492 workdir = root
493 renders = workdir / "renders"
494 renders.mkdir()
495 (renders / "mix.wav").write_text("audio")
496 # Directory pattern ignores all files inside it — gitignore semantics.
497 (root / ".museignore").write_text('[global]\npatterns = ["renders/"]\n')
498
499 plugin = MidiPlugin()
500 snap = plugin.snapshot(workdir)
501 assert "renders/mix.wav" not in snap["files"]