check_ignore.py python
275 lines 8.5 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago
1 """muse plumbing 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 "results": [
14 {
15 "path": "build/output.bin",
16 "ignored": true,
17 "matching_pattern": "build/"
18 },
19 {
20 "path": "tracks/drums.mid",
21 "ignored": false,
22 "matching_pattern": null
23 }
24 ]
25 }
26
27 Text output (``--format text``)::
28
29 ignored build/output.bin [build/]
30 ok tracks/drums.mid
31
32 With ``--quiet`` (exits 0 if *all* paths are ignored, exits 1 otherwise, no
33 other output):
34
35 (empty stdout)
36
37 With ``--patterns-only`` (emit the active pattern list, no paths needed)::
38
39 {
40 "domain": "midi",
41 "patterns": ["build/", "*.bin", "!tracks/*.mid"]
42 }
43
44 Plumbing contract
45 -----------------
46
47 - Exit 0: all evaluated paths are ignored (success for ``--quiet`` mode), or
48 all results emitted normally.
49 - Exit 1: one or more paths are not ignored (``--quiet`` mode only); bad args
50 or bad ``--format``.
51 - Exit 3: I/O or TOML parse error reading ``.museignore``.
52
53 Matching engine
54 ---------------
55
56 The matching logic (last-match-wins, negation rules, directory patterns,
57 anchored patterns) lives exclusively in :func:`muse.core.ignore.check_path_with_pattern`.
58 This command is a thin consumer of that function — it never reimplements the
59 matching rules, ensuring it always agrees with ``muse code add`` and the
60 snapshot engine.
61
62 Agent use
63 ---------
64
65 Inspect active rules before staging::
66
67 muse plumbing check-ignore --patterns-only --json
68
69 Pipe a list of paths from another command::
70
71 muse code symbols --kind import | awk '{print $NF}' | \\
72 muse plumbing check-ignore --stdin
73
74 Check a batch of paths non-interactively::
75
76 muse plumbing check-ignore build/render.bin tmp/*.log --json
77
78 Quiet mode for scripting::
79
80 muse plumbing check-ignore dist/ && echo "dist is ignored"
81 """
82
83 from __future__ import annotations
84
85 import argparse
86 import json
87 import logging
88 import sys
89 from typing import TypedDict
90
91 from muse.core.errors import ExitCode
92 from muse.core.ignore import check_path_with_pattern, load_ignore_config, resolve_patterns
93 from muse.core.repo import require_repo
94 from muse.core.validation import sanitize_display, validate_workspace_path
95 from muse.plugins.registry import read_domain
96
97 logger = logging.getLogger(__name__)
98
99 _FORMAT_CHOICES = ("json", "text")
100
101
102 class _PathResult(TypedDict):
103 path: str
104 ignored: bool
105 matching_pattern: str | None
106
107
108 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
109 """Register the check-ignore subcommand."""
110 parser = subparsers.add_parser(
111 "check-ignore",
112 help="Test paths against .museignore rules.",
113 description=__doc__,
114 formatter_class=argparse.RawDescriptionHelpFormatter,
115 )
116 parser.add_argument(
117 "paths",
118 nargs="*",
119 help=(
120 "Workspace-relative paths to test. "
121 "At least one path is required unless --stdin or --patterns-only is used."
122 ),
123 )
124 parser.add_argument(
125 "--stdin",
126 action="store_true",
127 dest="from_stdin",
128 help=(
129 "Read additional paths from stdin, one per line. "
130 "Blank lines and lines starting with '#' are skipped. "
131 "Combines with positional path arguments."
132 ),
133 )
134 parser.add_argument(
135 "--quiet", "-q",
136 action="store_true",
137 help="No output. Exit 0 if all paths are ignored, exit 1 otherwise.",
138 )
139 parser.add_argument(
140 "--verbose", "-V",
141 action="store_true",
142 help="Include the matching pattern in text output (JSON always includes it).",
143 )
144 parser.add_argument(
145 "--patterns-only",
146 action="store_true",
147 dest="patterns_only",
148 help=(
149 "Emit the resolved pattern list for this domain and exit. "
150 "No path arguments needed. "
151 "Useful for agents inspecting active ignore rules before staging."
152 ),
153 )
154 parser.add_argument(
155 "--format", "-f",
156 dest="fmt",
157 default="json",
158 metavar="FORMAT",
159 help="Output format: json or text. (default: json)",
160 )
161 parser.add_argument(
162 "--json", action="store_const", const="json", dest="fmt",
163 help="Shorthand for --format json.",
164 )
165 parser.set_defaults(func=run)
166
167
168 def run(args: argparse.Namespace) -> None:
169 """Test whether paths are excluded by ``.museignore`` rules.
170
171 Evaluates each supplied path against the global and domain-specific
172 patterns loaded from ``.museignore``. Domain context is read automatically
173 from ``.muse/repo.json``.
174
175 Paths should be workspace-relative POSIX paths, e.g. ``tracks/drums.mid``
176 or ``build/render.bin``.
177
178 The matching engine is :func:`muse.core.ignore.check_path_with_pattern` —
179 the same function used by the snapshot engine. This guarantees that
180 ``muse plumbing check-ignore`` never disagrees with ``muse commit``.
181 """
182 fmt: str = args.fmt
183 cli_paths: list[str] = args.paths or []
184 from_stdin: bool = args.from_stdin
185 quiet: bool = args.quiet
186 verbose: bool = args.verbose
187 patterns_only: bool = args.patterns_only
188
189 if fmt not in _FORMAT_CHOICES:
190 print(
191 json.dumps(
192 {"error": f"Unknown format {fmt!r}. Valid: {', '.join(_FORMAT_CHOICES)}"}
193 ),
194 file=sys.stderr,
195 )
196 raise SystemExit(ExitCode.USER_ERROR)
197
198 root = require_repo()
199 domain = read_domain(root)
200
201 try:
202 config = load_ignore_config(root)
203 except ValueError as exc:
204 print(json.dumps({"error": str(exc)}), file=sys.stderr)
205 raise SystemExit(ExitCode.INTERNAL_ERROR)
206
207 patterns = resolve_patterns(config, domain)
208
209 # --patterns-only: emit the resolved pattern list and exit — no paths needed.
210 if patterns_only:
211 if fmt == "text":
212 for pat in patterns:
213 print(pat)
214 else:
215 print(json.dumps({"domain": domain, "patterns": patterns}))
216 return
217
218 # Collect paths: positional args + optional stdin.
219 # Strip \r\n (not just \n) — CRLF-terminated input would embed \r in path
220 # strings, which silently corrupts pattern matching and text output.
221 all_paths: list[str] = list(cli_paths)
222 if from_stdin:
223 for line in sys.stdin:
224 stripped = line.rstrip("\r\n")
225 if stripped and not stripped.startswith("#"):
226 all_paths.append(stripped)
227
228 if not all_paths:
229 print(
230 json.dumps({"error": "At least one path argument is required."}),
231 file=sys.stderr,
232 )
233 raise SystemExit(ExitCode.USER_ERROR)
234
235 # Validate paths: reject traversal sequences, null bytes, absolute paths,
236 # control characters, and excessively long values.
237 for p in all_paths:
238 try:
239 validate_workspace_path(p)
240 except ValueError as exc:
241 print(
242 json.dumps({"error": f"Invalid path {p!r}: {exc}"}),
243 file=sys.stderr,
244 )
245 raise SystemExit(ExitCode.USER_ERROR)
246
247 # Evaluate every path using the single authoritative engine in core/ignore.py.
248 results: list[_PathResult] = []
249 for p in all_paths:
250 ignored, matching = check_path_with_pattern(p, patterns)
251 results.append({"path": p, "ignored": ignored, "matching_pattern": matching})
252
253 if quiet:
254 all_ignored = all(r["ignored"] for r in results)
255 raise SystemExit(0 if all_ignored else ExitCode.USER_ERROR)
256
257 if fmt == "text":
258 for r in results:
259 status = "ignored" if r["ignored"] else "ok "
260 if verbose and r["matching_pattern"]:
261 print(
262 f"{status} {sanitize_display(r['path'])} "
263 f"[{sanitize_display(r['matching_pattern'])}]"
264 )
265 else:
266 print(f"{status} {sanitize_display(r['path'])}")
267 return
268
269 print(
270 json.dumps({
271 "domain": domain,
272 "patterns_loaded": len(patterns),
273 "results": [dict(r) for r in results],
274 })
275 )
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago