gabriel / muse public
clean.py python
369 lines 12.6 KB
Raw
sha256:84df9126d09aeec0b8f1b908f0b06c10913feec28f3514b382efb1ba6d619385 refactor: rename StructuredMergePlugin to AddressedMergePlu… Sonnet 4.6 minor ⚠ breaking 23 days ago
1 """``muse clean`` — remove untracked files from the working tree.
2
3 Scans the working tree against HEAD's snapshot and removes files that are
4 not tracked in any commit. By design, ``--force`` is required to actually
5 delete files; without it the command behaves as a dry-run (equivalent to
6 passing ``-n``).
7
8 Usage::
9
10 muse clean -n # preview — show what would be removed
11 muse clean -f # delete untracked files
12 muse clean -f -d # also delete untracked directories
13 muse clean -f -x # also delete .museignore-excluded files
14 muse clean -f -d -x # everything untracked + ignored
15 muse clean -f --json # machine-readable result
16
17 All subcommands accept ``--json`` for machine-readable output::
18
19 {
20 "status": "clean" | "would_remove" | "removed",
21 "removed": ["path/to/file.txt", ...],
22 "dirs_removed": ["path/to/dir", ...],
23 "count": N,
24 "dry_run": true | false,
25 "duration_ms": 0.000123,
26 "exit_code": 0
27 }
28
29 ``status`` values:
30
31 - ``"clean"`` — nothing to remove (dry-run or force, no untracked files)
32 - ``"would_remove"`` — dry-run with untracked files found; nothing deleted
33 - ``"removed"`` — force-clean completed; files were deleted
34
35 ``duration_ms``
36 Wall-clock time from argument parsing to output.
37 ``exit_code``
38 Mirrors the process exit code: ``0`` for success (clean, would_remove,
39 removed) and ``1`` when files exist but neither --force nor --dry-run given.
40 Lets agents evaluate the result without inspecting the process exit code
41 separately.
42
43 Exit codes::
44
45 0 — nothing to clean, or clean completed successfully
46 1 — untracked files exist but neither --force nor --dry-run given
47 2 — not a Muse repository
48 3 — I/O error during deletion
49
50 Security model::
51
52 Every candidate path returned by ``walk_workdir`` is validated to sit
53 inside the repository root before any deletion is attempted. Paths that
54 resolve outside the root are skipped with a warning; they cannot be
55 produced by ``walk_workdir`` under normal operation but the guard ensures
56 correctness even if the walker is extended in the future.
57
58 Directory removal only touches directories whose direct children were all
59 removed in the current run. The repository root, ``.muse/``, and any
60 path inside ``.muse/`` are unconditionally protected.
61 """
62
63 import argparse
64 import fnmatch
65 import json
66 import logging
67 import pathlib
68 import sys
69 from typing import TypedDict
70
71 from muse.core.errors import ExitCode
72 from muse.core.ignore import load_ignore_config, resolve_patterns
73 from muse.core.repo import require_repo
74 from muse.core.snapshot import walk_workdir
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.plugins.registry import read_domain
83 from muse.core.types import Manifest
84 from muse.core.paths import muse_dir as _muse_dir
85 from muse.core.timing import start_timer
86 from muse.core.envelope import EnvelopeJson, make_envelope
87
88 logger = logging.getLogger(__name__)
89
90 # ---------------------------------------------------------------------------
91 # JSON wire format
92 # ---------------------------------------------------------------------------
93
94 class _CleanResultJson(EnvelopeJson):
95 """JSON output for ``muse clean``."""
96
97 status: str # "clean" | "would_remove" | "removed"
98 removed: list[str]
99 dirs_removed: list[str]
100 count: int
101 dry_run: bool
102
103 # ---------------------------------------------------------------------------
104 # Helpers
105 # ---------------------------------------------------------------------------
106
107 def _is_ignored(path: str, patterns: list[str]) -> bool:
108 """Return ``True`` if *path* matches any ``.museignore`` pattern.
109
110 Uses last-match-wins semantics so that negation patterns (lines starting
111 with ``!``) can un-ignore previously matched paths.
112
113 Uses ``fnmatch.fnmatch`` against both the full relative path and the
114 filename component (``path.rsplit("/", 1)[-1]``) to mirror the behaviour
115 of ``.gitignore`` pattern matching.
116 """
117 result = False
118 basename = path.rsplit("/", 1)[-1]
119 for pat in patterns:
120 negate = pat.startswith("!")
121 effective = pat[1:] if negate else pat
122 if fnmatch.fnmatch(path, effective) or fnmatch.fnmatch(basename, effective):
123 result = not negate
124 return result
125
126 def _safe_to_delete(root: pathlib.Path, target: pathlib.Path) -> bool:
127 """Return ``True`` if *target* is safe to delete.
128
129 Guards:
130 - Target must resolve inside *root* (prevents path-traversal).
131 - Target must not be a directory (directories are handled separately).
132 - The ``.muse/`` subtree is unconditionally protected.
133 """
134 try:
135 target.resolve().relative_to(root.resolve())
136 except ValueError:
137 logger.warning(
138 "⚠️ Skipping %s — resolves outside repository root", target
139 )
140 return False
141 muse_dir = _muse_dir(root)
142 try:
143 target.relative_to(muse_dir)
144 logger.warning("⚠️ Skipping %s — inside .muse/", target)
145 return False
146 except ValueError:
147 pass
148 return True
149
150 def _safe_to_rmdir(root: pathlib.Path, d: pathlib.Path) -> bool:
151 """Return ``True`` if *d* is safe to remove as an empty directory.
152
153 Protects the repository root, ``.muse/``, and any path inside ``.muse/``.
154 """
155 if d == root:
156 return False
157 muse_dir = _muse_dir(root)
158 if d == muse_dir:
159 return False
160 try:
161 d.relative_to(muse_dir)
162 return False # inside .muse/
163 except ValueError:
164 pass
165 try:
166 d.resolve().relative_to(root.resolve())
167 except ValueError:
168 return False # outside root
169 return True
170
171 # ---------------------------------------------------------------------------
172 # Command registration
173 # ---------------------------------------------------------------------------
174
175 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
176 """Register the ``muse clean`` subcommand."""
177 parser = subparsers.add_parser(
178 "clean",
179 help="Remove untracked files from the working tree.",
180 description=__doc__,
181 formatter_class=argparse.RawDescriptionHelpFormatter,
182 )
183 parser.add_argument(
184 "-n", "--dry-run",
185 action="store_true",
186 dest="dry_run",
187 help="Preview — show what would be removed without deleting.",
188 )
189 parser.add_argument(
190 "-f", "--force",
191 action="store_true",
192 help="Delete untracked files (required unless --dry-run is passed).",
193 )
194 parser.add_argument(
195 "-x", "--include-ignored",
196 action="store_true",
197 dest="include_ignored",
198 help="Also delete .museignore-excluded files.",
199 )
200 parser.add_argument(
201 "-d", "--directories",
202 action="store_true",
203 help="Also remove empty untracked directories after file deletion.",
204 )
205 parser.add_argument(
206 "--json", "-j",
207 action="store_true",
208 dest="json_out",
209 help="Emit machine-readable JSON on stdout.",
210 )
211 parser.set_defaults(func=run)
212
213 # ---------------------------------------------------------------------------
214 # Main handler
215 # ---------------------------------------------------------------------------
216
217 def run(args: argparse.Namespace) -> None:
218 """Remove untracked files from the working tree.
219
220 Files not tracked in the HEAD snapshot are considered untracked.
221 ``--force`` is required to actually delete; without it the command exits
222 with an error unless ``--dry-run`` is given. The ``.muse/`` subtree is
223 unconditionally protected regardless of working-tree content.
224
225 Agent quickstart
226 ----------------
227 ::
228
229 muse clean --dry-run --json
230 muse clean --force --json
231 muse clean --force -d -x --json
232
233 JSON fields
234 -----------
235 status Outcome: ``"clean"`` (nothing to remove), ``"would_remove"``
236 (dry-run with untracked files found), or ``"removed"``.
237 removed List of file paths removed (or that would be removed).
238 dirs_removed List of empty directory paths removed (with ``-d``).
239 count Total number of paths removed.
240 dry_run ``true`` when ``--dry-run`` was passed.
241
242 Exit codes
243 ----------
244 0 Success (or nothing to remove).
245 1 ``--force`` not given and ``--dry-run`` not given.
246 2 Not inside a Muse repository.
247 """
248 elapsed = start_timer()
249 dry_run: bool = args.dry_run
250 force: bool = args.force
251 include_ignored: bool = args.include_ignored
252 directories: bool = args.directories
253 json_out: bool = args.json_out
254
255 if not force and not dry_run:
256 print(
257 "⚠️ fatal: clean.requireForce is set to true.\n"
258 " Use --force to remove files, or --dry-run / -n to preview.",
259 file=sys.stderr,
260 )
261 raise SystemExit(ExitCode.USER_ERROR)
262
263 root = require_repo()
264 branch = read_current_branch(root)
265 domain = read_domain(root)
266
267 # Build committed manifest (empty for a branch with no commits yet).
268 committed: Manifest = {}
269 head_commit_id = get_head_commit_id(root, branch)
270 if head_commit_id:
271 commit = read_commit(root, head_commit_id)
272 if commit:
273 snap = read_snapshot(root, commit.snapshot_id)
274 if snap:
275 committed = snap.manifest
276
277 # Build current workdir manifest.
278 current = walk_workdir(root)
279
280 # Load ignore patterns; warn on failure but continue.
281 ignored_patterns: list[str] = []
282 if not include_ignored:
283 try:
284 ignore_cfg = load_ignore_config(root)
285 ignored_patterns = resolve_patterns(ignore_cfg, domain)
286 except OSError as exc:
287 logger.warning("⚠️ Could not load ignore config: %s", exc)
288
289 # Collect untracked paths.
290 untracked: list[str] = []
291 for rel_path in sorted(current):
292 if rel_path in committed:
293 continue
294 if not include_ignored and _is_ignored(rel_path, ignored_patterns):
295 continue
296 untracked.append(rel_path)
297
298 if not untracked:
299 if json_out:
300 print(json.dumps(_CleanResultJson(
301 **make_envelope(elapsed),
302 status="clean",
303 removed=[],
304 dirs_removed=[],
305 count=0,
306 dry_run=dry_run,
307 )))
308 else:
309 print("Nothing to clean.")
310 return
311
312 prefix = "[dry-run] " if dry_run else ""
313 verb = "Would remove" if dry_run else "Removing"
314
315 removed_files: list[str] = []
316 removed_dirs_list: list[str] = []
317 candidate_dirs: set[pathlib.Path] = set()
318
319 for rel_path in untracked:
320 target = root / rel_path
321 if not json_out:
322 print(f"{prefix}{verb}: {sanitize_display(rel_path)}")
323 if not dry_run:
324 if not _safe_to_delete(root, target):
325 continue
326 try:
327 target.unlink(missing_ok=True)
328 removed_files.append(rel_path)
329 if directories:
330 candidate_dirs.add(target.parent)
331 except OSError as exc:
332 print(
333 f"❌ Could not remove {sanitize_display(rel_path)}: {exc}",
334 file=sys.stderr,
335 )
336 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
337 else:
338 removed_files.append(rel_path)
339
340 # Remove empty directories (bottom-up), protected by _safe_to_rmdir.
341 if not dry_run and directories:
342 for d in sorted(candidate_dirs, key=lambda p: len(p.parts), reverse=True):
343 if not _safe_to_rmdir(root, d):
344 continue
345 try:
346 if d.is_dir() and not any(d.iterdir()):
347 d.rmdir()
348 rel_dir = str(d.relative_to(root))
349 removed_dirs_list.append(rel_dir)
350 if not json_out:
351 print(f"Removing directory: {sanitize_display(rel_dir)}")
352 except OSError:
353 pass
354
355 count = len(removed_files)
356 if json_out:
357 print(json.dumps(_CleanResultJson(
358 **make_envelope(elapsed),
359 status="would_remove" if dry_run else "removed",
360 removed=removed_files,
361 dirs_removed=removed_dirs_list,
362 count=count,
363 dry_run=dry_run,
364 )))
365 else:
366 if dry_run:
367 print(f"\n{count} untracked file(s) would be removed.")
368 else:
369 print(f"\n✅ Removed {count} untracked file(s).")
File History 1 commit
sha256:84df9126d09aeec0b8f1b908f0b06c10913feec28f3514b382efb1ba6d619385 refactor: rename StructuredMergePlugin to AddressedMergePlu… Sonnet 4.6 minor 23 days ago