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