cgcardona / muse public
test_core_ignore.py python
306 lines 12.0 KB
5f1a074d feat: implement .museignore — gitignore-style snapshot exclusion (#7) Gabriel Cardona <cgcardona@gmail.com> 3d ago
1 """Tests for muse/core/ignore.py — .museignore parser and path filter."""
2 from __future__ import annotations
3
4 import pathlib
5
6 import pytest
7
8 from muse.core.ignore import _matches, is_ignored, load_patterns
9
10
11 # ---------------------------------------------------------------------------
12 # load_patterns
13 # ---------------------------------------------------------------------------
14
15
16 class TestLoadPatterns:
17 def test_returns_empty_when_no_file(self, tmp_path: pathlib.Path) -> None:
18 assert load_patterns(tmp_path) == []
19
20 def test_reads_patterns(self, tmp_path: pathlib.Path) -> None:
21 (tmp_path / ".museignore").write_text("*.tmp\n*.bak\n")
22 assert load_patterns(tmp_path) == ["*.tmp", "*.bak"]
23
24 def test_strips_blank_lines(self, tmp_path: pathlib.Path) -> None:
25 (tmp_path / ".museignore").write_text("\n*.tmp\n\n*.bak\n\n")
26 assert load_patterns(tmp_path) == ["*.tmp", "*.bak"]
27
28 def test_strips_comments(self, tmp_path: pathlib.Path) -> None:
29 (tmp_path / ".museignore").write_text(
30 "# ignore backups\n*.bak\n# and temps\n*.tmp\n"
31 )
32 assert load_patterns(tmp_path) == ["*.bak", "*.tmp"]
33
34 def test_strips_whitespace_around_patterns(self, tmp_path: pathlib.Path) -> None:
35 (tmp_path / ".museignore").write_text(" *.tmp \n *.bak \n")
36 assert load_patterns(tmp_path) == ["*.tmp", "*.bak"]
37
38 def test_inline_comment_not_stripped(self, tmp_path: pathlib.Path) -> None:
39 # Only leading-# lines are comments; inline # is part of the pattern.
40 (tmp_path / ".museignore").write_text("*.tmp # not a comment\n")
41 assert load_patterns(tmp_path) == ["*.tmp # not a comment"]
42
43 def test_negation_pattern_preserved(self, tmp_path: pathlib.Path) -> None:
44 (tmp_path / ".museignore").write_text("*.bak\n!keep.bak\n")
45 assert load_patterns(tmp_path) == ["*.bak", "!keep.bak"]
46
47 def test_empty_file(self, tmp_path: pathlib.Path) -> None:
48 (tmp_path / ".museignore").write_text("")
49 assert load_patterns(tmp_path) == []
50
51
52 # ---------------------------------------------------------------------------
53 # _matches (internal — gitignore path semantics)
54 # ---------------------------------------------------------------------------
55
56
57 class TestMatchesInternal:
58 """Verify the core matching logic in isolation."""
59
60 # ---- Patterns without slash: match any component ----
61
62 def test_ext_pattern_matches_top_level(self) -> None:
63 import pathlib as pl
64 assert _matches(pl.PurePosixPath("drums.tmp"), "*.tmp")
65
66 def test_ext_pattern_matches_nested(self) -> None:
67 import pathlib as pl
68 assert _matches(pl.PurePosixPath("tracks/drums.tmp"), "*.tmp")
69
70 def test_ext_pattern_matches_deep_nested(self) -> None:
71 import pathlib as pl
72 assert _matches(pl.PurePosixPath("a/b/c/drums.tmp"), "*.tmp")
73
74 def test_ext_pattern_no_false_positive(self) -> None:
75 import pathlib as pl
76 assert not _matches(pl.PurePosixPath("tracks/drums.mid"), "*.tmp")
77
78 def test_exact_name_matches_any_depth(self) -> None:
79 import pathlib as pl
80 assert _matches(pl.PurePosixPath("a/b/.DS_Store"), ".DS_Store")
81
82 def test_exact_name_top_level(self) -> None:
83 import pathlib as pl
84 assert _matches(pl.PurePosixPath(".DS_Store"), ".DS_Store")
85
86 # ---- Patterns with slash: match full path from right ----
87
88 def test_dir_ext_matches_direct_child(self) -> None:
89 import pathlib as pl
90 assert _matches(pl.PurePosixPath("tracks/drums.bak"), "tracks/*.bak")
91
92 def test_dir_ext_no_match_different_dir(self) -> None:
93 import pathlib as pl
94 assert not _matches(pl.PurePosixPath("exports/drums.bak"), "tracks/*.bak")
95
96 def test_double_star_matches_nested(self) -> None:
97 import pathlib as pl
98 assert _matches(pl.PurePosixPath("a/b/cache/index.dat"), "**/cache/*.dat")
99
100 def test_double_star_matches_shallow(self) -> None:
101 import pathlib as pl
102 # **/cache/*.dat should match cache/index.dat (** = zero components)
103 assert _matches(pl.PurePosixPath("cache/index.dat"), "**/cache/*.dat")
104
105 # ---- Anchored patterns (leading /) ----
106
107 def test_anchored_matches_root_level(self) -> None:
108 import pathlib as pl
109 assert _matches(pl.PurePosixPath("scratch.mid"), "/scratch.mid")
110
111 def test_anchored_no_match_nested(self) -> None:
112 import pathlib as pl
113 assert not _matches(pl.PurePosixPath("tracks/scratch.mid"), "/scratch.mid")
114
115 def test_anchored_dir_pattern_no_match_file(self) -> None:
116 import pathlib as pl
117 # /renders/*.wav anchored to root
118 assert _matches(pl.PurePosixPath("renders/mix.wav"), "/renders/*.wav")
119 assert not _matches(pl.PurePosixPath("exports/renders/mix.wav"), "/renders/*.wav")
120
121
122 # ---------------------------------------------------------------------------
123 # is_ignored — full rule evaluation with negation
124 # ---------------------------------------------------------------------------
125
126
127 class TestIsIgnored:
128 def test_empty_patterns_ignores_nothing(self) -> None:
129 assert not is_ignored("tracks/drums.mid", [])
130
131 def test_simple_ext_ignored(self) -> None:
132 assert is_ignored("drums.tmp", ["*.tmp"])
133
134 def test_simple_ext_nested_ignored(self) -> None:
135 assert is_ignored("tracks/drums.tmp", ["*.tmp"])
136
137 def test_non_matching_not_ignored(self) -> None:
138 assert not is_ignored("drums.mid", ["*.tmp"])
139
140 def test_directory_pattern_not_applied_to_file(self) -> None:
141 # Trailing / means directory-only; must not ignore a file.
142 assert not is_ignored("renders/mix.wav", ["renders/"])
143
144 def test_negation_un_ignores(self) -> None:
145 patterns = ["*.bak", "!keep.bak"]
146 assert is_ignored("session.bak", patterns)
147 assert not is_ignored("keep.bak", patterns)
148
149 def test_negation_nested_un_ignores(self) -> None:
150 patterns = ["*.bak", "!tracks/keeper.bak"]
151 assert is_ignored("tracks/session.bak", patterns)
152 assert not is_ignored("tracks/keeper.bak", patterns)
153
154 def test_last_rule_wins(self) -> None:
155 # First rule ignores, second negates, third re-ignores.
156 patterns = ["*.bak", "!session.bak", "*.bak"]
157 assert is_ignored("session.bak", patterns)
158
159 def test_anchored_pattern_root_only(self) -> None:
160 patterns = ["/scratch.mid"]
161 assert is_ignored("scratch.mid", patterns)
162 assert not is_ignored("tracks/scratch.mid", patterns)
163
164 def test_ds_store_at_any_depth(self) -> None:
165 patterns = [".DS_Store"]
166 assert is_ignored(".DS_Store", patterns)
167 assert is_ignored("tracks/.DS_Store", patterns)
168 assert is_ignored("a/b/c/.DS_Store", patterns)
169
170 def test_double_star_glob(self) -> None:
171 # Match *.pyc at any depth using a no-slash pattern.
172 assert is_ignored("__pycache__/foo.pyc", ["*.pyc"])
173 assert is_ignored("tracks/__pycache__/foo.pyc", ["*.pyc"])
174 # Pattern with embedded slash + ** at start.
175 assert is_ignored("cache/index.dat", ["**/cache/*.dat"])
176 assert is_ignored("a/b/cache/index.dat", ["**/cache/*.dat"])
177
178 def test_multiple_patterns_first_matches(self) -> None:
179 patterns = ["*.tmp", "*.bak"]
180 assert is_ignored("drums.tmp", patterns)
181 assert is_ignored("drums.bak", patterns)
182 assert not is_ignored("drums.mid", patterns)
183
184 def test_negation_before_rule_has_no_effect(self) -> None:
185 # Negation appears before the rule it would override — last rule wins,
186 # so the file ends up ignored.
187 patterns = ["!session.bak", "*.bak"]
188 assert is_ignored("session.bak", patterns)
189
190
191 # ---------------------------------------------------------------------------
192 # Integration: MusicPlugin.snapshot() honours .museignore
193 # ---------------------------------------------------------------------------
194
195
196 class TestMusicPluginSnapshotIgnore:
197 """End-to-end: .museignore filters out paths during snapshot()."""
198
199 def _make_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
200 """Create a minimal repo structure with a muse-work/ directory."""
201 workdir = tmp_path / "muse-work"
202 workdir.mkdir()
203 return tmp_path
204
205 def test_snapshot_without_museignore_includes_all(
206 self, tmp_path: pathlib.Path
207 ) -> None:
208 from muse.plugins.music.plugin import MusicPlugin
209
210 root = self._make_repo(tmp_path)
211 workdir = root / "muse-work"
212 (workdir / "beat.mid").write_text("data")
213 (workdir / "session.tmp").write_text("temp")
214
215 plugin = MusicPlugin()
216 snap = plugin.snapshot(workdir)
217 assert "beat.mid" in snap["files"]
218 assert "session.tmp" in snap["files"]
219
220 def test_snapshot_excludes_ignored_files(self, tmp_path: pathlib.Path) -> None:
221 from muse.plugins.music.plugin import MusicPlugin
222
223 root = self._make_repo(tmp_path)
224 workdir = root / "muse-work"
225 (workdir / "beat.mid").write_text("data")
226 (workdir / "session.tmp").write_text("temp")
227 (root / ".museignore").write_text("*.tmp\n")
228
229 plugin = MusicPlugin()
230 snap = plugin.snapshot(workdir)
231 assert "beat.mid" in snap["files"]
232 assert "session.tmp" not in snap["files"]
233
234 def test_snapshot_negation_keeps_file(self, tmp_path: pathlib.Path) -> None:
235 from muse.plugins.music.plugin import MusicPlugin
236
237 root = self._make_repo(tmp_path)
238 workdir = root / "muse-work"
239 (workdir / "session.tmp").write_text("temp")
240 (workdir / "important.tmp").write_text("keep me")
241 (root / ".museignore").write_text("*.tmp\n!important.tmp\n")
242
243 plugin = MusicPlugin()
244 snap = plugin.snapshot(workdir)
245 assert "session.tmp" not in snap["files"]
246 assert "important.tmp" in snap["files"]
247
248 def test_snapshot_nested_pattern(self, tmp_path: pathlib.Path) -> None:
249 from muse.plugins.music.plugin import MusicPlugin
250
251 root = self._make_repo(tmp_path)
252 workdir = root / "muse-work"
253 renders = workdir / "renders"
254 renders.mkdir()
255 (workdir / "beat.mid").write_text("data")
256 (renders / "preview.wav").write_text("audio")
257 (root / ".museignore").write_text("renders/*.wav\n")
258
259 plugin = MusicPlugin()
260 snap = plugin.snapshot(workdir)
261 assert "beat.mid" in snap["files"]
262 assert "renders/preview.wav" not in snap["files"]
263
264 def test_snapshot_dotfiles_always_excluded(self, tmp_path: pathlib.Path) -> None:
265 from muse.plugins.music.plugin import MusicPlugin
266
267 root = self._make_repo(tmp_path)
268 workdir = root / "muse-work"
269 (workdir / "beat.mid").write_text("data")
270 (workdir / ".DS_Store").write_bytes(b"\x00" * 16)
271 # No .museignore — dotfiles excluded by the built-in rule.
272
273 plugin = MusicPlugin()
274 snap = plugin.snapshot(workdir)
275 assert "beat.mid" in snap["files"]
276 assert ".DS_Store" not in snap["files"]
277
278 def test_snapshot_with_empty_museignore(self, tmp_path: pathlib.Path) -> None:
279 from muse.plugins.music.plugin import MusicPlugin
280
281 root = self._make_repo(tmp_path)
282 workdir = root / "muse-work"
283 (workdir / "beat.mid").write_text("data")
284 (root / ".museignore").write_text("# just a comment\n\n")
285
286 plugin = MusicPlugin()
287 snap = plugin.snapshot(workdir)
288 assert "beat.mid" in snap["files"]
289
290 def test_snapshot_directory_pattern_does_not_exclude_file(
291 self, tmp_path: pathlib.Path
292 ) -> None:
293 from muse.plugins.music.plugin import MusicPlugin
294
295 root = self._make_repo(tmp_path)
296 workdir = root / "muse-work"
297 renders = workdir / "renders"
298 renders.mkdir()
299 (renders / "mix.wav").write_text("audio")
300 # Directory-only pattern — should not exclude files.
301 (root / ".museignore").write_text("renders/\n")
302
303 plugin = MusicPlugin()
304 snap = plugin.snapshot(workdir)
305 # The file is NOT excluded because trailing-/ patterns are directory-only.
306 assert "renders/mix.wav" in snap["files"]