sparse_checkout.py
python
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