gabriel / muse public
sparse_checkout.py python
622 lines 19.4 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 143 days ago
1 """``muse sparse-checkout`` — partial working-tree materialization.
2
3 Sparse-checkout lets you work with only a subset of a large repository's files
4 in your working tree. The full snapshot manifest is always stored and tracked
5 by Muse; only the files that match your sparse rules are written to disk.
6
7 Subcommands
8 -----------
9 ``init [--no-cone] [--json]``
10 Activate sparse-checkout. ``--cone`` (default) uses directory-prefix rules;
11 ``--no-cone`` uses glob patterns. If a config already exists, running init
12 again with a *different* mode switches the mode while preserving patterns.
13 Running init with the *same* mode is a no-op.
14
15 ``set <pattern...> [--json]``
16 Replace the current pattern list. Requires ``init`` first.
17
18 ``add <pattern...> [--json]``
19 Append patterns to the current list (deduplicates). Requires ``init`` first.
20
21 ``list [--json]``
22 Show the active patterns and mode.
23
24 ``stats [--json]``
25 Show how many files in the HEAD snapshot match or are excluded by the current
26 sparse config. Reports total_files, matching_files, excluded_files, and
27 efficiency (ratio of matching to total).
28
29 ``disable [--json]``
30 Remove the sparse-checkout configuration. The next ``checkout`` or ``merge``
31 will restore the full working tree.
32
33 Modes
34 -----
35 ``cone`` (default)
36 Patterns are directory prefixes, e.g. ``src/``. Root-level files always
37 match. Subdirectory files match when their path starts with a prefix.
38
39 ``pattern``
40 Patterns are glob expressions, e.g. ``**/*.py`` or ``src/**``.
41
42 Pattern safety rules
43 --------------------
44 Patterns are validated before storage. The following are rejected:
45
46 - ANSI escape sequences (terminal-injection guard)
47 - Null bytes (filesystem attack vector)
48 - Whitespace-only strings (meaningless; likely a user error)
49 - Path traversal via ``..`` segments (e.g. ``../../etc/passwd``)
50
51 JSON output schemas
52 -------------------
53
54 ``init --json``::
55
56 {"mode": str, "switched": bool, "previous_mode": str|null,
57 "duration_ms": float, "exit_code": int}
58
59 ``set --json``::
60
61 {"patterns": [str, ...], "total": int, "duration_ms": float, "exit_code": int}
62
63 ``add --json``::
64
65 {"added": int, "skipped": int, "patterns": [str, ...],
66 "total": int, "duration_ms": float, "exit_code": int}
67
68 ``list --json``::
69
70 {"enabled": bool, "mode": str|null, "patterns": [str, ...],
71 "duration_ms": float, "exit_code": int}
72
73 ``stats --json``::
74
75 {"enabled": bool, "mode": str|null, "patterns": [str, ...],
76 "total_files": int, "matching_files": int, "excluded_files": int,
77 "efficiency": float, "duration_ms": float, "exit_code": int}
78
79 ``disable --json``::
80
81 {"was_enabled": bool, "duration_ms": float, "exit_code": int}
82
83 Exit codes::
84
85 0 — success
86 1 — operation failed (no config, invalid patterns, config corruption)
87 2 — usage error
88 """
89
90 from __future__ import annotations
91
92 import argparse
93 import json as _json
94 import re
95 import sys
96
97 from muse.core.errors import ExitCode
98 from muse.core.repo import require_repo
99 from muse.core.sparse import (
100 filter_manifest_sparse,
101 read_sparse_config,
102 remove_sparse_config,
103 write_sparse_config,
104 )
105 from muse.core.validation import sanitize_display
106 from muse.core.timing import start_timer
107
108 # ---------------------------------------------------------------------------
109 # Pattern validation
110 # ---------------------------------------------------------------------------
111
112 _ANSI_RE = re.compile(r"\x1b|\x9b|\x1c|\x1d|\x1e|\x1f")
113
114 # Matches ".." as a path component: preceded/followed by separator or string boundary.
115 _TRAVERSAL_RE = re.compile(r"(^|[/\\])\.\.([/\\]|$)")
116
117
118 def _check_pattern(pat: str) -> tuple[bool, str]:
119 """Validate *pat* and return ``(ok, reason)``.
120
121 Rejects patterns that are:
122
123 - ANSI escape sequences — prevent terminal-injection when patterns are
124 printed to stdout/stderr.
125 - Null bytes — filesystem attack vector; ``open()`` rejects them on most
126 platforms, causing misleading errors deep in the call stack.
127 - Whitespace-only — meaningless as a path prefix or glob; almost certainly
128 a user error.
129 - Path traversal via ``..`` — a pattern like ``../../etc/passwd`` would
130 match files outside the repository root after prefix expansion.
131 """
132 if _ANSI_RE.search(pat):
133 return False, "ANSI escape sequence detected"
134 if "\x00" in pat:
135 return False, "null byte detected"
136 if not pat.strip():
137 return False, "empty or whitespace-only pattern"
138 if _TRAVERSAL_RE.search(pat):
139 return False, "path traversal via '..' detected"
140 if pat.strip("/\\") == "..":
141 return False, "path traversal via '..' detected"
142 return True, ""
143
144
145 def _validate_patterns(patterns: list[str]) -> None:
146 """Check every pattern in *patterns*, exiting on the first invalid one."""
147 for pat in patterns:
148 ok, reason = _check_pattern(pat)
149 if not ok:
150 print(
151 f"❌ Invalid pattern {sanitize_display(repr(pat))}: {reason}",
152 file=sys.stderr,
153 )
154 raise SystemExit(ExitCode.USER_ERROR)
155
156
157 # ---------------------------------------------------------------------------
158 # Config helpers
159 # ---------------------------------------------------------------------------
160
161
162 def _read_config_safe(root) -> dict | None:
163 """Read the sparse config, exiting with a clear error on JSON corruption.
164
165 ``load_json_file`` (used by ``read_sparse_config``) returns ``None`` for
166 both "file not found" and "file is corrupt JSON". We distinguish the two
167 cases by checking whether the file exists before falling back to ``None``.
168 A present-but-unreadable config is always a user-visible error.
169 """
170 import json as _stdlib_json
171 cfg_path = root / ".muse" / "sparse-checkout"
172 if not cfg_path.exists():
173 return None
174 try:
175 raw = cfg_path.read_text(encoding="utf-8")
176 data = _stdlib_json.loads(raw)
177 if not isinstance(data, dict):
178 raise ValueError(f"expected a JSON object, got {type(data).__name__}")
179 return data
180 except Exception as exc:
181 print(
182 f"❌ Sparse-checkout config is corrupted and cannot be read: {exc}",
183 file=sys.stderr,
184 )
185 raise SystemExit(ExitCode.USER_ERROR)
186
187
188 def _validate_config_structure(cfg: dict) -> None:
189 """Exit with USER_ERROR if *cfg* is missing required fields or has invalid values.
190
191 Called after a successful JSON parse to catch configs that are syntactically
192 valid JSON but semantically invalid for sparse-checkout (e.g. missing 'mode'
193 or 'patterns' keys, unknown mode value).
194 """
195 if "mode" not in cfg:
196 print(
197 "❌ Sparse-checkout config is missing required 'mode' field.",
198 file=sys.stderr,
199 )
200 raise SystemExit(ExitCode.USER_ERROR)
201 if cfg["mode"] not in ("cone", "pattern"):
202 print(
203 f"❌ Sparse-checkout config has invalid mode "
204 f"'{sanitize_display(str(cfg['mode']))}'. Expected 'cone' or 'pattern'.",
205 file=sys.stderr,
206 )
207 raise SystemExit(ExitCode.USER_ERROR)
208 if "patterns" not in cfg:
209 print(
210 "❌ Sparse-checkout config is missing required 'patterns' field.",
211 file=sys.stderr,
212 )
213 raise SystemExit(ExitCode.USER_ERROR)
214
215
216 def _require_config(root) -> dict:
217 """Return the sparse config or exit with a helpful message if not initialised."""
218 cfg = _read_config_safe(root)
219 if cfg is None:
220 print(
221 "❌ Sparse-checkout is not initialised. Run `muse sparse-checkout init` first.",
222 file=sys.stderr,
223 )
224 raise SystemExit(ExitCode.USER_ERROR)
225 _validate_config_structure(cfg)
226 return cfg
227
228
229 # ---------------------------------------------------------------------------
230 # Subcommand handlers
231 # ---------------------------------------------------------------------------
232
233
234 def _cmd_init(args: argparse.Namespace, root) -> None:
235 """Activate sparse-checkout or switch its mode.
236
237 If no config exists: create one with the requested mode (default: cone).
238 If a config already exists:
239 - Same mode as requested → no-op; report ``switched=False``.
240 - Different mode → update mode, preserve patterns; report ``switched=True``.
241
242 Patterns are always preserved across mode switches so that a user switching
243 from cone to pattern mode keeps their directory list for manual editing.
244 """
245 elapsed = start_timer()
246
247
248 json_out: bool = getattr(args, "json_out", False)
249 requested_mode = "pattern" if args.no_cone else "cone"
250
251 cfg = _read_config_safe(root)
252
253 if cfg is None:
254 write_sparse_config(root, {"mode": requested_mode, "patterns": []})
255 if json_out:
256 print(_json.dumps({
257 "mode": requested_mode,
258 "switched": False,
259 "previous_mode": None,
260 "duration_ms": elapsed(),
261 "exit_code": 0,
262 }))
263 else:
264 print(f"Sparse-checkout enabled (mode: {requested_mode}).")
265 return
266
267 _validate_config_structure(cfg)
268 previous_mode = cfg["mode"]
269 switched = previous_mode != requested_mode
270
271 if switched:
272 cfg["mode"] = requested_mode
273 write_sparse_config(root, cfg)
274
275 if json_out:
276 print(_json.dumps({
277 "mode": requested_mode,
278 "switched": switched,
279 "previous_mode": previous_mode if switched else None,
280 "duration_ms": elapsed(),
281 "exit_code": 0,
282 }))
283 else:
284 if switched:
285 print(f"Sparse-checkout mode switched: {previous_mode} → {requested_mode}.")
286 else:
287 print(f"Sparse-checkout already enabled (mode: {previous_mode}).")
288
289
290 def _cmd_set(args: argparse.Namespace, root) -> None:
291 """Replace the full pattern list.
292
293 Validates every pattern for safety before writing. Exits with USER_ERROR
294 if any pattern is invalid or if sparse-checkout has not been initialised.
295 """
296 elapsed = start_timer()
297
298
299 json_out: bool = getattr(args, "json_out", False)
300 cfg = _require_config(root)
301 _validate_patterns(args.patterns)
302
303 cfg["patterns"] = list(args.patterns)
304 write_sparse_config(root, cfg)
305
306 if json_out:
307 print(_json.dumps({
308 "patterns": cfg["patterns"],
309 "total": len(cfg["patterns"]),
310 "duration_ms": elapsed(),
311 "exit_code": 0,
312 }))
313 else:
314 print(f"Patterns set ({len(cfg['patterns'])} total).")
315
316
317 def _cmd_add(args: argparse.Namespace, root) -> None:
318 """Append new patterns, skipping duplicates.
319
320 Validates every candidate pattern before adding. Exits with USER_ERROR
321 if any pattern is invalid or if sparse-checkout has not been initialised.
322 """
323 elapsed = start_timer()
324
325
326 json_out: bool = getattr(args, "json_out", False)
327 cfg = _require_config(root)
328 _validate_patterns(args.patterns)
329
330 existing = set(cfg["patterns"])
331 new_pats = [p for p in args.patterns if p not in existing]
332 cfg["patterns"].extend(new_pats)
333 write_sparse_config(root, cfg)
334
335 added = len(new_pats)
336 skipped = len(args.patterns) - added
337
338 if json_out:
339 print(_json.dumps({
340 "added": added,
341 "skipped": skipped,
342 "patterns": cfg["patterns"],
343 "total": len(cfg["patterns"]),
344 "duration_ms": elapsed(),
345 "exit_code": 0,
346 }))
347 else:
348 msg = f"Added {added} pattern(s)"
349 if skipped:
350 msg += f" ({skipped} already present)"
351 print(msg + ".")
352
353
354 def _cmd_list(args: argparse.Namespace, root) -> None:
355 """Display the active patterns and mode.
356
357 Returns exit code 0 even when sparse-checkout is disabled — the absence
358 of a config is not an error, just a state. Validates the config structure
359 when a config file is present (catches post-hoc corruption).
360 """
361 elapsed = start_timer()
362
363
364 json_out: bool = getattr(args, "output_json", False)
365 cfg = _read_config_safe(root)
366
367 if cfg is not None:
368 _validate_config_structure(cfg)
369
370 if cfg is None:
371 if json_out:
372 print(_json.dumps({
373 "enabled": False,
374 "mode": None,
375 "patterns": [],
376 "duration_ms": elapsed(),
377 "exit_code": 0,
378 }))
379 else:
380 print("Sparse-checkout is disabled (full working tree).")
381 return
382
383 if json_out:
384 print(_json.dumps({
385 "enabled": True,
386 "mode": cfg["mode"],
387 "patterns": cfg["patterns"],
388 "duration_ms": elapsed(),
389 "exit_code": 0,
390 }))
391 else:
392 mode = cfg["mode"]
393 patterns = cfg["patterns"]
394 print(f"Mode: {mode}")
395 print(f"Patterns: {len(patterns)}")
396 if patterns:
397 print()
398 for pat in patterns:
399 print(f" {pat}")
400 else:
401 print(" (none — matches nothing)")
402
403
404 def _cmd_stats(args: argparse.Namespace, root) -> None:
405 """Report how many HEAD-snapshot files match the current sparse config.
406
407 Reads the HEAD commit's snapshot manifest and applies the sparse filter,
408 counting matching vs. excluded files. When sparse-checkout is disabled,
409 all files are considered matching (efficiency = 1.0). When no commits
410 exist yet, all counts are zero.
411
412 Always emits JSON — stats is a machine-first command.
413
414 JSON output fields
415 ------------------
416 ``enabled``
417 ``true`` when a sparse-checkout config is present.
418 ``mode``
419 ``"cone"`` or ``"pattern"``, or ``null`` when disabled.
420 ``patterns``
421 The active pattern list (empty list when disabled).
422 ``total_files``
423 Total files in the HEAD snapshot (0 if no commits).
424 ``matching_files``
425 Files that pass the sparse filter (or total when disabled).
426 ``excluded_files``
427 ``total_files - matching_files``.
428 ``efficiency``
429 ``matching_files / total_files``, or ``1.0`` when disabled / no files.
430 ``duration_ms``
431 Wall-clock milliseconds from command start to JSON emission.
432 ``exit_code``
433 Always 0 on success.
434 """
435 elapsed = start_timer()
436
437
438 cfg = _read_config_safe(root)
439 if cfg is not None:
440 _validate_config_structure(cfg)
441
442 # Resolve the HEAD snapshot manifest.
443 total = 0
444 manifest: dict = {}
445 try:
446 from muse.core.store import (
447 get_head_commit_id,
448 read_commit,
449 read_current_branch,
450 read_snapshot,
451 )
452 branch = read_current_branch(root)
453 commit_id = get_head_commit_id(root, branch)
454 if commit_id is not None:
455 commit = read_commit(root, commit_id)
456 if commit is not None:
457 snap = read_snapshot(root, commit.snapshot_id)
458 if snap is not None:
459 manifest = snap.manifest
460 total = len(manifest)
461 except Exception:
462 pass # No commits yet — counts stay zero.
463
464 if cfg is None:
465 matching = total
466 elif total == 0:
467 matching = 0
468 else:
469 matching = len(filter_manifest_sparse(manifest, cfg["patterns"], mode=cfg["mode"]))
470
471 excluded = total - matching
472 if total > 0:
473 efficiency = round(matching / total, 6)
474 else:
475 efficiency = 1.0 if cfg is None else 0.0
476
477 print(_json.dumps({
478 "enabled": cfg is not None,
479 "mode": cfg["mode"] if cfg else None,
480 "patterns": cfg["patterns"] if cfg else [],
481 "total_files": total,
482 "matching_files": matching,
483 "excluded_files": excluded,
484 "efficiency": efficiency,
485 "duration_ms": elapsed(),
486 "exit_code": 0,
487 }))
488
489
490 def _cmd_disable(args: argparse.Namespace, root) -> None:
491 """Remove the sparse-checkout config.
492
493 Idempotent — no error if already disabled. The next ``checkout`` or
494 ``merge`` will materialise the full working tree.
495 """
496 elapsed = start_timer()
497
498
499 json_out: bool = getattr(args, "json_out", False)
500 cfg = _read_config_safe(root)
501 was_enabled = cfg is not None
502
503 if was_enabled:
504 remove_sparse_config(root)
505
506 if json_out:
507 print(_json.dumps({
508 "was_enabled": was_enabled,
509 "duration_ms": elapsed(),
510 "exit_code": 0,
511 }))
512 else:
513 if was_enabled:
514 print("Sparse-checkout disabled. Full working tree will be restored on next checkout.")
515 else:
516 print("Sparse-checkout is already disabled.")
517
518
519 # ---------------------------------------------------------------------------
520 # Registration
521 # ---------------------------------------------------------------------------
522
523
524 def register(
525 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
526 ) -> None:
527 """Register the ``muse sparse-checkout`` subcommand."""
528 parser = subparsers.add_parser(
529 "sparse-checkout",
530 help="Partial working-tree materialization.",
531 description=__doc__,
532 formatter_class=argparse.RawDescriptionHelpFormatter,
533 )
534 sub = parser.add_subparsers(dest="sc_command", metavar="SUBCOMMAND")
535 sub.required = True
536
537 # init
538 p_init = sub.add_parser(
539 "init",
540 help="Activate sparse-checkout (or switch mode on existing config).",
541 )
542 p_init.add_argument(
543 "--no-cone",
544 action="store_true",
545 default=False,
546 help="Use glob-pattern mode instead of cone (directory-prefix) mode.",
547 )
548 p_init.add_argument(
549 "--json", "-j",
550 action="store_true",
551 dest="json_out",
552 help="Emit machine-readable JSON output.",
553 )
554 p_init.set_defaults(sc_func=_cmd_init)
555
556 # set
557 p_set = sub.add_parser("set", help="Replace pattern list.")
558 p_set.add_argument("patterns", nargs="+", metavar="PATTERN")
559 p_set.add_argument(
560 "--json", "-j",
561 action="store_true",
562 dest="json_out",
563 help="Emit machine-readable JSON output.",
564 )
565 p_set.set_defaults(sc_func=_cmd_set)
566
567 # add
568 p_add = sub.add_parser("add", help="Append patterns.")
569 p_add.add_argument("patterns", nargs="+", metavar="PATTERN")
570 p_add.add_argument(
571 "--json", "-j",
572 action="store_true",
573 dest="json_out",
574 help="Emit machine-readable JSON output.",
575 )
576 p_add.set_defaults(sc_func=_cmd_add)
577
578 # list
579 p_list = sub.add_parser("list", help="Show active patterns.")
580 p_list.add_argument(
581 "--json", "-j",
582 action="store_true",
583 dest="output_json",
584 help="Emit machine-readable JSON output.",
585 )
586 p_list.set_defaults(sc_func=_cmd_list)
587
588 # stats
589 p_stats = sub.add_parser(
590 "stats",
591 help="Show how many HEAD-snapshot files match the sparse config.",
592 )
593 p_stats.add_argument(
594 "--json", "-j",
595 action="store_true",
596 dest="json_out",
597 help="Emit machine-readable JSON output (default for stats).",
598 )
599 p_stats.set_defaults(sc_func=_cmd_stats)
600
601 # disable
602 p_dis = sub.add_parser("disable", help="Remove sparse-checkout configuration.")
603 p_dis.add_argument(
604 "--json", "-j",
605 action="store_true",
606 dest="json_out",
607 help="Emit machine-readable JSON output.",
608 )
609 p_dis.set_defaults(sc_func=_cmd_disable)
610
611 parser.set_defaults(func=run)
612
613
614 # ---------------------------------------------------------------------------
615 # Entry point
616 # ---------------------------------------------------------------------------
617
618
619 def run(args: argparse.Namespace) -> None:
620 """Dispatch to the appropriate sparse-checkout subcommand."""
621 root = require_repo()
622 args.sc_func(args, root)
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 143 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 146 days ago