gabriel / muse public
check_ignore.py python
369 lines 11.8 KB
Raw
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