gabriel / muse public
clean.py python
376 lines 12.6 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 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 from __future__ import annotations
64
65 import argparse
66 import fnmatch
67 import json
68 import logging
69 import pathlib
70 import sys
71 from typing import TypedDict
72
73 from muse.core.errors import ExitCode
74 from muse.core.ignore import load_ignore_config, resolve_patterns
75 from muse.core.repo import require_repo
76 from muse.core.snapshot import walk_workdir
77 from muse.core.store import get_head_commit_id, read_commit, read_current_branch, read_snapshot
78 from muse.core.validation import sanitize_display
79 from muse.plugins.registry import read_domain
80 from muse.core._types import Manifest
81 from muse.core.paths import muse_dir as _muse_dir
82 from muse.core.timing import start_timer
83 from muse.core.envelope import EnvelopeJson, make_envelope
84
85 logger = logging.getLogger(__name__)
86
87
88 # ---------------------------------------------------------------------------
89 # JSON wire format
90 # ---------------------------------------------------------------------------
91
92
93 class _CleanResultJson(EnvelopeJson):
94 """JSON output for ``muse clean``."""
95
96 status: str # "clean" | "would_remove" | "removed"
97 removed: list[str]
98 dirs_removed: list[str]
99 count: int
100 dry_run: bool
101
102
103 # ---------------------------------------------------------------------------
104 # Helpers
105 # ---------------------------------------------------------------------------
106
107
108 def _is_ignored(path: str, patterns: list[str]) -> bool:
109 """Return ``True`` if *path* matches any ``.museignore`` pattern.
110
111 Uses last-match-wins semantics so that negation patterns (lines starting
112 with ``!``) can un-ignore previously matched paths.
113
114 Uses ``fnmatch.fnmatch`` against both the full relative path and the
115 filename component (``path.rsplit("/", 1)[-1]``) to mirror the behaviour
116 of ``.gitignore`` pattern matching.
117 """
118 result = False
119 basename = path.rsplit("/", 1)[-1]
120 for pat in patterns:
121 negate = pat.startswith("!")
122 effective = pat[1:] if negate else pat
123 if fnmatch.fnmatch(path, effective) or fnmatch.fnmatch(basename, effective):
124 result = not negate
125 return result
126
127
128 def _safe_to_delete(root: pathlib.Path, target: pathlib.Path) -> bool:
129 """Return ``True`` if *target* is safe to delete.
130
131 Guards:
132 - Target must resolve inside *root* (prevents path-traversal).
133 - Target must not be a directory (directories are handled separately).
134 - The ``.muse/`` subtree is unconditionally protected.
135 """
136 try:
137 target.resolve().relative_to(root.resolve())
138 except ValueError:
139 logger.warning(
140 "⚠️ Skipping %s — resolves outside repository root", target
141 )
142 return False
143 muse_dir = _muse_dir(root)
144 try:
145 target.relative_to(muse_dir)
146 logger.warning("⚠️ Skipping %s — inside .muse/", target)
147 return False
148 except ValueError:
149 pass
150 return True
151
152
153 def _safe_to_rmdir(root: pathlib.Path, d: pathlib.Path) -> bool:
154 """Return ``True`` if *d* is safe to remove as an empty directory.
155
156 Protects the repository root, ``.muse/``, and any path inside ``.muse/``.
157 """
158 if d == root:
159 return False
160 muse_dir = _muse_dir(root)
161 if d == muse_dir:
162 return False
163 try:
164 d.relative_to(muse_dir)
165 return False # inside .muse/
166 except ValueError:
167 pass
168 try:
169 d.resolve().relative_to(root.resolve())
170 except ValueError:
171 return False # outside root
172 return True
173
174
175 # ---------------------------------------------------------------------------
176 # Command registration
177 # ---------------------------------------------------------------------------
178
179
180 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
181 """Register the ``muse clean`` subcommand."""
182 parser = subparsers.add_parser(
183 "clean",
184 help="Remove untracked files from the working tree.",
185 description=__doc__,
186 formatter_class=argparse.RawDescriptionHelpFormatter,
187 )
188 parser.add_argument(
189 "-n", "--dry-run",
190 action="store_true",
191 dest="dry_run",
192 help="Preview — show what would be removed without deleting.",
193 )
194 parser.add_argument(
195 "-f", "--force",
196 action="store_true",
197 help="Delete untracked files (required unless --dry-run is passed).",
198 )
199 parser.add_argument(
200 "-x", "--include-ignored",
201 action="store_true",
202 dest="include_ignored",
203 help="Also delete .museignore-excluded files.",
204 )
205 parser.add_argument(
206 "-d", "--directories",
207 action="store_true",
208 help="Also remove empty untracked directories after file deletion.",
209 )
210 parser.add_argument(
211 "--json", "-j",
212 action="store_true",
213 dest="json_out",
214 help="Emit machine-readable JSON on stdout.",
215 )
216 parser.set_defaults(func=run)
217
218
219 # ---------------------------------------------------------------------------
220 # Main handler
221 # ---------------------------------------------------------------------------
222
223
224 def run(args: argparse.Namespace) -> None:
225 """Remove untracked files from the working tree.
226
227 Files not tracked in the HEAD snapshot are considered untracked.
228 ``--force`` is required to actually delete; without it the command exits
229 with an error unless ``--dry-run`` is given. The ``.muse/`` subtree is
230 unconditionally protected regardless of working-tree content.
231
232 Agent quickstart
233 ----------------
234 ::
235
236 muse clean --dry-run --json
237 muse clean --force --json
238 muse clean --force -d -x --json
239
240 JSON fields
241 -----------
242 status Outcome: ``"clean"`` (nothing to remove), ``"would_remove"``
243 (dry-run with untracked files found), or ``"removed"``.
244 removed List of file paths removed (or that would be removed).
245 dirs_removed List of empty directory paths removed (with ``-d``).
246 count Total number of paths removed.
247 dry_run ``true`` when ``--dry-run`` was passed.
248
249 Exit codes
250 ----------
251 0 Success (or nothing to remove).
252 1 ``--force`` not given and ``--dry-run`` not given.
253 2 Not inside a Muse repository.
254 """
255 elapsed = start_timer()
256 dry_run: bool = args.dry_run
257 force: bool = args.force
258 include_ignored: bool = args.include_ignored
259 directories: bool = args.directories
260 json_out: bool = args.json_out
261
262 if not force and not dry_run:
263 print(
264 "⚠️ fatal: clean.requireForce is set to true.\n"
265 " Use --force to remove files, or --dry-run / -n to preview.",
266 file=sys.stderr,
267 )
268 raise SystemExit(ExitCode.USER_ERROR)
269
270 root = require_repo()
271 branch = read_current_branch(root)
272 domain = read_domain(root)
273
274 # Build committed manifest (empty for a branch with no commits yet).
275 committed: Manifest = {}
276 head_commit_id = get_head_commit_id(root, branch)
277 if head_commit_id:
278 commit = read_commit(root, head_commit_id)
279 if commit:
280 snap = read_snapshot(root, commit.snapshot_id)
281 if snap:
282 committed = snap.manifest
283
284 # Build current workdir manifest.
285 current = walk_workdir(root)
286
287 # Load ignore patterns; warn on failure but continue.
288 ignored_patterns: list[str] = []
289 if not include_ignored:
290 try:
291 ignore_cfg = load_ignore_config(root)
292 ignored_patterns = resolve_patterns(ignore_cfg, domain)
293 except OSError as exc:
294 logger.warning("⚠️ Could not load ignore config: %s", exc)
295
296 # Collect untracked paths.
297 untracked: list[str] = []
298 for rel_path in sorted(current):
299 if rel_path in committed:
300 continue
301 if not include_ignored and _is_ignored(rel_path, ignored_patterns):
302 continue
303 untracked.append(rel_path)
304
305 if not untracked:
306 if json_out:
307 print(json.dumps(_CleanResultJson(
308 **make_envelope(elapsed),
309 status="clean",
310 removed=[],
311 dirs_removed=[],
312 count=0,
313 dry_run=dry_run,
314 )))
315 else:
316 print("Nothing to clean.")
317 return
318
319 prefix = "[dry-run] " if dry_run else ""
320 verb = "Would remove" if dry_run else "Removing"
321
322 removed_files: list[str] = []
323 removed_dirs_list: list[str] = []
324 candidate_dirs: set[pathlib.Path] = set()
325
326 for rel_path in untracked:
327 target = root / rel_path
328 if not json_out:
329 print(f"{prefix}{verb}: {sanitize_display(rel_path)}")
330 if not dry_run:
331 if not _safe_to_delete(root, target):
332 continue
333 try:
334 target.unlink(missing_ok=True)
335 removed_files.append(rel_path)
336 if directories:
337 candidate_dirs.add(target.parent)
338 except OSError as exc:
339 print(
340 f"❌ Could not remove {sanitize_display(rel_path)}: {exc}",
341 file=sys.stderr,
342 )
343 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
344 else:
345 removed_files.append(rel_path)
346
347 # Remove empty directories (bottom-up), protected by _safe_to_rmdir.
348 if not dry_run and directories:
349 for d in sorted(candidate_dirs, key=lambda p: len(p.parts), reverse=True):
350 if not _safe_to_rmdir(root, d):
351 continue
352 try:
353 if d.is_dir() and not any(d.iterdir()):
354 d.rmdir()
355 rel_dir = str(d.relative_to(root))
356 removed_dirs_list.append(rel_dir)
357 if not json_out:
358 print(f"Removing directory: {sanitize_display(rel_dir)}")
359 except OSError:
360 pass
361
362 count = len(removed_files)
363 if json_out:
364 print(json.dumps(_CleanResultJson(
365 **make_envelope(elapsed),
366 status="would_remove" if dry_run else "removed",
367 removed=removed_files,
368 dirs_removed=removed_dirs_list,
369 count=count,
370 dry_run=dry_run,
371 )))
372 else:
373 if dry_run:
374 print(f"\n{count} untracked file(s) would be removed.")
375 else:
376 print(f"\n✅ Removed {count} untracked file(s).")
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago