cgcardona / muse public
test_core_ignore.py python
500 lines 19.4 KB
dfaf1b77 refactor: rename muse-work/ → state/ Gabriel Cardona <gabriel@tellurstori.com> 8h 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_not_applied_to_file(self) -> None:
272 # Trailing / means directory-only; must not ignore a file.
273 assert not is_ignored("renders/mix.wav", ["renders/"])
274
275 def test_negation_un_ignores(self) -> None:
276 patterns = ["*.bak", "!keep.bak"]
277 assert is_ignored("session.bak", patterns)
278 assert not is_ignored("keep.bak", patterns)
279
280 def test_negation_nested_un_ignores(self) -> None:
281 patterns = ["*.bak", "!tracks/keeper.bak"]
282 assert is_ignored("tracks/session.bak", patterns)
283 assert not is_ignored("tracks/keeper.bak", patterns)
284
285 def test_last_rule_wins(self) -> None:
286 # First rule ignores, second negates, third re-ignores.
287 patterns = ["*.bak", "!session.bak", "*.bak"]
288 assert is_ignored("session.bak", patterns)
289
290 def test_anchored_pattern_root_only(self) -> None:
291 patterns = ["/scratch.mid"]
292 assert is_ignored("scratch.mid", patterns)
293 assert not is_ignored("tracks/scratch.mid", patterns)
294
295 def test_ds_store_at_any_depth(self) -> None:
296 patterns = [".DS_Store"]
297 assert is_ignored(".DS_Store", patterns)
298 assert is_ignored("tracks/.DS_Store", patterns)
299 assert is_ignored("a/b/c/.DS_Store", patterns)
300
301 def test_double_star_glob(self) -> None:
302 # Match *.pyc at any depth using a no-slash pattern.
303 assert is_ignored("__pycache__/foo.pyc", ["*.pyc"])
304 assert is_ignored("tracks/__pycache__/foo.pyc", ["*.pyc"])
305 # Pattern with embedded slash + ** at start.
306 assert is_ignored("cache/index.dat", ["**/cache/*.dat"])
307 assert is_ignored("a/b/cache/index.dat", ["**/cache/*.dat"])
308
309 def test_multiple_patterns_first_matches(self) -> None:
310 patterns = ["*.tmp", "*.bak"]
311 assert is_ignored("drums.tmp", patterns)
312 assert is_ignored("drums.bak", patterns)
313 assert not is_ignored("drums.mid", patterns)
314
315 def test_negation_before_rule_has_no_effect(self) -> None:
316 # Negation appears before the rule it would override — last rule wins,
317 # so the file ends up ignored.
318 patterns = ["!session.bak", "*.bak"]
319 assert is_ignored("session.bak", patterns)
320
321
322 # ---------------------------------------------------------------------------
323 # Integration: MidiPlugin.snapshot() honours .museignore TOML format
324 # ---------------------------------------------------------------------------
325
326
327 class TestMidiPluginSnapshotIgnore:
328 """End-to-end: .museignore TOML format filters paths during snapshot()."""
329
330 def _make_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
331 """Create a minimal repo structure with a state/ directory."""
332 workdir = tmp_path / "state"
333 workdir.mkdir()
334 return tmp_path
335
336 def test_snapshot_without_museignore_includes_all(
337 self, tmp_path: pathlib.Path
338 ) -> None:
339 from muse.plugins.midi.plugin import MidiPlugin
340
341 root = self._make_repo(tmp_path)
342 workdir = root / "state"
343 (workdir / "beat.mid").write_text("data")
344 (workdir / "session.tmp").write_text("temp")
345
346 plugin = MidiPlugin()
347 snap = plugin.snapshot(workdir)
348 assert "beat.mid" in snap["files"]
349 assert "session.tmp" in snap["files"]
350
351 def test_snapshot_excludes_global_pattern(self, tmp_path: pathlib.Path) -> None:
352 from muse.plugins.midi.plugin import MidiPlugin
353
354 root = self._make_repo(tmp_path)
355 workdir = root / "state"
356 (workdir / "beat.mid").write_text("data")
357 (workdir / "session.tmp").write_text("temp")
358 (root / ".museignore").write_text('[global]\npatterns = ["*.tmp"]\n')
359
360 plugin = MidiPlugin()
361 snap = plugin.snapshot(workdir)
362 assert "beat.mid" in snap["files"]
363 assert "session.tmp" not in snap["files"]
364
365 def test_snapshot_excludes_domain_specific_pattern(
366 self, tmp_path: pathlib.Path
367 ) -> None:
368 from muse.plugins.midi.plugin import MidiPlugin
369
370 root = self._make_repo(tmp_path)
371 workdir = root / "state"
372 (workdir / "beat.mid").write_text("data")
373 (workdir / "session.bak").write_text("backup")
374 (root / ".museignore").write_text(
375 '[domain.midi]\npatterns = ["*.bak"]\n'
376 )
377
378 plugin = MidiPlugin()
379 snap = plugin.snapshot(workdir)
380 assert "beat.mid" in snap["files"]
381 assert "session.bak" not in snap["files"]
382
383 def test_snapshot_domain_isolation_other_domain_ignored(
384 self, tmp_path: pathlib.Path
385 ) -> None:
386 from muse.plugins.midi.plugin import MidiPlugin
387
388 root = self._make_repo(tmp_path)
389 workdir = root / "state"
390 (workdir / "beat.mid").write_text("data")
391 (workdir / "requirements.txt").write_text("pytest\n")
392 # code-only ignore — must NOT apply to the midi plugin.
393 (root / ".museignore").write_text(
394 '[domain.code]\npatterns = ["requirements.txt"]\n'
395 )
396
397 plugin = MidiPlugin()
398 snap = plugin.snapshot(workdir)
399 # requirements.txt should remain because the [domain.code] section
400 # does not apply when the active domain is "midi".
401 assert "requirements.txt" in snap["files"]
402 assert "beat.mid" in snap["files"]
403
404 def test_snapshot_negation_keeps_file(self, tmp_path: pathlib.Path) -> None:
405 from muse.plugins.midi.plugin import MidiPlugin
406
407 root = self._make_repo(tmp_path)
408 workdir = root / "state"
409 (workdir / "session.tmp").write_text("temp")
410 (workdir / "important.tmp").write_text("keep me")
411 (root / ".museignore").write_text(
412 '[global]\npatterns = ["*.tmp", "!important.tmp"]\n'
413 )
414
415 plugin = MidiPlugin()
416 snap = plugin.snapshot(workdir)
417 assert "session.tmp" not in snap["files"]
418 assert "important.tmp" in snap["files"]
419
420 def test_snapshot_domain_negation_overrides_global(
421 self, tmp_path: pathlib.Path
422 ) -> None:
423 from muse.plugins.midi.plugin import MidiPlugin
424
425 root = self._make_repo(tmp_path)
426 workdir = root / "state"
427 (workdir / "session.bak").write_text("backup")
428 content = (
429 '[global]\npatterns = ["*.bak"]\n'
430 '[domain.midi]\npatterns = ["!session.bak"]\n'
431 )
432 (root / ".museignore").write_text(content)
433
434 plugin = MidiPlugin()
435 snap = plugin.snapshot(workdir)
436 # session.bak is globally ignored but un-ignored by the midi domain section.
437 assert "session.bak" in snap["files"]
438
439 def test_snapshot_nested_pattern(self, tmp_path: pathlib.Path) -> None:
440 from muse.plugins.midi.plugin import MidiPlugin
441
442 root = self._make_repo(tmp_path)
443 workdir = root / "state"
444 renders = workdir / "renders"
445 renders.mkdir()
446 (workdir / "beat.mid").write_text("data")
447 (renders / "preview.wav").write_text("audio")
448 (root / ".museignore").write_text(
449 '[global]\npatterns = ["renders/*.wav"]\n'
450 )
451
452 plugin = MidiPlugin()
453 snap = plugin.snapshot(workdir)
454 assert "beat.mid" in snap["files"]
455 assert "renders/preview.wav" not in snap["files"]
456
457 def test_snapshot_dotfiles_always_excluded(self, tmp_path: pathlib.Path) -> None:
458 from muse.plugins.midi.plugin import MidiPlugin
459
460 root = self._make_repo(tmp_path)
461 workdir = root / "state"
462 (workdir / "beat.mid").write_text("data")
463 (workdir / ".DS_Store").write_bytes(b"\x00" * 16)
464 # No .museignore — dotfiles excluded by the built-in plugin rule.
465
466 plugin = MidiPlugin()
467 snap = plugin.snapshot(workdir)
468 assert "beat.mid" in snap["files"]
469 assert ".DS_Store" not in snap["files"]
470
471 def test_snapshot_with_empty_museignore(self, tmp_path: pathlib.Path) -> None:
472 from muse.plugins.midi.plugin import MidiPlugin
473
474 root = self._make_repo(tmp_path)
475 workdir = root / "state"
476 (workdir / "beat.mid").write_text("data")
477 # Valid TOML — just a comment, no sections.
478 (root / ".museignore").write_text("# empty config\n")
479
480 plugin = MidiPlugin()
481 snap = plugin.snapshot(workdir)
482 assert "beat.mid" in snap["files"]
483
484 def test_snapshot_directory_pattern_does_not_exclude_file(
485 self, tmp_path: pathlib.Path
486 ) -> None:
487 from muse.plugins.midi.plugin import MidiPlugin
488
489 root = self._make_repo(tmp_path)
490 workdir = root / "state"
491 renders = workdir / "renders"
492 renders.mkdir()
493 (renders / "mix.wav").write_text("audio")
494 # Directory-only pattern — should not exclude files.
495 (root / ".museignore").write_text('[global]\npatterns = ["renders/"]\n')
496
497 plugin = MidiPlugin()
498 snap = plugin.snapshot(workdir)
499 # The file is NOT excluded because trailing-/ patterns are directory-only.
500 assert "renders/mix.wav" in snap["files"]