check_ignore.py
python
sha256:24d9cb81ac1dd071e5d98862c740ad101180588361b4f96a790af79a99aed901
docs: architectural plan for unified-store snapshot-content…
Sonnet 5
10 days ago
| 1 | """muse check-ignore — test whether paths are ignored by ``.museignore``. |
| 2 | |
| 3 | Reads the ``.museignore`` file (if present), resolves patterns for the active |
| 4 | domain, and evaluates each supplied path against the compiled rule list. |
| 5 | Reports whether each path is ignored and — when in verbose text mode — which |
| 6 | pattern matched it. JSON output always includes the matching pattern. |
| 7 | |
| 8 | Output (JSON, default):: |
| 9 | |
| 10 | { |
| 11 | "domain": "midi", |
| 12 | "patterns_loaded": 4, |
| 13 | "summary": { |
| 14 | "total": 2, |
| 15 | "ignored": 1, |
| 16 | "not_ignored": 1 |
| 17 | }, |
| 18 | "results": [ |
| 19 | { |
| 20 | "path": "build/output.bin", |
| 21 | "ignored": true, |
| 22 | "matching_pattern": "build/" |
| 23 | }, |
| 24 | { |
| 25 | "path": "tracks/drums.mid", |
| 26 | "ignored": false, |
| 27 | "matching_pattern": null |
| 28 | } |
| 29 | ], |
| 30 | "duration_ms": 0.000087, |
| 31 | "exit_code": 0 |
| 32 | } |
| 33 | |
| 34 | Text output (``--format text``):: |
| 35 | |
| 36 | ignored build/output.bin [build/] |
| 37 | ok tracks/drums.mid |
| 38 | |
| 39 | With ``--quiet`` (exits 0 if *all* paths are ignored, exits 1 otherwise, no |
| 40 | other output):: |
| 41 | |
| 42 | (empty stdout) |
| 43 | |
| 44 | With ``--patterns-only`` (emit the active pattern list, no paths needed):: |
| 45 | |
| 46 | { |
| 47 | "domain": "midi", |
| 48 | "patterns_loaded": 3, |
| 49 | "patterns": ["build/", "*.bin", "!tracks/*.mid"], |
| 50 | "duration_ms": 0.000031, |
| 51 | "exit_code": 0 |
| 52 | } |
| 53 | |
| 54 | Output contract |
| 55 | --------------- |
| 56 | |
| 57 | - Exit 0: all evaluated paths are ignored (success for ``--quiet`` mode), or |
| 58 | all results emitted normally. |
| 59 | - Exit 1: one or more paths are not ignored (``--quiet`` mode only); bad args |
| 60 | or bad ``--format``; ``--ignored-only`` combined with ``--patterns-only`` |
| 61 | or ``--quiet``. |
| 62 | - Exit 3: I/O or TOML parse error reading ``.museignore``. |
| 63 | |
| 64 | JSON fields present in every successful response |
| 65 | ------------------------------------------------ |
| 66 | |
| 67 | ``duration_ms`` |
| 68 | Wall-clock time in seconds from argument parsing to output. |
| 69 | ``exit_code`` |
| 70 | Always ``0`` on success. Lets agents parse a single JSON payload instead |
| 71 | of inspecting the process exit code separately. |
| 72 | ``summary`` *(default mode only)* |
| 73 | ``total`` — number of results emitted (after ``--ignored-only`` filter). |
| 74 | ``ignored`` — paths that matched an ignore pattern. |
| 75 | ``not_ignored`` — paths that did not match any ignore pattern. |
| 76 | ``patterns_loaded`` *(both modes)* |
| 77 | Count of patterns resolved for the active domain. |
| 78 | |
| 79 | Matching engine |
| 80 | --------------- |
| 81 | |
| 82 | The matching logic (last-match-wins, negation rules, directory patterns, |
| 83 | anchored patterns) lives exclusively in :func:`muse.core.ignore.check_path_with_pattern`. |
| 84 | This command is a thin consumer of that function — it never reimplements the |
| 85 | matching rules, ensuring it always agrees with ``muse code add`` and the |
| 86 | snapshot engine. |
| 87 | |
| 88 | Agent use |
| 89 | --------- |
| 90 | |
| 91 | Inspect active patterns before staging:: |
| 92 | |
| 93 | muse check-ignore --patterns-only --json |
| 94 | |
| 95 | Audit which staged files will be excluded from the snapshot:: |
| 96 | |
| 97 | muse check-ignore --ignored-only --stdin < staged_paths.txt --json |
| 98 | |
| 99 | Pipe a list of paths from another command:: |
| 100 | |
| 101 | muse code symbols --kind import | awk '{print $NF}' | \\ |
| 102 | muse check-ignore --stdin |
| 103 | |
| 104 | Check a batch of paths non-interactively:: |
| 105 | |
| 106 | muse check-ignore build/render.bin tmp/*.log --json |
| 107 | |
| 108 | Quiet mode for scripting:: |
| 109 | |
| 110 | muse check-ignore dist/ && echo "dist is ignored" |
| 111 | |
| 112 | Summary-only audit (no per-path detail needed):: |
| 113 | |
| 114 | muse check-ignore foo.py bar.py baz.py --json | \\ |
| 115 | python3 -c "import sys,json; s=json.load(sys.stdin)['summary']; print(s)" |
| 116 | """ |
| 117 | |
| 118 | import argparse |
| 119 | import json |
| 120 | import logging |
| 121 | import sys |
| 122 | from typing import TypedDict |
| 123 | |
| 124 | from muse.core.errors import ExitCode |
| 125 | from muse.core.ignore import check_path_with_pattern, load_ignore_config, resolve_patterns |
| 126 | from muse.core.repo import require_repo |
| 127 | from muse.core.validation import sanitize_display, validate_workspace_path |
| 128 | from muse.core.timing import start_timer |
| 129 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 130 | from muse.plugins.registry import read_domain |
| 131 | |
| 132 | logger = logging.getLogger(__name__) |
| 133 | |
| 134 | class _PathResult(TypedDict): |
| 135 | path: str |
| 136 | ignored: bool |
| 137 | matching_pattern: str | None |
| 138 | |
| 139 | class _SummaryDict(TypedDict): |
| 140 | total: int |
| 141 | ignored: int |
| 142 | not_ignored: int |
| 143 | |
| 144 | class _PatternsOnlyJson(EnvelopeJson): |
| 145 | """Wire shape for --patterns-only --json.""" |
| 146 | |
| 147 | domain: str |
| 148 | patterns_loaded: int |
| 149 | patterns: list[str] |
| 150 | |
| 151 | class _CheckIgnoreJson(EnvelopeJson): |
| 152 | """Wire shape for default path-check --json output.""" |
| 153 | |
| 154 | domain: str |
| 155 | patterns_loaded: int |
| 156 | summary: _SummaryDict |
| 157 | results: list[dict] |
| 158 | |
| 159 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 160 | """Register the check-ignore subcommand.""" |
| 161 | parser = subparsers.add_parser( |
| 162 | "check-ignore", |
| 163 | help="Test paths against .museignore rules.", |
| 164 | description=__doc__, |
| 165 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 166 | ) |
| 167 | parser.add_argument( |
| 168 | "paths", |
| 169 | nargs="*", |
| 170 | help=( |
| 171 | "Workspace-relative paths to test. " |
| 172 | "At least one path is required unless --stdin or --patterns-only is used." |
| 173 | ), |
| 174 | ) |
| 175 | parser.add_argument( |
| 176 | "--stdin", |
| 177 | action="store_true", |
| 178 | dest="from_stdin", |
| 179 | help=( |
| 180 | "Read additional paths from stdin, one per line. " |
| 181 | "Blank lines and lines starting with '#' are skipped. " |
| 182 | "Combines with positional path arguments." |
| 183 | ), |
| 184 | ) |
| 185 | parser.add_argument( |
| 186 | "--quiet", "-q", |
| 187 | action="store_true", |
| 188 | help="No output. Exit 0 if all paths are ignored, exit 1 otherwise.", |
| 189 | ) |
| 190 | parser.add_argument( |
| 191 | "--verbose", "-V", |
| 192 | action="store_true", |
| 193 | help="Include the matching pattern in text output (JSON always includes it).", |
| 194 | ) |
| 195 | parser.add_argument( |
| 196 | "--patterns-only", |
| 197 | action="store_true", |
| 198 | dest="patterns_only", |
| 199 | help=( |
| 200 | "Emit the resolved pattern list for this domain and exit. " |
| 201 | "No path arguments needed. " |
| 202 | "Useful for agents inspecting active ignore rules before staging." |
| 203 | ), |
| 204 | ) |
| 205 | parser.add_argument( |
| 206 | "--json", "-j", action="store_true", dest="json_out", |
| 207 | help="Emit machine-readable JSON.", |
| 208 | ) |
| 209 | parser.add_argument( |
| 210 | "--ignored-only", |
| 211 | action="store_true", |
| 212 | dest="ignored_only", |
| 213 | help=( |
| 214 | "Show only paths that are ignored. " |
| 215 | "Useful for auditing which staged files will be excluded from the snapshot." |
| 216 | ), |
| 217 | ) |
| 218 | parser.set_defaults(func=run) |
| 219 | |
| 220 | def run(args: argparse.Namespace) -> None: |
| 221 | """Test whether paths are excluded by ``.museignore`` rules. |
| 222 | |
| 223 | Evaluates each supplied path against the global and domain-specific |
| 224 | patterns loaded from ``.museignore``. Uses the same matching engine as the |
| 225 | snapshot engine so results are always consistent with ``muse commit``. Use |
| 226 | ``--patterns-only`` to inspect the loaded rules without providing paths; |
| 227 | ``--ignored-only`` to filter output to only excluded paths. |
| 228 | |
| 229 | Agent quickstart |
| 230 | ---------------- |
| 231 | :: |
| 232 | |
| 233 | muse check-ignore tracks/drums.mid build/ --json |
| 234 | muse check-ignore --patterns-only --json |
| 235 | muse check-ignore --stdin --json < staged_paths.txt |
| 236 | |
| 237 | JSON fields |
| 238 | ----------- |
| 239 | domain Repository domain (e.g. ``"code"``). |
| 240 | patterns_loaded Number of patterns loaded. |
| 241 | summary ``total``, ``ignored``, ``not_ignored`` counts. |
| 242 | results List of per-path objects: ``path``, ``ignored`` (bool), |
| 243 | ``matching_pattern`` (first matching rule or ``null``). |
| 244 | patterns List of loaded patterns (only with ``--patterns-only``). |
| 245 | |
| 246 | Exit codes |
| 247 | ---------- |
| 248 | 0 Query completed (paths may or may not be ignored). |
| 249 | 1 Conflicting flags, missing paths, or path validation error. |
| 250 | 2 Not inside a Muse repository. |
| 251 | 3 Internal error reading ``.museignore``. |
| 252 | """ |
| 253 | elapsed = start_timer() |
| 254 | json_out: bool = args.json_out |
| 255 | cli_paths: list[str] = args.paths or [] |
| 256 | from_stdin: bool = args.from_stdin |
| 257 | quiet: bool = args.quiet |
| 258 | verbose: bool = args.verbose |
| 259 | patterns_only: bool = args.patterns_only |
| 260 | ignored_only: bool = getattr(args, "ignored_only", False) |
| 261 | |
| 262 | if ignored_only and patterns_only: |
| 263 | print( |
| 264 | json.dumps({"error": "--ignored-only and --patterns-only are mutually exclusive."}), |
| 265 | file=sys.stderr, |
| 266 | ) |
| 267 | raise SystemExit(ExitCode.USER_ERROR) |
| 268 | |
| 269 | if ignored_only and quiet: |
| 270 | print( |
| 271 | json.dumps({"error": "--ignored-only and --quiet are mutually exclusive."}), |
| 272 | file=sys.stderr, |
| 273 | ) |
| 274 | raise SystemExit(ExitCode.USER_ERROR) |
| 275 | |
| 276 | root = require_repo() |
| 277 | domain = read_domain(root) |
| 278 | |
| 279 | try: |
| 280 | config = load_ignore_config(root) |
| 281 | except ValueError as exc: |
| 282 | print(json.dumps({"error": str(exc)}), file=sys.stderr) |
| 283 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 284 | |
| 285 | patterns = resolve_patterns(config, domain) |
| 286 | |
| 287 | # --patterns-only: emit the resolved pattern list and exit — no paths needed. |
| 288 | if patterns_only: |
| 289 | if not json_out: |
| 290 | for pat in patterns: |
| 291 | print(pat) |
| 292 | else: |
| 293 | print(json.dumps(_PatternsOnlyJson( |
| 294 | **make_envelope(elapsed), |
| 295 | domain=domain, |
| 296 | patterns_loaded=len(patterns), |
| 297 | patterns=patterns, |
| 298 | ))) |
| 299 | return |
| 300 | |
| 301 | # Collect paths: positional args + optional stdin. |
| 302 | # Strip \r\n (not just \n) — CRLF-terminated input would embed \r in path |
| 303 | # strings, which silently corrupts pattern matching and text output. |
| 304 | all_paths: list[str] = list(cli_paths) |
| 305 | if from_stdin: |
| 306 | for line in sys.stdin: |
| 307 | stripped = line.rstrip("\r\n") |
| 308 | if stripped and not stripped.startswith("#"): |
| 309 | all_paths.append(stripped) |
| 310 | |
| 311 | if not all_paths: |
| 312 | print( |
| 313 | json.dumps({"error": "At least one path argument is required."}), |
| 314 | file=sys.stderr, |
| 315 | ) |
| 316 | raise SystemExit(ExitCode.USER_ERROR) |
| 317 | |
| 318 | # Validate paths: reject traversal sequences, null bytes, absolute paths, |
| 319 | # control characters, and excessively long values. |
| 320 | for p in all_paths: |
| 321 | try: |
| 322 | validate_workspace_path(p) |
| 323 | except ValueError as exc: |
| 324 | print( |
| 325 | json.dumps({"error": f"Invalid path {p!r}: {exc}"}), |
| 326 | file=sys.stderr, |
| 327 | ) |
| 328 | raise SystemExit(ExitCode.USER_ERROR) |
| 329 | |
| 330 | # Evaluate every path using the single authoritative engine in core/ignore.py. |
| 331 | results: list[_PathResult] = [] |
| 332 | for p in all_paths: |
| 333 | ignored, matching = check_path_with_pattern(p, patterns) |
| 334 | results.append({"path": p, "ignored": ignored, "matching_pattern": matching}) |
| 335 | |
| 336 | if quiet: |
| 337 | all_ignored = all(r["ignored"] for r in results) |
| 338 | raise SystemExit(0 if all_ignored else ExitCode.USER_ERROR) |
| 339 | |
| 340 | # --ignored-only: retain only paths that matched an ignore pattern. |
| 341 | if ignored_only: |
| 342 | results = [r for r in results if r["ignored"]] |
| 343 | |
| 344 | # Build summary over the (possibly filtered) result set. |
| 345 | summary: _SummaryDict = { |
| 346 | "total": len(results), |
| 347 | "ignored": sum(1 for r in results if r["ignored"]), |
| 348 | "not_ignored": sum(1 for r in results if not r["ignored"]), |
| 349 | } |
| 350 | |
| 351 | if not json_out: |
| 352 | for r in results: |
| 353 | status = "ignored" if r["ignored"] else "ok " |
| 354 | if verbose and r["matching_pattern"]: |
| 355 | print( |
| 356 | f"{status} {sanitize_display(r['path'])} " |
| 357 | f"[{sanitize_display(r['matching_pattern'])}]" |
| 358 | ) |
| 359 | else: |
| 360 | print(f"{status} {sanitize_display(r['path'])}") |
| 361 | return |
| 362 | |
| 363 | print(json.dumps(_CheckIgnoreJson( |
| 364 | **make_envelope(elapsed), |
| 365 | domain=domain, |
| 366 | patterns_loaded=len(patterns), |
| 367 | summary=summary, |
| 368 | results=[dict(r) for r in results], |
| 369 | ))) |
File History
1 commit
sha256:24d9cb81ac1dd071e5d98862c740ad101180588361b4f96a790af79a99aed901
docs: architectural plan for unified-store snapshot-content…
Sonnet 5
10 days ago