sparse_checkout.py
python
sha256:84df9126d09aeec0b8f1b908f0b06c10913feec28f3514b382efb1ba6d619385
refactor: rename StructuredMergePlugin to AddressedMergePlu…
Sonnet 4.6
minor
⚠ breaking
24 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 | import argparse |
| 91 | import json as _json |
| 92 | import re |
| 93 | import sys |
| 94 | |
| 95 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 96 | from muse.core.paths import sparse_checkout_path as _sparse_checkout_path |
| 97 | from muse.core.errors import ExitCode |
| 98 | from muse.core.repo import require_repo |
| 99 | from muse.core.sparse import ( |
| 100 | SparseConfig, |
| 101 | filter_manifest_sparse, |
| 102 | read_sparse_config, |
| 103 | remove_sparse_config, |
| 104 | write_sparse_config, |
| 105 | ) |
| 106 | from muse.core.validation import sanitize_display |
| 107 | from muse.core.timing import start_timer |
| 108 | |
| 109 | # --------------------------------------------------------------------------- |
| 110 | # Wire types |
| 111 | # --------------------------------------------------------------------------- |
| 112 | |
| 113 | class _SparseInitJson(EnvelopeJson): |
| 114 | mode: str |
| 115 | switched: bool |
| 116 | previous_mode: str | None |
| 117 | |
| 118 | class _SparseSetJson(EnvelopeJson): |
| 119 | patterns: list[str] |
| 120 | total: int |
| 121 | |
| 122 | class _SparseAddJson(EnvelopeJson): |
| 123 | added: int |
| 124 | skipped: int |
| 125 | patterns: list[str] |
| 126 | total: int |
| 127 | |
| 128 | class _SparseListJson(EnvelopeJson): |
| 129 | enabled: bool |
| 130 | mode: str | None |
| 131 | patterns: list[str] |
| 132 | |
| 133 | class _SparseStatsJson(EnvelopeJson): |
| 134 | enabled: bool |
| 135 | mode: str | None |
| 136 | patterns: list[str] |
| 137 | total_files: int |
| 138 | matching_files: int |
| 139 | excluded_files: int |
| 140 | efficiency: float |
| 141 | |
| 142 | class _SparseDisableJson(EnvelopeJson): |
| 143 | was_enabled: bool |
| 144 | |
| 145 | # --------------------------------------------------------------------------- |
| 146 | # Pattern validation |
| 147 | # --------------------------------------------------------------------------- |
| 148 | |
| 149 | _ANSI_RE = re.compile(r"\x1b|\x9b|\x1c|\x1d|\x1e|\x1f") |
| 150 | |
| 151 | # Matches ".." as a path component: preceded/followed by separator or string boundary. |
| 152 | _TRAVERSAL_RE = re.compile(r"(^|[/\\])\.\.([/\\]|$)") |
| 153 | |
| 154 | def _check_pattern(pat: str) -> tuple[bool, str]: |
| 155 | """Validate *pat* and return ``(ok, reason)``. |
| 156 | |
| 157 | Rejects patterns that are: |
| 158 | |
| 159 | - ANSI escape sequences — prevent terminal-injection when patterns are |
| 160 | printed to stdout/stderr. |
| 161 | - Null bytes — filesystem attack vector; ``open()`` rejects them on most |
| 162 | platforms, causing misleading errors deep in the call stack. |
| 163 | - Whitespace-only — meaningless as a path prefix or glob; almost certainly |
| 164 | a user error. |
| 165 | - Path traversal via ``..`` — a pattern like ``../../etc/passwd`` would |
| 166 | match files outside the repository root after prefix expansion. |
| 167 | """ |
| 168 | if _ANSI_RE.search(pat): |
| 169 | return False, "ANSI escape sequence detected" |
| 170 | if "\x00" in pat: |
| 171 | return False, "null byte detected" |
| 172 | if not pat.strip(): |
| 173 | return False, "empty or whitespace-only pattern" |
| 174 | if _TRAVERSAL_RE.search(pat): |
| 175 | return False, "path traversal via '..' detected" |
| 176 | if pat.strip("/\\") == "..": |
| 177 | return False, "path traversal via '..' detected" |
| 178 | return True, "" |
| 179 | |
| 180 | def _validate_patterns(patterns: list[str]) -> None: |
| 181 | """Check every pattern in *patterns*, exiting on the first invalid one.""" |
| 182 | for pat in patterns: |
| 183 | ok, reason = _check_pattern(pat) |
| 184 | if not ok: |
| 185 | print( |
| 186 | f"❌ Invalid pattern {sanitize_display(repr(pat))}: {reason}", |
| 187 | file=sys.stderr, |
| 188 | ) |
| 189 | raise SystemExit(ExitCode.USER_ERROR) |
| 190 | |
| 191 | # --------------------------------------------------------------------------- |
| 192 | # Config helpers |
| 193 | # --------------------------------------------------------------------------- |
| 194 | |
| 195 | def _read_config_safe(root: pathlib.Path) -> SparseConfig | None: |
| 196 | """Read the sparse config, exiting with a clear error on JSON corruption. |
| 197 | |
| 198 | ``load_json_file`` (used by ``read_sparse_config``) returns ``None`` for |
| 199 | both "file not found" and "file is corrupt JSON". We distinguish the two |
| 200 | cases by checking whether the file exists before falling back to ``None``. |
| 201 | A present-but-unreadable config is always a user-visible error. |
| 202 | """ |
| 203 | import json as _stdlib_json |
| 204 | cfg_path = _sparse_checkout_path(root) |
| 205 | if not cfg_path.exists(): |
| 206 | return None |
| 207 | try: |
| 208 | raw = cfg_path.read_text(encoding="utf-8") |
| 209 | data = _stdlib_json.loads(raw) |
| 210 | if not isinstance(data, dict): |
| 211 | raise ValueError(f"expected a JSON object, got {type(data).__name__}") |
| 212 | return data |
| 213 | except Exception as exc: |
| 214 | print( |
| 215 | f"❌ Sparse-checkout config is corrupted and cannot be read: {exc}", |
| 216 | file=sys.stderr, |
| 217 | ) |
| 218 | raise SystemExit(ExitCode.USER_ERROR) |
| 219 | |
| 220 | def _validate_config_structure(cfg: SparseConfig) -> None: |
| 221 | """Exit with USER_ERROR if *cfg* is missing required fields or has invalid values. |
| 222 | |
| 223 | Called after a successful JSON parse to catch configs that are syntactically |
| 224 | valid JSON but semantically invalid for sparse-checkout (e.g. missing 'mode' |
| 225 | or 'patterns' keys, unknown mode value). |
| 226 | """ |
| 227 | if "mode" not in cfg: |
| 228 | print( |
| 229 | "❌ Sparse-checkout config is missing required 'mode' field.", |
| 230 | file=sys.stderr, |
| 231 | ) |
| 232 | raise SystemExit(ExitCode.USER_ERROR) |
| 233 | if cfg["mode"] not in ("cone", "pattern"): |
| 234 | print( |
| 235 | f"❌ Sparse-checkout config has invalid mode " |
| 236 | f"'{sanitize_display(str(cfg['mode']))}'. Expected 'cone' or 'pattern'.", |
| 237 | file=sys.stderr, |
| 238 | ) |
| 239 | raise SystemExit(ExitCode.USER_ERROR) |
| 240 | if "patterns" not in cfg: |
| 241 | print( |
| 242 | "❌ Sparse-checkout config is missing required 'patterns' field.", |
| 243 | file=sys.stderr, |
| 244 | ) |
| 245 | raise SystemExit(ExitCode.USER_ERROR) |
| 246 | |
| 247 | def _require_config(root: pathlib.Path) -> SparseConfig: |
| 248 | """Return the sparse config or exit with a helpful message if not initialised.""" |
| 249 | cfg = _read_config_safe(root) |
| 250 | if cfg is None: |
| 251 | print( |
| 252 | "❌ Sparse-checkout is not initialised. Run `muse sparse-checkout init` first.", |
| 253 | file=sys.stderr, |
| 254 | ) |
| 255 | raise SystemExit(ExitCode.USER_ERROR) |
| 256 | _validate_config_structure(cfg) |
| 257 | return cfg |
| 258 | |
| 259 | # --------------------------------------------------------------------------- |
| 260 | # Subcommand handlers |
| 261 | # --------------------------------------------------------------------------- |
| 262 | |
| 263 | def _cmd_init(args: argparse.Namespace, root: pathlib.Path) -> None: |
| 264 | """Activate sparse-checkout or switch its mode. |
| 265 | |
| 266 | Initialises a new sparse-checkout configuration with the requested mode |
| 267 | (``cone`` by default, ``pattern`` when ``--no-cone`` is set). When a |
| 268 | config already exists, running init again with the *same* mode is a no-op |
| 269 | (``switched=False``); a *different* mode switches the mode, preserving |
| 270 | existing patterns (``switched=True``). |
| 271 | |
| 272 | Agent quickstart:: |
| 273 | |
| 274 | muse sparse-checkout init --json |
| 275 | muse sparse-checkout init --no-cone --json |
| 276 | |
| 277 | JSON fields:: |
| 278 | |
| 279 | mode Active mode after this command: ``"cone"`` or ``"pattern"``. |
| 280 | switched ``true`` when the mode was changed from a previous setting. |
| 281 | previous_mode Prior mode when switched; ``null`` on first init or no-op. |
| 282 | muse_version Muse release that produced this output. |
| 283 | schema Envelope schema version (int). |
| 284 | exit_code ``0`` on success, ``1`` on config validation failure. |
| 285 | duration_ms Wall-clock milliseconds for the command. |
| 286 | timestamp ISO-8601 UTC timestamp of command completion. |
| 287 | warnings List of non-fatal advisory messages. |
| 288 | |
| 289 | Exit codes:: |
| 290 | |
| 291 | 0 Success. |
| 292 | 1 User error (invalid config). |
| 293 | 2 Usage error. |
| 294 | """ |
| 295 | elapsed = start_timer() |
| 296 | |
| 297 | json_out: bool = getattr(args, "json_out", False) |
| 298 | requested_mode = "pattern" if args.no_cone else "cone" |
| 299 | |
| 300 | cfg = _read_config_safe(root) |
| 301 | |
| 302 | if cfg is None: |
| 303 | write_sparse_config(root, {"mode": requested_mode, "patterns": []}) |
| 304 | if json_out: |
| 305 | print(_json.dumps(_SparseInitJson( |
| 306 | **make_envelope(elapsed), |
| 307 | mode=requested_mode, |
| 308 | switched=False, |
| 309 | previous_mode=None, |
| 310 | ))) |
| 311 | else: |
| 312 | print(f"Sparse-checkout enabled (mode: {requested_mode}).") |
| 313 | return |
| 314 | |
| 315 | _validate_config_structure(cfg) |
| 316 | previous_mode = cfg["mode"] |
| 317 | switched = previous_mode != requested_mode |
| 318 | |
| 319 | if switched: |
| 320 | cfg["mode"] = requested_mode |
| 321 | write_sparse_config(root, cfg) |
| 322 | |
| 323 | if json_out: |
| 324 | print(_json.dumps(_SparseInitJson( |
| 325 | **make_envelope(elapsed), |
| 326 | mode=requested_mode, |
| 327 | switched=switched, |
| 328 | previous_mode=previous_mode if switched else None, |
| 329 | ))) |
| 330 | else: |
| 331 | if switched: |
| 332 | print(f"Sparse-checkout mode switched: {previous_mode} → {requested_mode}.") |
| 333 | else: |
| 334 | print(f"Sparse-checkout already enabled (mode: {previous_mode}).") |
| 335 | |
| 336 | def _cmd_set(args: argparse.Namespace, root: pathlib.Path) -> None: |
| 337 | """Replace the full pattern list. |
| 338 | |
| 339 | Validates every pattern for safety before writing. Overwrites the |
| 340 | previous pattern list entirely — use ``add`` to append instead. Exits |
| 341 | with ``USER_ERROR`` if any pattern is invalid or if sparse-checkout has |
| 342 | not been initialised. |
| 343 | |
| 344 | Agent quickstart:: |
| 345 | |
| 346 | muse sparse-checkout set 'src/' 'tests/' --json |
| 347 | muse sparse-checkout set '**/*.py' --json |
| 348 | |
| 349 | JSON fields:: |
| 350 | |
| 351 | patterns Updated pattern list after the operation. |
| 352 | total Number of patterns now active. |
| 353 | muse_version Muse release that produced this output. |
| 354 | schema Envelope schema version (int). |
| 355 | exit_code ``0`` on success, ``1`` on error. |
| 356 | duration_ms Wall-clock milliseconds for the command. |
| 357 | timestamp ISO-8601 UTC timestamp of command completion. |
| 358 | warnings List of non-fatal advisory messages. |
| 359 | |
| 360 | Exit codes:: |
| 361 | |
| 362 | 0 Success. |
| 363 | 1 User error (not initialised, invalid pattern). |
| 364 | 2 Usage error. |
| 365 | """ |
| 366 | elapsed = start_timer() |
| 367 | |
| 368 | json_out: bool = getattr(args, "json_out", False) |
| 369 | cfg = _require_config(root) |
| 370 | _validate_patterns(args.patterns) |
| 371 | |
| 372 | cfg["patterns"] = list(args.patterns) |
| 373 | write_sparse_config(root, cfg) |
| 374 | |
| 375 | if json_out: |
| 376 | print(_json.dumps(_SparseSetJson( |
| 377 | **make_envelope(elapsed), |
| 378 | patterns=cfg["patterns"], |
| 379 | total=len(cfg["patterns"]), |
| 380 | ))) |
| 381 | else: |
| 382 | print(f"Patterns set ({len(cfg['patterns'])} total).") |
| 383 | |
| 384 | def _cmd_add(args: argparse.Namespace, root: pathlib.Path) -> None: |
| 385 | """Append new patterns, skipping duplicates. |
| 386 | |
| 387 | Validates every candidate pattern before adding. Patterns already present |
| 388 | are silently counted as ``skipped``; only genuinely new patterns are stored. |
| 389 | Exits with ``USER_ERROR`` if any pattern is invalid or if sparse-checkout |
| 390 | has not been initialised. |
| 391 | |
| 392 | Agent quickstart:: |
| 393 | |
| 394 | muse sparse-checkout add 'docs/' --json |
| 395 | muse sparse-checkout add '**/*.md' '**/*.rst' --json |
| 396 | |
| 397 | JSON fields:: |
| 398 | |
| 399 | added Number of new patterns appended. |
| 400 | skipped Number of patterns already present (not re-added). |
| 401 | patterns Full pattern list after the operation. |
| 402 | total Total number of patterns now active. |
| 403 | muse_version Muse release that produced this output. |
| 404 | schema Envelope schema version (int). |
| 405 | exit_code ``0`` on success, ``1`` on error. |
| 406 | duration_ms Wall-clock milliseconds for the command. |
| 407 | timestamp ISO-8601 UTC timestamp of command completion. |
| 408 | warnings List of non-fatal advisory messages. |
| 409 | |
| 410 | Exit codes:: |
| 411 | |
| 412 | 0 Success. |
| 413 | 1 User error (not initialised, invalid pattern). |
| 414 | 2 Usage error. |
| 415 | """ |
| 416 | elapsed = start_timer() |
| 417 | |
| 418 | json_out: bool = getattr(args, "json_out", False) |
| 419 | cfg = _require_config(root) |
| 420 | _validate_patterns(args.patterns) |
| 421 | |
| 422 | existing = set(cfg["patterns"]) |
| 423 | new_pats = [p for p in args.patterns if p not in existing] |
| 424 | cfg["patterns"].extend(new_pats) |
| 425 | write_sparse_config(root, cfg) |
| 426 | |
| 427 | added = len(new_pats) |
| 428 | skipped = len(args.patterns) - added |
| 429 | |
| 430 | if json_out: |
| 431 | print(_json.dumps(_SparseAddJson( |
| 432 | **make_envelope(elapsed), |
| 433 | added=added, |
| 434 | skipped=skipped, |
| 435 | patterns=cfg["patterns"], |
| 436 | total=len(cfg["patterns"]), |
| 437 | ))) |
| 438 | else: |
| 439 | msg = f"Added {added} pattern(s)" |
| 440 | if skipped: |
| 441 | msg += f" ({skipped} already present)" |
| 442 | print(f"{msg}.") |
| 443 | |
| 444 | def _cmd_list(args: argparse.Namespace, root: pathlib.Path) -> None: |
| 445 | """Display the active patterns and mode. |
| 446 | |
| 447 | Returns exit code 0 even when sparse-checkout is disabled — the absence |
| 448 | of a config is not an error, just a state. Validates the config structure |
| 449 | when a config file is present (catches post-hoc corruption). |
| 450 | |
| 451 | Agent quickstart:: |
| 452 | |
| 453 | muse sparse-checkout list --json |
| 454 | |
| 455 | JSON fields:: |
| 456 | |
| 457 | enabled ``true`` when sparse-checkout is configured. |
| 458 | mode ``"cone"`` or ``"pattern"`` when enabled; ``null`` when disabled. |
| 459 | patterns Active pattern list; empty list when disabled. |
| 460 | muse_version Muse release that produced this output. |
| 461 | schema Envelope schema version (int). |
| 462 | exit_code ``0`` on success, ``1`` on config corruption. |
| 463 | duration_ms Wall-clock milliseconds for the command. |
| 464 | timestamp ISO-8601 UTC timestamp of command completion. |
| 465 | warnings List of non-fatal advisory messages. |
| 466 | |
| 467 | Exit codes:: |
| 468 | |
| 469 | 0 Success (including when sparse-checkout is disabled). |
| 470 | 1 User error (corrupt config). |
| 471 | """ |
| 472 | elapsed = start_timer() |
| 473 | |
| 474 | json_out: bool = getattr(args, "json_out", False) |
| 475 | cfg = _read_config_safe(root) |
| 476 | |
| 477 | if cfg is not None: |
| 478 | _validate_config_structure(cfg) |
| 479 | |
| 480 | if cfg is None: |
| 481 | if json_out: |
| 482 | print(_json.dumps(_SparseListJson( |
| 483 | **make_envelope(elapsed), |
| 484 | enabled=False, |
| 485 | mode=None, |
| 486 | patterns=[], |
| 487 | ))) |
| 488 | else: |
| 489 | print("Sparse-checkout is disabled (full working tree).") |
| 490 | return |
| 491 | |
| 492 | if json_out: |
| 493 | print(_json.dumps(_SparseListJson( |
| 494 | **make_envelope(elapsed), |
| 495 | enabled=True, |
| 496 | mode=cfg["mode"], |
| 497 | patterns=cfg["patterns"], |
| 498 | ))) |
| 499 | else: |
| 500 | mode = cfg["mode"] |
| 501 | patterns = cfg["patterns"] |
| 502 | print(f"Mode: {mode}") |
| 503 | print(f"Patterns: {len(patterns)}") |
| 504 | if patterns: |
| 505 | print() |
| 506 | for pat in patterns: |
| 507 | print(f" {pat}") |
| 508 | else: |
| 509 | print(" (none — matches nothing)") |
| 510 | |
| 511 | def _cmd_stats(args: argparse.Namespace, root: pathlib.Path) -> None: |
| 512 | """Report how many HEAD-snapshot files match the current sparse config. |
| 513 | |
| 514 | Reads the HEAD commit's snapshot manifest and applies the sparse filter, |
| 515 | counting matching vs. excluded files. When sparse-checkout is disabled, |
| 516 | all files are considered matching (efficiency = 1.0). When no commits |
| 517 | exist yet, all counts are zero. |
| 518 | |
| 519 | Agent quickstart:: |
| 520 | |
| 521 | muse sparse-checkout stats --json |
| 522 | |
| 523 | JSON fields:: |
| 524 | |
| 525 | enabled ``true`` when a sparse-checkout config is present. |
| 526 | mode ``"cone"`` or ``"pattern"``; ``null`` when disabled. |
| 527 | patterns Active pattern list; empty list when disabled. |
| 528 | total_files Total files in the HEAD snapshot (0 if no commits). |
| 529 | matching_files Files that pass the sparse filter (or total when disabled). |
| 530 | excluded_files ``total_files - matching_files``. |
| 531 | efficiency ``matching_files / total_files``; 1.0 when disabled or no files. |
| 532 | muse_version Muse release that produced this output. |
| 533 | schema Envelope schema version (int). |
| 534 | exit_code Always ``0`` on success. |
| 535 | duration_ms Wall-clock milliseconds for the command. |
| 536 | timestamp ISO-8601 UTC timestamp of command completion. |
| 537 | warnings List of non-fatal advisory messages. |
| 538 | |
| 539 | Exit codes:: |
| 540 | |
| 541 | 0 Success. |
| 542 | 1 User error (corrupt config). |
| 543 | """ |
| 544 | elapsed = start_timer() |
| 545 | |
| 546 | cfg = _read_config_safe(root) |
| 547 | if cfg is not None: |
| 548 | _validate_config_structure(cfg) |
| 549 | |
| 550 | # Resolve the HEAD snapshot manifest. |
| 551 | total = 0 |
| 552 | manifest = {} |
| 553 | try: |
| 554 | from muse.core.refs import ( |
| 555 | get_head_commit_id, |
| 556 | read_current_branch, |
| 557 | ) |
| 558 | from muse.core.commits import read_commit |
| 559 | from muse.core.snapshots import read_snapshot |
| 560 | branch = read_current_branch(root) |
| 561 | commit_id = get_head_commit_id(root, branch) |
| 562 | if commit_id is not None: |
| 563 | commit = read_commit(root, commit_id) |
| 564 | if commit is not None: |
| 565 | snap = read_snapshot(root, commit.snapshot_id) |
| 566 | if snap is not None: |
| 567 | manifest = snap.manifest |
| 568 | total = len(manifest) |
| 569 | except Exception: |
| 570 | pass # No commits yet — counts stay zero. |
| 571 | |
| 572 | if cfg is None: |
| 573 | matching = total |
| 574 | elif total == 0: |
| 575 | matching = 0 |
| 576 | else: |
| 577 | matching = len(filter_manifest_sparse(manifest, cfg["patterns"], mode=cfg["mode"])) |
| 578 | |
| 579 | excluded = total - matching |
| 580 | if total > 0: |
| 581 | efficiency = round(matching / total, 6) |
| 582 | else: |
| 583 | efficiency = 1.0 if cfg is None else 0.0 |
| 584 | |
| 585 | print(_json.dumps(_SparseStatsJson( |
| 586 | **make_envelope(elapsed), |
| 587 | enabled=cfg is not None, |
| 588 | mode=cfg["mode"] if cfg else None, |
| 589 | patterns=cfg["patterns"] if cfg else [], |
| 590 | total_files=total, |
| 591 | matching_files=matching, |
| 592 | excluded_files=excluded, |
| 593 | efficiency=efficiency, |
| 594 | ))) |
| 595 | |
| 596 | def _cmd_disable(args: argparse.Namespace, root: pathlib.Path) -> None: |
| 597 | """Remove the sparse-checkout config. |
| 598 | |
| 599 | Idempotent — no error if already disabled. The next ``checkout`` or |
| 600 | ``merge`` will materialise the full working tree. |
| 601 | |
| 602 | Agent quickstart:: |
| 603 | |
| 604 | muse sparse-checkout disable --json |
| 605 | |
| 606 | JSON fields:: |
| 607 | |
| 608 | was_enabled ``true`` when sparse-checkout was active before this command. |
| 609 | muse_version Muse release that produced this output. |
| 610 | schema Envelope schema version (int). |
| 611 | exit_code ``0`` on success. |
| 612 | duration_ms Wall-clock milliseconds for the command. |
| 613 | timestamp ISO-8601 UTC timestamp of command completion. |
| 614 | warnings List of non-fatal advisory messages. |
| 615 | |
| 616 | Exit codes:: |
| 617 | |
| 618 | 0 Success (including when sparse-checkout was already disabled). |
| 619 | 1 User error (corrupt config). |
| 620 | """ |
| 621 | elapsed = start_timer() |
| 622 | |
| 623 | json_out: bool = getattr(args, "json_out", False) |
| 624 | cfg = _read_config_safe(root) |
| 625 | was_enabled = cfg is not None |
| 626 | |
| 627 | if was_enabled: |
| 628 | remove_sparse_config(root) |
| 629 | |
| 630 | if json_out: |
| 631 | print(_json.dumps(_SparseDisableJson( |
| 632 | **make_envelope(elapsed), |
| 633 | was_enabled=was_enabled, |
| 634 | ))) |
| 635 | else: |
| 636 | if was_enabled: |
| 637 | print("Sparse-checkout disabled. Full working tree will be restored on next checkout.") |
| 638 | else: |
| 639 | print("Sparse-checkout is already disabled.") |
| 640 | |
| 641 | # --------------------------------------------------------------------------- |
| 642 | # Registration |
| 643 | # --------------------------------------------------------------------------- |
| 644 | |
| 645 | def register( |
| 646 | subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 647 | ) -> None: |
| 648 | """Register the ``muse sparse-checkout`` subcommand.""" |
| 649 | parser = subparsers.add_parser( |
| 650 | "sparse-checkout", |
| 651 | help="Partial working-tree materialization.", |
| 652 | description=__doc__, |
| 653 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 654 | ) |
| 655 | sub = parser.add_subparsers(dest="sc_command", metavar="SUBCOMMAND") |
| 656 | sub.required = True |
| 657 | |
| 658 | # init |
| 659 | p_init = sub.add_parser( |
| 660 | "init", |
| 661 | help="Activate sparse-checkout (or switch mode on existing config).", |
| 662 | ) |
| 663 | p_init.add_argument( |
| 664 | "--no-cone", |
| 665 | action="store_true", |
| 666 | default=False, |
| 667 | help="Use glob-pattern mode instead of cone (directory-prefix) mode.", |
| 668 | ) |
| 669 | p_init.add_argument( |
| 670 | "--json", "-j", |
| 671 | action="store_true", |
| 672 | dest="json_out", |
| 673 | help="Emit machine-readable JSON output.", |
| 674 | ) |
| 675 | p_init.set_defaults(sc_func=_cmd_init) |
| 676 | |
| 677 | # set |
| 678 | p_set = sub.add_parser("set", help="Replace pattern list.") |
| 679 | p_set.add_argument("patterns", nargs="+", metavar="PATTERN") |
| 680 | p_set.add_argument( |
| 681 | "--json", "-j", |
| 682 | action="store_true", |
| 683 | dest="json_out", |
| 684 | help="Emit machine-readable JSON output.", |
| 685 | ) |
| 686 | p_set.set_defaults(sc_func=_cmd_set) |
| 687 | |
| 688 | # add |
| 689 | p_add = sub.add_parser("add", help="Append patterns.") |
| 690 | p_add.add_argument("patterns", nargs="+", metavar="PATTERN") |
| 691 | p_add.add_argument( |
| 692 | "--json", "-j", |
| 693 | action="store_true", |
| 694 | dest="json_out", |
| 695 | help="Emit machine-readable JSON output.", |
| 696 | ) |
| 697 | p_add.set_defaults(sc_func=_cmd_add) |
| 698 | |
| 699 | # list |
| 700 | p_list = sub.add_parser("list", help="Show active patterns.") |
| 701 | p_list.add_argument( |
| 702 | "--json", "-j", |
| 703 | action="store_true", |
| 704 | dest="json_out", |
| 705 | help="Emit machine-readable JSON output.", |
| 706 | ) |
| 707 | p_list.set_defaults(sc_func=_cmd_list) |
| 708 | |
| 709 | # stats |
| 710 | p_stats = sub.add_parser( |
| 711 | "stats", |
| 712 | help="Show how many HEAD-snapshot files match the sparse config.", |
| 713 | ) |
| 714 | p_stats.add_argument( |
| 715 | "--json", "-j", |
| 716 | action="store_true", |
| 717 | dest="json_out", |
| 718 | help="Emit machine-readable JSON output (default for stats).", |
| 719 | ) |
| 720 | p_stats.set_defaults(sc_func=_cmd_stats) |
| 721 | |
| 722 | # disable |
| 723 | p_dis = sub.add_parser("disable", help="Remove sparse-checkout configuration.") |
| 724 | p_dis.add_argument( |
| 725 | "--json", "-j", |
| 726 | action="store_true", |
| 727 | dest="json_out", |
| 728 | help="Emit machine-readable JSON output.", |
| 729 | ) |
| 730 | p_dis.set_defaults(sc_func=_cmd_disable) |
| 731 | |
| 732 | parser.set_defaults(func=run) |
| 733 | |
| 734 | # --------------------------------------------------------------------------- |
| 735 | # Entry point |
| 736 | # --------------------------------------------------------------------------- |
| 737 | |
| 738 | def run(args: argparse.Namespace) -> None: |
| 739 | """Dispatch ``muse sparse-checkout`` to the appropriate subcommand handler. |
| 740 | |
| 741 | Subcommands: ``init``, ``set``, ``add``, ``list``, ``stats``, ``disable``. |
| 742 | Each handler carries its own docstring, JSON schema, and exit codes. |
| 743 | """ |
| 744 | root = require_repo() |
| 745 | args.sc_func(args, root) |
File History
1 commit
sha256:84df9126d09aeec0b8f1b908f0b06c10913feec28f3514b382efb1ba6d619385
refactor: rename StructuredMergePlugin to AddressedMergePlu…
Sonnet 4.6
minor
⚠
24 days ago