rm.py
python
sha256:e6465e8a9b7fa8e6223ed4a3576e96c568c913ae2caeb9c31f15e7a81b250b40
docs: add | jq convention to --json section of agent-guide
Sonnet 4.6
1 day ago
| 1 | """``muse rm`` — remove files from tracking and optionally from disk. |
| 2 | |
| 3 | Mirrors ``git rm`` exactly. Files removed with ``muse rm`` are staged for |
| 4 | deletion in the next commit; the commit then drops them from the snapshot. |
| 5 | |
| 6 | Usage:: |
| 7 | |
| 8 | muse rm <path> [<path> …] # stage deletion + delete from disk |
| 9 | muse rm --cached <path> [<path> …] # stage deletion only (keep on disk) |
| 10 | muse rm -r <dir> # recursive — required when <path> is a dir |
| 11 | muse rm -n / --dry-run # preview what would be removed |
| 12 | muse rm -f / --force # override safety checks |
| 13 | muse rm --json # machine-readable output |
| 14 | |
| 15 | Staged changes after ``muse rm`` |
| 16 | --------------------------------- |
| 17 | ``muse status`` will show removed files as "D" (deleted / staged for deletion). |
| 18 | Run ``muse commit`` to record the removal in the next snapshot. |
| 19 | |
| 20 | To un-do a staged removal before committing:: |
| 21 | |
| 22 | muse code reset <file> # unstage the deletion; file is back in next commit |
| 23 | |
| 24 | Safety model |
| 25 | ------------ |
| 26 | By default ``muse rm`` refuses to remove: |
| 27 | |
| 28 | - **Modified files** — the on-disk content differs from the HEAD snapshot. |
| 29 | Pass ``--force`` / ``-f`` to override. |
| 30 | - **Staged-but-uncommitted additions** — the file was added with |
| 31 | ``muse code add`` but never committed. Pass ``--force`` / ``-f`` to remove. |
| 32 | - **Directories** — pass ``-r`` (recursive) to allow removing all tracked files |
| 33 | under a directory. |
| 34 | |
| 35 | The ``--cached`` flag removes only the stage entry (and marks the file deleted |
| 36 | in the next commit) without touching the on-disk copy. This is the correct |
| 37 | way to untrack a file while keeping it in the working tree — e.g. when the |
| 38 | file should be listed in ``.museignore`` instead. |
| 39 | |
| 40 | JSON output schema:: |
| 41 | |
| 42 | { |
| 43 | "status": "removed" | "dry_run" | "nothing_to_remove" | "error", |
| 44 | "removed": ["relative/path/a.txt", ...], |
| 45 | "cached": true | false, |
| 46 | "dry_run": true | false, |
| 47 | "count": N, |
| 48 | "duration_ms": 12.3, |
| 49 | "exit_code": 0 |
| 50 | } |
| 51 | |
| 52 | ``duration_ms`` is the wall-clock time in milliseconds from command start to |
| 53 | JSON output (useful for agent performance budgeting). ``exit_code`` mirrors |
| 54 | the process exit code so agents can parse the outcome without checking the |
| 55 | shell exit status separately. Both fields are present on all output paths, |
| 56 | including error responses. |
| 57 | |
| 58 | Exit codes:: |
| 59 | |
| 60 | 0 — one or more files removed (or would be removed in dry-run) |
| 61 | 1 — user error: file not tracked, directory without -r, safety check failed |
| 62 | 2 — not a Muse repository |
| 63 | 3 — I/O error during deletion |
| 64 | """ |
| 65 | |
| 66 | import argparse |
| 67 | import logging |
| 68 | import pathlib |
| 69 | import sys |
| 70 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 71 | from muse.core.errors import ExitCode |
| 72 | from muse.core.repo import require_repo |
| 73 | from muse.core.snapshot import hash_file |
| 74 | from muse.core.types import Manifest |
| 75 | from muse.core.refs import ( |
| 76 | get_head_commit_id, |
| 77 | read_current_branch, |
| 78 | ) |
| 79 | from muse.core.commits import read_commit |
| 80 | from muse.core.snapshots import read_snapshot |
| 81 | from muse.core.validation import sanitize_display |
| 82 | from muse.core.timing import start_timer |
| 83 | from muse.plugins.code.stage import ( |
| 84 | StagedEntry, |
| 85 | StagedFileMap, |
| 86 | make_entry, |
| 87 | read_stage, |
| 88 | write_stage, |
| 89 | ) |
| 90 | |
| 91 | logger = logging.getLogger(__name__) |
| 92 | |
| 93 | # --------------------------------------------------------------------------- |
| 94 | # JSON wire format |
| 95 | # --------------------------------------------------------------------------- |
| 96 | |
| 97 | class _RmResultJson(EnvelopeJson): |
| 98 | """JSON output for ``muse rm --json``.""" |
| 99 | |
| 100 | status: str # "removed" | "dry_run" | "nothing_to_remove" | "error" |
| 101 | removed: list[str] |
| 102 | cached: bool |
| 103 | dry_run: bool |
| 104 | count: int |
| 105 | |
| 106 | # --------------------------------------------------------------------------- |
| 107 | # Private helpers |
| 108 | # --------------------------------------------------------------------------- |
| 109 | |
| 110 | def _head_manifest(root: pathlib.Path) -> Manifest: |
| 111 | """Return the manifest from the current HEAD commit, or ``{}`` if none. |
| 112 | |
| 113 | Returns an empty dict for repositories with no commits yet. |
| 114 | Never raises — callers treat an empty manifest as "nothing committed." |
| 115 | """ |
| 116 | try: |
| 117 | branch = read_current_branch(root) |
| 118 | commit_id = get_head_commit_id(root, branch) |
| 119 | if not commit_id: |
| 120 | return {} |
| 121 | commit = read_commit(root, commit_id) |
| 122 | if commit is None: |
| 123 | return {} |
| 124 | snap = read_snapshot(root, commit.snapshot_id) |
| 125 | return dict(snap.manifest) if snap else {} |
| 126 | except Exception: |
| 127 | return {} |
| 128 | |
| 129 | def _collect_targets( |
| 130 | root: pathlib.Path, |
| 131 | raw_paths: list[str], |
| 132 | recursive: bool, |
| 133 | head_manifest: Manifest, |
| 134 | stage: StagedFileMap, |
| 135 | ) -> list[str]: |
| 136 | """Expand *raw_paths* into a sorted list of tracked relative paths. |
| 137 | |
| 138 | Directories are only expanded when *recursive* is ``True``. A path that |
| 139 | is neither tracked in HEAD nor staged raises ``SystemExit(USER_ERROR)`` |
| 140 | immediately. |
| 141 | |
| 142 | Paths that resolve outside the repository root are rejected regardless of |
| 143 | whether they exist on disk — this prevents directory-traversal attacks via |
| 144 | crafted relative paths like ``../../etc/passwd``. |
| 145 | |
| 146 | Returns relative POSIX paths (e.g. ``"src/auth.py"``). |
| 147 | """ |
| 148 | result: list[str] = [] |
| 149 | |
| 150 | for raw in raw_paths: |
| 151 | p = pathlib.Path(raw) |
| 152 | if not p.is_absolute(): |
| 153 | # Prefer CWD-relative resolution (normal real-world usage). |
| 154 | # Fall back to root-relative when CWD is outside the repository |
| 155 | # (common in tests and agent pipelines using -C or MUSE_REPO_ROOT). |
| 156 | cwd_candidate = (pathlib.Path.cwd() / p).resolve() |
| 157 | try: |
| 158 | cwd_candidate.relative_to(root.resolve()) |
| 159 | abs_target = cwd_candidate |
| 160 | except ValueError: |
| 161 | abs_target = (root / p).resolve() |
| 162 | else: |
| 163 | abs_target = p.resolve() |
| 164 | |
| 165 | # Reject paths that escape the repository root. |
| 166 | try: |
| 167 | rel = abs_target.relative_to(root.resolve()) |
| 168 | except ValueError: |
| 169 | print( |
| 170 | f"❌ fatal: '{sanitize_display(raw)}' is outside the repository root.", |
| 171 | file=sys.stderr, |
| 172 | ) |
| 173 | raise SystemExit(ExitCode.USER_ERROR) |
| 174 | |
| 175 | rel_posix = rel.as_posix() |
| 176 | |
| 177 | if abs_target.is_dir(): |
| 178 | if not recursive: |
| 179 | print( |
| 180 | f"❌ fatal: not removing '{sanitize_display(rel_posix)}' " |
| 181 | f"recursively without -r.", |
| 182 | file=sys.stderr, |
| 183 | ) |
| 184 | raise SystemExit(ExitCode.USER_ERROR) |
| 185 | # Collect all tracked files under this directory. |
| 186 | dir_files = [ |
| 187 | p for p in list(head_manifest.keys()) + [ |
| 188 | k for k, v in stage.items() if v["mode"] != "D" |
| 189 | ] |
| 190 | if (p == rel_posix or p.startswith(f"{rel_posix}/")) |
| 191 | and p not in result |
| 192 | ] |
| 193 | if not dir_files: |
| 194 | print( |
| 195 | f"❌ fatal: pathspec '{sanitize_display(rel_posix)}' did not match " |
| 196 | f"any tracked files.", |
| 197 | file=sys.stderr, |
| 198 | ) |
| 199 | raise SystemExit(ExitCode.USER_ERROR) |
| 200 | result.extend(dir_files) |
| 201 | else: |
| 202 | # Must be tracked in HEAD or staged (as A or M, not already D). |
| 203 | in_head = rel_posix in head_manifest |
| 204 | staged_entry = stage.get(rel_posix) |
| 205 | in_stage = staged_entry is not None and staged_entry["mode"] != "D" |
| 206 | |
| 207 | if not in_head and not in_stage: |
| 208 | print( |
| 209 | f"❌ fatal: pathspec '{sanitize_display(rel_posix)}' did not match " |
| 210 | f"any tracked files.", |
| 211 | file=sys.stderr, |
| 212 | ) |
| 213 | raise SystemExit(ExitCode.USER_ERROR) |
| 214 | |
| 215 | if rel_posix not in result: |
| 216 | result.append(rel_posix) |
| 217 | |
| 218 | return sorted(set(result)) |
| 219 | |
| 220 | def _check_safety( |
| 221 | root: pathlib.Path, |
| 222 | rel_path: str, |
| 223 | head_manifest: Manifest, |
| 224 | stage: StagedFileMap, |
| 225 | force: bool, |
| 226 | cached: bool, |
| 227 | ) -> None: |
| 228 | """Raise ``SystemExit(USER_ERROR)`` if removing *rel_path* is unsafe. |
| 229 | |
| 230 | Safety checks (skipped when *force* is ``True``): |
| 231 | |
| 232 | 1. **Staged-but-uncommitted addition** — the file is in stage with mode |
| 233 | ``"A"`` (it was added with ``muse code add`` but never committed). |
| 234 | Removing it would discard staged work that has never been recorded. |
| 235 | |
| 236 | 2. **Modified on disk vs HEAD** — the on-disk content has diverged from |
| 237 | the HEAD snapshot (only checked when *cached* is ``False``, because |
| 238 | ``--cached`` never touches the disk). |
| 239 | |
| 240 | Both checks are skipped entirely when *force* is ``True``. If the file |
| 241 | is absent from disk during check 2 (already manually deleted), the check |
| 242 | is silently skipped — the deletion will be staged regardless. |
| 243 | """ |
| 244 | if force: |
| 245 | return |
| 246 | |
| 247 | staged_entry = stage.get(rel_path) |
| 248 | in_head = rel_path in head_manifest |
| 249 | |
| 250 | # Check 1: staged addition that was never committed. |
| 251 | if staged_entry is not None and staged_entry["mode"] == "A" and not in_head: |
| 252 | print( |
| 253 | f"❌ error: '{sanitize_display(rel_path)}' has staged changes that have " |
| 254 | f"never been committed.\n" |
| 255 | f" Use --force to override, or 'muse code reset {rel_path}' to unstage.", |
| 256 | file=sys.stderr, |
| 257 | ) |
| 258 | raise SystemExit(ExitCode.USER_ERROR) |
| 259 | |
| 260 | # Check 2: on-disk content differs from HEAD (only when we'd delete the file). |
| 261 | if not cached and in_head: |
| 262 | abs_path = root / rel_path |
| 263 | if abs_path.exists(): |
| 264 | try: |
| 265 | disk_object_id = hash_file(abs_path) |
| 266 | head_object_id = head_manifest[rel_path] |
| 267 | if disk_object_id != head_object_id: |
| 268 | print( |
| 269 | f"❌ error: '{sanitize_display(rel_path)}' has local modifications.\n" |
| 270 | f" Use --force to override, or --cached to keep the file on disk.", |
| 271 | file=sys.stderr, |
| 272 | ) |
| 273 | raise SystemExit(ExitCode.USER_ERROR) |
| 274 | except OSError: |
| 275 | pass # File unreadable — proceed; deletion will surface the error. |
| 276 | |
| 277 | # --------------------------------------------------------------------------- |
| 278 | # Command registration |
| 279 | # --------------------------------------------------------------------------- |
| 280 | |
| 281 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 282 | """Register the ``muse rm`` subcommand.""" |
| 283 | parser = subparsers.add_parser( |
| 284 | "rm", |
| 285 | help="Remove files from tracking (and optionally from disk).", |
| 286 | description=__doc__, |
| 287 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 288 | ) |
| 289 | parser.add_argument( |
| 290 | "paths", |
| 291 | nargs="+", |
| 292 | metavar="PATH", |
| 293 | help="File(s) or directory(ies) to remove from tracking.", |
| 294 | ) |
| 295 | parser.add_argument( |
| 296 | "--cached", |
| 297 | action="store_true", |
| 298 | help=( |
| 299 | "Stage the deletion without deleting the file from disk. " |
| 300 | "Use this to untrack a file while keeping it in the working tree." |
| 301 | ), |
| 302 | ) |
| 303 | parser.add_argument( |
| 304 | "-r", "--recursive", |
| 305 | action="store_true", |
| 306 | dest="recursive", |
| 307 | help="Allow recursive removal when a directory is given.", |
| 308 | ) |
| 309 | parser.add_argument( |
| 310 | "-f", "--force", |
| 311 | action="store_true", |
| 312 | help=( |
| 313 | "Override safety checks — remove even if the file has local " |
| 314 | "modifications or staged-but-uncommitted changes." |
| 315 | ), |
| 316 | ) |
| 317 | parser.add_argument( |
| 318 | "-n", "--dry-run", |
| 319 | action="store_true", |
| 320 | dest="dry_run", |
| 321 | help="Preview what would be removed without making any changes.", |
| 322 | ) |
| 323 | parser.add_argument( |
| 324 | "--json", "-j", |
| 325 | action="store_true", |
| 326 | dest="json_out", |
| 327 | help="Emit machine-readable JSON on stdout.", |
| 328 | ) |
| 329 | parser.set_defaults(func=run) |
| 330 | |
| 331 | # --------------------------------------------------------------------------- |
| 332 | # Main handler |
| 333 | # --------------------------------------------------------------------------- |
| 334 | |
| 335 | def run(args: argparse.Namespace) -> None: |
| 336 | """Remove files from tracking and optionally from disk. |
| 337 | |
| 338 | Files are staged for deletion (mode ``"D"`` in the stage index) and will |
| 339 | be absent from the snapshot produced by the next ``muse commit``. |
| 340 | |
| 341 | Behaviour per flag combination: |
| 342 | |
| 343 | - No flags: stage deletion + delete file from disk (requires file is |
| 344 | unmodified vs HEAD, or ``--force``). |
| 345 | - ``--cached``: stage deletion only; on-disk file is untouched. |
| 346 | - ``--force`` / ``-f``: bypass the modified-file and staged-addition safety |
| 347 | checks. Use when you're certain you want to discard the on-disk changes. |
| 348 | - ``--dry-run`` / ``-n``: print what would be removed without writing |
| 349 | anything to the stage or the disk. Safety checks still apply unless |
| 350 | ``--force`` is also given. |
| 351 | - ``-r``: required when a path argument is a directory; expands to all |
| 352 | tracked files under that directory. |
| 353 | - ``--json``: machine-readable JSON on stdout (always, including error |
| 354 | paths and dry-run). |
| 355 | |
| 356 | Agent quickstart:: |
| 357 | |
| 358 | muse rm song.txt --json |
| 359 | muse rm --cached compiled/app.css --json |
| 360 | muse rm -r --cached build/ --json |
| 361 | muse rm -n --json *.txt |
| 362 | |
| 363 | JSON fields:: |
| 364 | |
| 365 | status str "removed" | "dry_run" | "nothing_to_remove" | "error" |
| 366 | removed list[str] Repo-relative paths that were (or would be) removed |
| 367 | cached bool True when --cached was in effect |
| 368 | dry_run bool True when no writes were made |
| 369 | count int Number of files removed |
| 370 | |
| 371 | Exit codes:: |
| 372 | |
| 373 | 0 Success (files removed or would be removed in dry-run). |
| 374 | 1 User error: file not tracked, directory without -r, safety check failed. |
| 375 | 2 Not a Muse repository. |
| 376 | 3 I/O error during deletion. |
| 377 | """ |
| 378 | import json as _json |
| 379 | |
| 380 | elapsed = start_timer() |
| 381 | |
| 382 | cached: bool = args.cached |
| 383 | recursive: bool = args.recursive |
| 384 | force: bool = args.force |
| 385 | dry_run: bool = args.dry_run |
| 386 | json_out: bool = args.json_out |
| 387 | |
| 388 | def _emit_error(exit_code: int) -> None: |
| 389 | """Emit a JSON error response on stdout when ``--json`` is active.""" |
| 390 | if json_out: |
| 391 | print(_json.dumps(_RmResultJson( |
| 392 | **make_envelope(elapsed, exit_code=exit_code), |
| 393 | status="error", |
| 394 | removed=[], |
| 395 | cached=cached, |
| 396 | dry_run=dry_run, |
| 397 | count=0, |
| 398 | ))) |
| 399 | |
| 400 | root = require_repo() |
| 401 | head_manifest = _head_manifest(root) |
| 402 | stage = read_stage(root) |
| 403 | |
| 404 | # Expand path arguments into a sorted list of relative tracked paths. |
| 405 | try: |
| 406 | targets = _collect_targets( |
| 407 | root, args.paths, recursive, head_manifest, stage |
| 408 | ) |
| 409 | except SystemExit as exc: |
| 410 | _emit_error(int(exc.code)) |
| 411 | raise |
| 412 | |
| 413 | if not targets: |
| 414 | if json_out: |
| 415 | print(_json.dumps(_RmResultJson( |
| 416 | **make_envelope(elapsed), |
| 417 | status="nothing_to_remove", |
| 418 | removed=[], |
| 419 | cached=cached, |
| 420 | dry_run=dry_run, |
| 421 | count=0, |
| 422 | ))) |
| 423 | else: |
| 424 | print("Nothing to remove.") |
| 425 | return |
| 426 | |
| 427 | # Run safety checks before touching anything. |
| 428 | try: |
| 429 | for rel_path in targets: |
| 430 | _check_safety(root, rel_path, head_manifest, stage, force, cached) |
| 431 | except SystemExit as exc: |
| 432 | _emit_error(int(exc.code)) |
| 433 | raise |
| 434 | |
| 435 | # At this point all targets are safe to remove. |
| 436 | removed: list[str] = [] |
| 437 | |
| 438 | if dry_run: |
| 439 | for rel_path in targets: |
| 440 | if not json_out: |
| 441 | print(f"[dry-run] Would remove: {sanitize_display(rel_path)}") |
| 442 | removed.append(rel_path) |
| 443 | else: |
| 444 | new_stage: StagedFileMap = dict(stage) |
| 445 | |
| 446 | for rel_path in targets: |
| 447 | in_head = rel_path in head_manifest |
| 448 | |
| 449 | if in_head: |
| 450 | # File exists in the last commit → stage it as deleted. |
| 451 | new_stage[rel_path] = make_entry(object_id="", mode="D") |
| 452 | else: |
| 453 | # File was only staged (mode "A") but never committed → |
| 454 | # remove it from the stage entirely (un-track it). |
| 455 | new_stage.pop(rel_path, None) |
| 456 | |
| 457 | # Delete from disk unless --cached or already gone. |
| 458 | if not cached: |
| 459 | abs_path = root / rel_path |
| 460 | if abs_path.exists(): |
| 461 | try: |
| 462 | abs_path.unlink() |
| 463 | except OSError as exc: |
| 464 | print( |
| 465 | f"❌ error: could not delete " |
| 466 | f"'{sanitize_display(rel_path)}': {exc}", |
| 467 | file=sys.stderr, |
| 468 | ) |
| 469 | _emit_error(ExitCode.INTERNAL_ERROR) |
| 470 | raise SystemExit(ExitCode.INTERNAL_ERROR) from exc |
| 471 | |
| 472 | removed.append(rel_path) |
| 473 | if not json_out: |
| 474 | verb = "rm (cached)" if cached else "rm" |
| 475 | print(f"{verb}: {sanitize_display(rel_path)}") |
| 476 | |
| 477 | write_stage(root, new_stage) |
| 478 | |
| 479 | count = len(removed) |
| 480 | |
| 481 | if json_out: |
| 482 | status = "dry_run" if dry_run else "removed" |
| 483 | print(_json.dumps(_RmResultJson( |
| 484 | **make_envelope(elapsed), |
| 485 | status=status, |
| 486 | removed=removed, |
| 487 | cached=cached, |
| 488 | dry_run=dry_run, |
| 489 | count=count, |
| 490 | ))) |
File History
1 commit
sha256:e6465e8a9b7fa8e6223ed4a3576e96c568c913ae2caeb9c31f15e7a81b250b40
docs: add | jq convention to --json section of agent-guide
Sonnet 4.6
1 day ago