gabriel / muse public
ignore.py python
278 lines 10.4 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Muse ignore — ``.museignore`` TOML parser and workspace path filter.
2
3 ``.museignore`` uses TOML with two kinds of sections:
4
5 ``[global]``
6 Patterns applied to every domain. Evaluated first, in array order.
7
8 ``[domain.<name>]``
9 Patterns applied only when the active domain is *<name>*. Appended
10 after global patterns and evaluated in array order.
11
12 Pattern syntax (gitignore-compatible):
13
14 - A trailing ``/`` marks a directory pattern; it is never matched against
15 individual files (Muse VCS tracks files, not directories).
16 - A leading ``/`` **anchors** the pattern to the repository root, so
17 ``/tmp/*.mid`` matches only ``tmp/drums.mid`` and not ``cache/tmp/drums.mid``.
18 - A leading ``!`` **negates** a pattern: a path previously matched by an ignore
19 rule is un-ignored when it matches a subsequent negation rule.
20 - ``*`` matches any sequence of characters **except** a path separator (``/``).
21 - ``**`` matches any sequence of characters **including** path separators.
22 - All other characters are matched literally.
23
24 Rule evaluation
25 ---------------
26 Patterns are evaluated in the order they appear (global first, then
27 domain-specific). The **last matching rule wins**, mirroring gitignore
28 behaviour. A later ``!important.tmp`` overrides an earlier ``*.tmp`` for
29 that specific path.
30
31 Public API
32 ----------
33 - :func:`load_ignore_config` — parse ``.museignore`` → :data:`MuseIgnoreConfig`
34 - :func:`resolve_patterns` — flatten config to ``list[str]`` for a domain
35 - :func:`is_ignored` — test a relative POSIX path against a pattern list
36 """
37
38 from __future__ import annotations
39
40 import fnmatch
41 import pathlib
42 import tomllib
43 from typing import TypedDict
44
45
46 type _DomainMap = dict[str, "DomainSection"]
47 _FILENAME = ".museignore"
48
49
50 class DomainSection(TypedDict, total=False):
51 """Patterns for one ignore section (global or a named domain)."""
52
53 patterns: list[str]
54
55
56 # ``global`` is a Python keyword, so we use the functional TypedDict form.
57 MuseIgnoreConfig = TypedDict(
58 "MuseIgnoreConfig",
59 {
60 "global": DomainSection,
61 "domain": _DomainMap,
62 },
63 total=False,
64 )
65
66
67 def load_ignore_config(root: pathlib.Path) -> MuseIgnoreConfig:
68 """Read ``.museignore`` from *root* and return the parsed configuration.
69
70 Builds :data:`MuseIgnoreConfig` from the raw TOML dict using explicit
71 ``isinstance`` checks — no ``Any`` propagated into the return value.
72
73 Args:
74 root: Repository root directory (the directory that contains ``.muse/``
75 and ``state/``). The ``.museignore`` file, if present, lives
76 directly inside *root*.
77
78 Returns:
79 A :data:`MuseIgnoreConfig` mapping. Both the ``"global"`` key and the
80 ``"domain"`` key are optional; use :func:`resolve_patterns` which
81 handles all missing-key cases. Returns an empty mapping when
82 ``.museignore`` is absent.
83
84 Raises:
85 ValueError: When ``.museignore`` exists but contains invalid TOML.
86 """
87 ignore_file = root / _FILENAME
88 if not ignore_file.exists():
89 return {}
90
91 raw_bytes = ignore_file.read_bytes()
92 try:
93 raw = tomllib.loads(raw_bytes.decode("utf-8"))
94 except tomllib.TOMLDecodeError as exc:
95 raise ValueError(f"{_FILENAME}: TOML parse error — {exc}") from exc
96
97 result: MuseIgnoreConfig = {}
98
99 # [global] section
100 global_raw = raw.get("global")
101 if isinstance(global_raw, dict):
102 global_section: DomainSection = {}
103 global_patterns_val = global_raw.get("patterns")
104 if isinstance(global_patterns_val, list):
105 global_section["patterns"] = [
106 p for p in global_patterns_val if isinstance(p, str)
107 ]
108 result["global"] = global_section
109
110 # [domain.*] sections — each key under [domain] is a domain name.
111 domain_raw = raw.get("domain")
112 if isinstance(domain_raw, dict):
113 domain_map: _DomainMap = {}
114 for domain_name, domain_val in domain_raw.items():
115 if isinstance(domain_name, str) and isinstance(domain_val, dict):
116 section: DomainSection = {}
117 domain_patterns_val = domain_val.get("patterns")
118 if isinstance(domain_patterns_val, list):
119 section["patterns"] = [
120 p for p in domain_patterns_val if isinstance(p, str)
121 ]
122 domain_map[domain_name] = section
123 result["domain"] = domain_map
124
125 return result
126
127
128 def resolve_patterns(config: MuseIgnoreConfig, domain: str) -> list[str]:
129 """Flatten *config* into an ordered pattern list for *domain*.
130
131 Global patterns come first (in array order), followed by domain-specific
132 patterns. Patterns declared under any other domain are never included.
133
134 Args:
135 config: Parsed ignore configuration from :func:`load_ignore_config`.
136 domain: The active domain name, e.g. ``"music"`` or ``"code"``.
137
138 Returns:
139 Ordered ``list[str]`` of raw glob pattern strings. Returns an empty
140 list when *config* is empty or neither section contains patterns.
141 """
142 global_patterns: list[str] = []
143 if "global" in config:
144 global_section = config["global"]
145 if "patterns" in global_section:
146 global_patterns = global_section["patterns"]
147
148 domain_patterns: list[str] = []
149 if "domain" in config:
150 domain_map = config["domain"]
151 if domain in domain_map:
152 domain_section = domain_map[domain]
153 if "patterns" in domain_section:
154 domain_patterns = domain_section["patterns"]
155
156 return global_patterns + domain_patterns
157
158
159 def check_path_with_pattern(
160 rel_posix: str,
161 patterns: list[str],
162 ) -> tuple[bool, str | None]:
163 """Return ``(ignored, matching_pattern)`` for *rel_posix*.
164
165 Identical semantics to :func:`is_ignored` but also returns the last pattern
166 that caused *ignored* to be ``True``, or ``None`` when the path is not
167 ignored (either never matched, or un-ignored by a ``!negation`` rule).
168
169 This is the authoritative matching implementation. ``muse plumbing
170 check-ignore`` uses it directly so the plumbing command and the snapshot
171 engine always agree on ignore semantics.
172
173 Args:
174 rel_posix: Workspace-relative POSIX path, e.g. ``"tracks/drums.mid"``.
175 patterns: Ordered pattern list from :func:`resolve_patterns`.
176
177 Returns:
178 A 2-tuple ``(ignored: bool, matching_pattern: str | None)``.
179 ``matching_pattern`` is the last pattern that set ``ignored = True``,
180 or ``None`` when the path ended up not ignored.
181 """
182 # Hot path: avoid constructing a PurePosixPath for every call — the pure-
183 # string _matches is 7-8× faster for the common case of 10-20 patterns
184 # evaluated against every file in a large working tree.
185 ignored = False
186 matching: str | None = None
187
188 for pattern in patterns:
189 negate = pattern.startswith("!")
190 pat = pattern[1:] if negate else pattern
191
192 matched = False
193 if pat.endswith("/"):
194 dir_prefix = pat
195 if rel_posix.startswith(dir_prefix) or _matches(rel_posix, pat.rstrip("/") + "/**"):
196 matched = True
197 elif _matches(rel_posix, pat):
198 matched = True
199
200 if matched:
201 ignored = not negate
202 matching = pattern if not negate else None
203
204 return ignored, matching
205
206
207 def is_ignored(rel_posix: str, patterns: list[str]) -> bool:
208 """Return ``True`` if *rel_posix* should be excluded from the snapshot.
209
210 Args:
211 rel_posix: Workspace-relative POSIX path, e.g. ``"tracks/drums.mid"``.
212 patterns: Ordered pattern list from :func:`resolve_patterns`.
213
214 Returns:
215 ``True`` when the path is ignored, ``False`` otherwise. An empty
216 *patterns* list means nothing is ignored.
217
218 The last matching rule wins. A negation rule (``!pattern``) can un-ignore
219 a path that was matched by an earlier rule.
220
221 Directory patterns (trailing ``/``) match any file whose path starts with
222 that directory prefix — e.g. ``artifacts/`` ignores ``artifacts/demo.html``.
223
224 Delegates to :func:`check_path_with_pattern` — the single authoritative
225 matching loop — to guarantee that this function and ``muse plumbing
226 check-ignore`` always agree on ignore semantics.
227 """
228 ignored, _ = check_path_with_pattern(rel_posix, patterns)
229 return ignored
230
231
232 def _matches(path_str: str, pattern: str) -> bool:
233 """Return ``True`` if *path_str* matches *pattern*.
234
235 Implements gitignore path-matching semantics using pure string operations
236 and :func:`fnmatch.fnmatch` — no :class:`pathlib.PurePosixPath` objects
237 are constructed. This keeps the per-file overhead negligible even with
238 20+ patterns evaluated against every file in a 75 000-file working tree.
239
240 Semantics:
241
242 - **Anchored** (leading ``/``): matched against the full relative path
243 with the leading slash stripped.
244 - **Pattern with embedded ``/``**: matched against the full relative path
245 via ``fnmatch.fnmatch``. A leading ``**/`` is additionally tried
246 against the path with the prefix stripped (handles ``**/cache/*.dat``
247 matching ``cache/index.dat``).
248 - **Pattern without ``/``**: matched against the filename and every
249 trailing suffix of the path so that ``*.tmp`` matches ``drums.tmp``
250 *and* ``tracks/drums.tmp``.
251
252 Args:
253 path_str: Workspace-relative POSIX path as a plain string
254 (e.g. ``"tracks/drums.mid"``).
255 pattern: A single gitignore-style pattern (no leading ``!``).
256 """
257 # Anchored: match the full path from the root.
258 if pattern.startswith("/"):
259 return fnmatch.fnmatch(path_str, pattern[1:])
260
261 # Embedded slash: match from the right.
262 # ``**/foo/*.dat`` is tried first against the full path; if that fails,
263 # the ``**/`` prefix is stripped and tried again — this makes the pattern
264 # match paths that do not have any leading components (e.g. ``foo/a.dat``).
265 if "/" in pattern:
266 if fnmatch.fnmatch(path_str, pattern):
267 return True
268 if pattern.startswith("**/"):
269 return fnmatch.fnmatch(path_str, pattern[3:])
270 return False
271
272 # No slash: match against the filename and every trailing suffix so that
273 # ``*.tmp`` matches both ``drums.tmp`` and ``tracks/drums.tmp``.
274 parts = path_str.split("/")
275 for i in range(len(parts)):
276 if fnmatch.fnmatch("/".join(parts[i:]), pattern):
277 return True
278 return False
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago