"""Muse ignore — ``.museignore`` TOML parser and workspace path filter. ``.museignore`` uses TOML with two kinds of sections: ``[global]`` Patterns applied to every domain. Evaluated first, in array order. ``[domain.]`` Patterns applied only when the active domain is **. Appended after global patterns and evaluated in array order. Pattern syntax (gitignore-compatible): - A trailing ``/`` marks a directory pattern; it is never matched against individual files (Muse VCS tracks files, not directories). - A leading ``/`` **anchors** the pattern to the repository root, so ``/tmp/*.mid`` matches only ``tmp/drums.mid`` and not ``cache/tmp/drums.mid``. - A leading ``!`` **negates** a pattern: a path previously matched by an ignore rule is un-ignored when it matches a subsequent negation rule. - ``*`` matches any sequence of characters **except** a path separator (``/``). - ``**`` matches any sequence of characters **including** path separators. - All other characters are matched literally. Rule evaluation --------------- Patterns are evaluated in the order they appear (global first, then domain-specific). The **last matching rule wins**, mirroring gitignore behaviour. A later ``!important.tmp`` overrides an earlier ``*.tmp`` for that specific path. Public API ---------- - :func:`load_ignore_config` — parse ``.museignore`` → :data:`MuseIgnoreConfig` - :func:`resolve_patterns` — flatten config to ``list[str]`` for a domain - :func:`is_ignored` — test a relative POSIX path against a pattern list """ from __future__ import annotations import fnmatch import pathlib import tomllib from typing import TypedDict type _DomainMap = dict[str, "DomainSection"] _FILENAME = ".museignore" class DomainSection(TypedDict, total=False): """Patterns for one ignore section (global or a named domain).""" patterns: list[str] # ``global`` is a Python keyword, so we use the functional TypedDict form. MuseIgnoreConfig = TypedDict( "MuseIgnoreConfig", { "global": DomainSection, "domain": _DomainMap, }, total=False, ) def load_ignore_config(root: pathlib.Path) -> MuseIgnoreConfig: """Read ``.museignore`` from *root* and return the parsed configuration. Builds :data:`MuseIgnoreConfig` from the raw TOML dict using explicit ``isinstance`` checks — no ``Any`` propagated into the return value. Args: root: Repository root directory (the directory that contains ``.muse/`` and ``state/``). The ``.museignore`` file, if present, lives directly inside *root*. Returns: A :data:`MuseIgnoreConfig` mapping. Both the ``"global"`` key and the ``"domain"`` key are optional; use :func:`resolve_patterns` which handles all missing-key cases. Returns an empty mapping when ``.museignore`` is absent. Raises: ValueError: When ``.museignore`` exists but contains invalid TOML. """ ignore_file = root / _FILENAME if not ignore_file.exists(): return {} raw_bytes = ignore_file.read_bytes() try: raw = tomllib.loads(raw_bytes.decode("utf-8")) except tomllib.TOMLDecodeError as exc: raise ValueError(f"{_FILENAME}: TOML parse error — {exc}") from exc result: MuseIgnoreConfig = {} # [global] section global_raw = raw.get("global") if isinstance(global_raw, dict): global_section: DomainSection = {} global_patterns_val = global_raw.get("patterns") if isinstance(global_patterns_val, list): global_section["patterns"] = [ p for p in global_patterns_val if isinstance(p, str) ] result["global"] = global_section # [domain.*] sections — each key under [domain] is a domain name. domain_raw = raw.get("domain") if isinstance(domain_raw, dict): domain_map: _DomainMap = {} for domain_name, domain_val in domain_raw.items(): if isinstance(domain_name, str) and isinstance(domain_val, dict): section: DomainSection = {} domain_patterns_val = domain_val.get("patterns") if isinstance(domain_patterns_val, list): section["patterns"] = [ p for p in domain_patterns_val if isinstance(p, str) ] domain_map[domain_name] = section result["domain"] = domain_map return result def resolve_patterns(config: MuseIgnoreConfig, domain: str) -> list[str]: """Flatten *config* into an ordered pattern list for *domain*. Global patterns come first (in array order), followed by domain-specific patterns. Patterns declared under any other domain are never included. Args: config: Parsed ignore configuration from :func:`load_ignore_config`. domain: The active domain name, e.g. ``"music"`` or ``"code"``. Returns: Ordered ``list[str]`` of raw glob pattern strings. Returns an empty list when *config* is empty or neither section contains patterns. """ global_patterns: list[str] = [] if "global" in config: global_section = config["global"] if "patterns" in global_section: global_patterns = global_section["patterns"] domain_patterns: list[str] = [] if "domain" in config: domain_map = config["domain"] if domain in domain_map: domain_section = domain_map[domain] if "patterns" in domain_section: domain_patterns = domain_section["patterns"] return global_patterns + domain_patterns def check_path_with_pattern( rel_posix: str, patterns: list[str], ) -> tuple[bool, str | None]: """Return ``(ignored, matching_pattern)`` for *rel_posix*. Identical semantics to :func:`is_ignored` but also returns the last pattern that caused *ignored* to be ``True``, or ``None`` when the path is not ignored (either never matched, or un-ignored by a ``!negation`` rule). This is the authoritative matching implementation. ``muse plumbing check-ignore`` uses it directly so the plumbing command and the snapshot engine always agree on ignore semantics. Args: rel_posix: Workspace-relative POSIX path, e.g. ``"tracks/drums.mid"``. patterns: Ordered pattern list from :func:`resolve_patterns`. Returns: A 2-tuple ``(ignored: bool, matching_pattern: str | None)``. ``matching_pattern`` is the last pattern that set ``ignored = True``, or ``None`` when the path ended up not ignored. """ # Hot path: avoid constructing a PurePosixPath for every call — the pure- # string _matches is 7-8× faster for the common case of 10-20 patterns # evaluated against every file in a large working tree. ignored = False matching: str | None = None for pattern in patterns: negate = pattern.startswith("!") pat = pattern[1:] if negate else pattern matched = False if pat.endswith("/"): dir_prefix = pat if rel_posix.startswith(dir_prefix) or _matches(rel_posix, pat.rstrip("/") + "/**"): matched = True elif _matches(rel_posix, pat): matched = True if matched: ignored = not negate matching = pattern if not negate else None return ignored, matching def is_ignored(rel_posix: str, patterns: list[str]) -> bool: """Return ``True`` if *rel_posix* should be excluded from the snapshot. Args: rel_posix: Workspace-relative POSIX path, e.g. ``"tracks/drums.mid"``. patterns: Ordered pattern list from :func:`resolve_patterns`. Returns: ``True`` when the path is ignored, ``False`` otherwise. An empty *patterns* list means nothing is ignored. The last matching rule wins. A negation rule (``!pattern``) can un-ignore a path that was matched by an earlier rule. Directory patterns (trailing ``/``) match any file whose path starts with that directory prefix — e.g. ``artifacts/`` ignores ``artifacts/demo.html``. Delegates to :func:`check_path_with_pattern` — the single authoritative matching loop — to guarantee that this function and ``muse plumbing check-ignore`` always agree on ignore semantics. """ ignored, _ = check_path_with_pattern(rel_posix, patterns) return ignored def _matches(path_str: str, pattern: str) -> bool: """Return ``True`` if *path_str* matches *pattern*. Implements gitignore path-matching semantics using pure string operations and :func:`fnmatch.fnmatch` — no :class:`pathlib.PurePosixPath` objects are constructed. This keeps the per-file overhead negligible even with 20+ patterns evaluated against every file in a 75 000-file working tree. Semantics: - **Anchored** (leading ``/``): matched against the full relative path with the leading slash stripped. - **Pattern with embedded ``/``**: matched against the full relative path via ``fnmatch.fnmatch``. A leading ``**/`` is additionally tried against the path with the prefix stripped (handles ``**/cache/*.dat`` matching ``cache/index.dat``). - **Pattern without ``/``**: matched against the filename and every trailing suffix of the path so that ``*.tmp`` matches ``drums.tmp`` *and* ``tracks/drums.tmp``. Args: path_str: Workspace-relative POSIX path as a plain string (e.g. ``"tracks/drums.mid"``). pattern: A single gitignore-style pattern (no leading ``!``). """ # Anchored: match the full path from the root. if pattern.startswith("/"): return fnmatch.fnmatch(path_str, pattern[1:]) # Embedded slash: match from the right. # ``**/foo/*.dat`` is tried first against the full path; if that fails, # the ``**/`` prefix is stripped and tried again — this makes the pattern # match paths that do not have any leading components (e.g. ``foo/a.dat``). if "/" in pattern: if fnmatch.fnmatch(path_str, pattern): return True if pattern.startswith("**/"): return fnmatch.fnmatch(path_str, pattern[3:]) return False # No slash: match against the filename and every trailing suffix so that # ``*.tmp`` matches both ``drums.tmp`` and ``tracks/drums.tmp``. parts = path_str.split("/") for i in range(len(parts)): if fnmatch.fnmatch("/".join(parts[i:]), pattern): return True return False