"""muse plumbing check-ignore — test whether paths are ignored by ``.museignore``. Reads the ``.museignore`` file (if present), resolves patterns for the active domain, and evaluates each supplied path against the compiled rule list. Reports whether each path is ignored and — when in verbose text mode — which pattern matched it. JSON output always includes the matching pattern. Output (JSON, default):: { "domain": "midi", "patterns_loaded": 4, "results": [ { "path": "build/output.bin", "ignored": true, "matching_pattern": "build/" }, { "path": "tracks/drums.mid", "ignored": false, "matching_pattern": null } ] } Text output (``--format text``):: ignored build/output.bin [build/] ok tracks/drums.mid With ``--quiet`` (exits 0 if *all* paths are ignored, exits 1 otherwise, no other output): (empty stdout) With ``--patterns-only`` (emit the active pattern list, no paths needed):: { "domain": "midi", "patterns": ["build/", "*.bin", "!tracks/*.mid"] } Plumbing contract ----------------- - Exit 0: all evaluated paths are ignored (success for ``--quiet`` mode), or all results emitted normally. - Exit 1: one or more paths are not ignored (``--quiet`` mode only); bad args or bad ``--format``. - Exit 3: I/O or TOML parse error reading ``.museignore``. Matching engine --------------- The matching logic (last-match-wins, negation rules, directory patterns, anchored patterns) lives exclusively in :func:`muse.core.ignore.check_path_with_pattern`. This command is a thin consumer of that function — it never reimplements the matching rules, ensuring it always agrees with ``muse code add`` and the snapshot engine. Agent use --------- Inspect active rules before staging:: muse plumbing check-ignore --patterns-only --json Pipe a list of paths from another command:: muse code symbols --kind import | awk '{print $NF}' | \\ muse plumbing check-ignore --stdin Check a batch of paths non-interactively:: muse plumbing check-ignore build/render.bin tmp/*.log --json Quiet mode for scripting:: muse plumbing check-ignore dist/ && echo "dist is ignored" """ from __future__ import annotations import argparse import json import logging import sys from typing import TypedDict from muse.core.errors import ExitCode from muse.core.ignore import check_path_with_pattern, load_ignore_config, resolve_patterns from muse.core.repo import require_repo from muse.core.validation import sanitize_display, validate_workspace_path from muse.plugins.registry import read_domain logger = logging.getLogger(__name__) _FORMAT_CHOICES = ("json", "text") class _PathResult(TypedDict): path: str ignored: bool matching_pattern: str | None def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the check-ignore subcommand.""" parser = subparsers.add_parser( "check-ignore", help="Test paths against .museignore rules.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "paths", nargs="*", help=( "Workspace-relative paths to test. " "At least one path is required unless --stdin or --patterns-only is used." ), ) parser.add_argument( "--stdin", action="store_true", dest="from_stdin", help=( "Read additional paths from stdin, one per line. " "Blank lines and lines starting with '#' are skipped. " "Combines with positional path arguments." ), ) parser.add_argument( "--quiet", "-q", action="store_true", help="No output. Exit 0 if all paths are ignored, exit 1 otherwise.", ) parser.add_argument( "--verbose", "-V", action="store_true", help="Include the matching pattern in text output (JSON always includes it).", ) parser.add_argument( "--patterns-only", action="store_true", dest="patterns_only", help=( "Emit the resolved pattern list for this domain and exit. " "No path arguments needed. " "Useful for agents inspecting active ignore rules before staging." ), ) parser.add_argument( "--format", "-f", dest="fmt", default="json", metavar="FORMAT", help="Output format: json or text. (default: json)", ) parser.add_argument( "--json", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.", ) parser.set_defaults(func=run) def run(args: argparse.Namespace) -> None: """Test whether paths are excluded by ``.museignore`` rules. Evaluates each supplied path against the global and domain-specific patterns loaded from ``.museignore``. Domain context is read automatically from ``.muse/repo.json``. Paths should be workspace-relative POSIX paths, e.g. ``tracks/drums.mid`` or ``build/render.bin``. The matching engine is :func:`muse.core.ignore.check_path_with_pattern` — the same function used by the snapshot engine. This guarantees that ``muse plumbing check-ignore`` never disagrees with ``muse commit``. """ fmt: str = args.fmt cli_paths: list[str] = args.paths or [] from_stdin: bool = args.from_stdin quiet: bool = args.quiet verbose: bool = args.verbose patterns_only: bool = args.patterns_only if fmt not in _FORMAT_CHOICES: print( json.dumps( {"error": f"Unknown format {fmt!r}. Valid: {', '.join(_FORMAT_CHOICES)}"} ), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) root = require_repo() domain = read_domain(root) try: config = load_ignore_config(root) except ValueError as exc: print(json.dumps({"error": str(exc)}), file=sys.stderr) raise SystemExit(ExitCode.INTERNAL_ERROR) patterns = resolve_patterns(config, domain) # --patterns-only: emit the resolved pattern list and exit — no paths needed. if patterns_only: if fmt == "text": for pat in patterns: print(pat) else: print(json.dumps({"domain": domain, "patterns": patterns})) return # Collect paths: positional args + optional stdin. # Strip \r\n (not just \n) — CRLF-terminated input would embed \r in path # strings, which silently corrupts pattern matching and text output. all_paths: list[str] = list(cli_paths) if from_stdin: for line in sys.stdin: stripped = line.rstrip("\r\n") if stripped and not stripped.startswith("#"): all_paths.append(stripped) if not all_paths: print( json.dumps({"error": "At least one path argument is required."}), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # Validate paths: reject traversal sequences, null bytes, absolute paths, # control characters, and excessively long values. for p in all_paths: try: validate_workspace_path(p) except ValueError as exc: print( json.dumps({"error": f"Invalid path {p!r}: {exc}"}), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # Evaluate every path using the single authoritative engine in core/ignore.py. results: list[_PathResult] = [] for p in all_paths: ignored, matching = check_path_with_pattern(p, patterns) results.append({"path": p, "ignored": ignored, "matching_pattern": matching}) if quiet: all_ignored = all(r["ignored"] for r in results) raise SystemExit(0 if all_ignored else ExitCode.USER_ERROR) if fmt == "text": for r in results: status = "ignored" if r["ignored"] else "ok " if verbose and r["matching_pattern"]: print( f"{status} {sanitize_display(r['path'])} " f"[{sanitize_display(r['matching_pattern'])}]" ) else: print(f"{status} {sanitize_display(r['path'])}") return print( json.dumps({ "domain": domain, "patterns_loaded": len(patterns), "results": [dict(r) for r in results], }) )