gabriel / muse public
rm.py python
490 lines 17.3 KB
Raw
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