gabriel / muse public
test_cmd_clean_hardening.py python
798 lines 28.1 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 134 days ago
1 """Hardening test suite for ``muse clean``.
2
3 Coverage:
4 - Unit: _is_ignored, _safe_to_delete, _safe_to_rmdir helpers
5 - Security: path-traversal guard, .muse/ protection, symlink skipping
6 - Error routing: all user errors go to stderr
7 - JSON schema: _CleanResultJson shape for all outcomes
8 - --dry-run: no side effects with and without --json
9 - --include-ignored: respects .museignore patterns
10 - --directories: empty-dir removal, .muse/ immune
11 - Integration: clean lifecycle (commit → add untracked → clean)
12 - E2E: help output, combined flags
13 - Stress: 1 000 untracked files, concurrent reads, 50-pattern ignore list
14 """
15
16 from __future__ import annotations
17
18 import datetime
19 import json
20 import os
21 import pathlib
22 import threading
23 from unittest.mock import patch
24
25 import pytest
26 from tests.cli_test_helper import CliRunner, InvokeResult
27 from typing import TypedDict
28
29 from muse.cli.commands.clean import _is_ignored, _safe_to_delete, _safe_to_rmdir
30 from muse.core.object_store import write_object
31 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
32 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
33 from muse.core._types import Manifest, blob_id
34
35 runner = CliRunner()
36
37
38 # ---------------------------------------------------------------------------
39 # Typed output shape (mirrors _CleanResultJson in clean.py)
40 # ---------------------------------------------------------------------------
41
42
43 class _CleanOut(TypedDict, total=False):
44 status: str
45 removed: list[str]
46 dirs_removed: list[str]
47 count: int
48 dry_run: bool
49 duration_ms: float
50 exit_code: int
51
52
53 # ---------------------------------------------------------------------------
54 # Helpers
55 # ---------------------------------------------------------------------------
56
57
58 def _sha(data: bytes) -> str:
59 """Return the canonical Muse object ID (sha256: prefix + 64 hex chars)."""
60 return blob_id(data)
61
62
63 def _init_repo(path: pathlib.Path, *, domain: str = "midi") -> pathlib.Path:
64 muse = path / ".muse"
65 for sub in ("commits", "snapshots", "objects", "refs/heads"):
66 (muse / sub).mkdir(parents=True, exist_ok=True)
67 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
68 (muse / "repo.json").write_text(
69 json.dumps({"repo_id": "clean-hard-test", "domain": domain}),
70 encoding="utf-8",
71 )
72 return path
73
74
75 def _commit_file(root: pathlib.Path, rel_path: str, content: bytes) -> str:
76 """Write *content* to *rel_path*, store the object and commit it."""
77 obj_id = _sha(content)
78 write_object(root, obj_id, content)
79 (root / rel_path).write_bytes(content)
80 manifest = {rel_path: obj_id}
81 snap_id = compute_snapshot_id(manifest)
82 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
83 write_snapshot(root, snap)
84 committed_at = datetime.datetime.now(datetime.timezone.utc)
85 commit_id = compute_commit_id(
86 repo_id="clean-hard-test",
87 parent_ids=[],
88 snapshot_id=snap_id,
89 message="initial",
90 committed_at_iso=committed_at.isoformat(),
91 )
92 write_commit(
93 root,
94 CommitRecord(
95 commit_id=commit_id,
96 repo_id="clean-hard-test",
97 created_on_branch="main",
98 snapshot_id=snap_id,
99 message="initial",
100 committed_at=committed_at,
101 ),
102 )
103 (root / ".muse" / "refs" / "heads" / "main").write_text(
104 commit_id, encoding="utf-8"
105 )
106 return commit_id
107
108
109 def _env(repo: pathlib.Path) -> Manifest:
110 return {"MUSE_REPO_ROOT": str(repo)}
111
112
113 def _invoke(args: list[str], env: Manifest) -> InvokeResult:
114 return runner.invoke(None, args, env=env)
115
116
117 def _parse_json(result: InvokeResult) -> _CleanOut:
118 for line in result.output.splitlines():
119 line = line.strip()
120 if line.startswith("{"):
121 raw = json.loads(line)
122 out = _CleanOut(
123 status=raw["status"],
124 removed=raw["removed"],
125 dirs_removed=raw["dirs_removed"],
126 count=raw["count"],
127 dry_run=raw["dry_run"],
128 )
129 if "duration_ms" in raw:
130 out["duration_ms"] = raw["duration_ms"]
131 if "exit_code" in raw:
132 out["exit_code"] = raw["exit_code"]
133 return out
134 raise AssertionError(f"No JSON line found in output:\n{result.output}")
135
136
137 # ---------------------------------------------------------------------------
138 # Unit: _is_ignored
139 # ---------------------------------------------------------------------------
140
141
142 def test_is_ignored_exact_match() -> None:
143 assert _is_ignored("build/out.o", ["build/*"]) is True
144
145
146 def test_is_ignored_basename_match() -> None:
147 assert _is_ignored("deep/nested/file.pyc", ["*.pyc"]) is True
148
149
150 def test_is_ignored_no_match() -> None:
151 assert _is_ignored("src/main.py", ["*.pyc", "build/*"]) is False
152
153
154 def test_is_ignored_negation_unignores() -> None:
155 # First pattern ignores all .log, second un-ignores keep.log.
156 assert _is_ignored("keep.log", ["*.log", "!keep.log"]) is False
157
158
159 def test_is_ignored_negation_last_match_wins() -> None:
160 # !keep.log then *.log — last match re-ignores.
161 assert _is_ignored("keep.log", ["!keep.log", "*.log"]) is True
162
163
164 def test_is_ignored_empty_patterns() -> None:
165 assert _is_ignored("anything.txt", []) is False
166
167
168 def test_is_ignored_deep_path() -> None:
169 assert _is_ignored("a/b/c/d.tmp", ["*.tmp"]) is True
170
171
172 # ---------------------------------------------------------------------------
173 # Unit: _safe_to_delete
174 # ---------------------------------------------------------------------------
175
176
177 def test_safe_to_delete_normal_file(tmp_path: pathlib.Path) -> None:
178 _init_repo(tmp_path)
179 target = tmp_path / "file.txt"
180 target.write_text("x", encoding="utf-8")
181 assert _safe_to_delete(tmp_path, target) is True
182
183
184 def test_safe_to_delete_blocks_muse_dir(tmp_path: pathlib.Path) -> None:
185 _init_repo(tmp_path)
186 target = tmp_path / ".muse" / "HEAD"
187 assert _safe_to_delete(tmp_path, target) is False
188
189
190 def test_safe_to_delete_blocks_deep_muse(tmp_path: pathlib.Path) -> None:
191 _init_repo(tmp_path)
192 target = tmp_path / ".muse" / "refs" / "heads" / "main"
193 assert _safe_to_delete(tmp_path, target) is False
194
195
196 # ---------------------------------------------------------------------------
197 # Unit: _safe_to_rmdir
198 # ---------------------------------------------------------------------------
199
200
201 def test_safe_to_rmdir_normal_dir(tmp_path: pathlib.Path) -> None:
202 _init_repo(tmp_path)
203 d = tmp_path / "empty_dir"
204 d.mkdir()
205 assert _safe_to_rmdir(tmp_path, d) is True
206
207
208 def test_safe_to_rmdir_blocks_root(tmp_path: pathlib.Path) -> None:
209 _init_repo(tmp_path)
210 assert _safe_to_rmdir(tmp_path, tmp_path) is False
211
212
213 def test_safe_to_rmdir_blocks_muse(tmp_path: pathlib.Path) -> None:
214 _init_repo(tmp_path)
215 assert _safe_to_rmdir(tmp_path, tmp_path / ".muse") is False
216
217
218 def test_safe_to_rmdir_blocks_muse_subtree(tmp_path: pathlib.Path) -> None:
219 _init_repo(tmp_path)
220 assert _safe_to_rmdir(tmp_path, tmp_path / ".muse" / "refs") is False
221
222
223 # ---------------------------------------------------------------------------
224 # Security: path traversal guard
225 # ---------------------------------------------------------------------------
226
227
228 def test_path_traversal_skipped(tmp_path: pathlib.Path) -> None:
229 """walk_workdir returning a path that resolves outside root is skipped."""
230 _init_repo(tmp_path)
231 outside = tmp_path.parent / "outside_target.txt"
232 outside.write_text("secret", encoding="utf-8")
233
234 fake_workdir: Manifest = {"../outside_target.txt": "deadbeef"}
235
236 with patch("muse.cli.commands.clean.walk_workdir", return_value=fake_workdir):
237 result = _invoke(["clean", "-f"], _env(tmp_path))
238
239 # Exit 0 — skipped file is not treated as an error.
240 assert result.exit_code == 0
241 # The outside file must still exist.
242 assert outside.exists()
243
244
245 def test_muse_dir_protected_even_if_listed(tmp_path: pathlib.Path) -> None:
246 """Even if walk_workdir incorrectly lists .muse/HEAD, it must not be deleted."""
247 _init_repo(tmp_path)
248 fake_workdir: Manifest = {".muse/HEAD": "deadbeef"}
249
250 with patch("muse.cli.commands.clean.walk_workdir", return_value=fake_workdir):
251 result = _invoke(["clean", "-f"], _env(tmp_path))
252
253 assert (tmp_path / ".muse" / "HEAD").exists()
254
255
256 # ---------------------------------------------------------------------------
257 # Error routing: all user errors go to stderr
258 # ---------------------------------------------------------------------------
259
260
261 def test_no_flags_error_on_stderr(tmp_path: pathlib.Path) -> None:
262 _init_repo(tmp_path)
263 (tmp_path / "junk.txt").write_text("junk", encoding="utf-8")
264 result = _invoke(["clean"], _env(tmp_path))
265 assert result.exit_code != 0
266 assert "force" in result.stderr.lower() or "force" in result.output.lower()
267
268
269 def test_ignore_load_failure_logs_warning_not_crash(tmp_path: pathlib.Path) -> None:
270 """OSError from load_ignore_config must not abort the command."""
271 _init_repo(tmp_path)
272 (tmp_path / "junk.txt").write_text("junk", encoding="utf-8")
273
274 with patch(
275 "muse.cli.commands.clean.load_ignore_config",
276 side_effect=OSError("disk full"),
277 ):
278 result = _invoke(["clean", "-n"], _env(tmp_path))
279
280 assert result.exit_code == 0
281 assert "junk.txt" in result.output
282
283
284 # ---------------------------------------------------------------------------
285 # JSON schema: _CleanResultJson
286 # ---------------------------------------------------------------------------
287
288
289 def test_json_nothing_to_clean(tmp_path: pathlib.Path) -> None:
290 _init_repo(tmp_path)
291 _commit_file(tmp_path, "tracked.txt", b"tracked")
292 result = _invoke(["clean", "-f", "--json"], _env(tmp_path))
293 assert result.exit_code == 0
294 data = _parse_json(result)
295 assert data["status"] == "clean"
296 assert data["removed"] == []
297 assert data["dirs_removed"] == []
298 assert data["count"] == 0
299 assert data["dry_run"] is False
300
301
302 def test_json_dry_run_shows_files(tmp_path: pathlib.Path) -> None:
303 _init_repo(tmp_path)
304 (tmp_path / "ghost.txt").write_text("ghost", encoding="utf-8")
305 result = _invoke(["clean", "-n", "--json"], _env(tmp_path))
306 assert result.exit_code == 0
307 data = _parse_json(result)
308 assert data["status"] == "would_remove"
309 assert "ghost.txt" in data["removed"]
310 assert data["count"] == 1
311 assert data["dry_run"] is True
312 assert (tmp_path / "ghost.txt").exists() # not deleted
313
314
315 def test_json_removed_files(tmp_path: pathlib.Path) -> None:
316 _init_repo(tmp_path)
317 _commit_file(tmp_path, "kept.txt", b"kept")
318 (tmp_path / "remove_me.txt").write_text("bye", encoding="utf-8")
319 result = _invoke(["clean", "-f", "--json"], _env(tmp_path))
320 assert result.exit_code == 0
321 data = _parse_json(result)
322 assert data["status"] == "removed"
323 assert "remove_me.txt" in data["removed"]
324 assert data["count"] == 1
325 assert data["dry_run"] is False
326
327
328 def test_json_dirs_removed(tmp_path: pathlib.Path) -> None:
329 _init_repo(tmp_path)
330 _commit_file(tmp_path, "kept.txt", b"kept")
331 d = tmp_path / "empty_subdir"
332 d.mkdir()
333 (d / "junk.txt").write_text("junk", encoding="utf-8")
334 result = _invoke(["clean", "-f", "-d", "--json"], _env(tmp_path))
335 assert result.exit_code == 0
336 data = _parse_json(result)
337 assert "empty_subdir/junk.txt" in data["removed"]
338 assert "empty_subdir" in data["dirs_removed"]
339
340
341 def test_json_schema_fields_present(tmp_path: pathlib.Path) -> None:
342 _init_repo(tmp_path)
343 result = _invoke(["clean", "-n", "--json"], _env(tmp_path))
344 assert result.exit_code == 0
345 data = _parse_json(result)
346 for key in ("status", "removed", "dirs_removed", "count", "dry_run"):
347 assert key in data, f"Missing key: {key}"
348
349
350 # ---------------------------------------------------------------------------
351 # --dry-run: no side effects
352 # ---------------------------------------------------------------------------
353
354
355 def test_dry_run_no_deletion(tmp_path: pathlib.Path) -> None:
356 _init_repo(tmp_path)
357 (tmp_path / "ephemeral.txt").write_text("keep me", encoding="utf-8")
358 result = _invoke(["clean", "-n"], _env(tmp_path))
359 assert result.exit_code == 0
360 assert (tmp_path / "ephemeral.txt").exists()
361
362
363 def test_dry_run_shows_count(tmp_path: pathlib.Path) -> None:
364 _init_repo(tmp_path)
365 for i in range(5):
366 (tmp_path / f"file_{i}.txt").write_text(str(i), encoding="utf-8")
367 result = _invoke(["clean", "-n"], _env(tmp_path))
368 assert result.exit_code == 0
369 assert "5" in result.output
370
371
372 def test_dry_run_json_reports_all(tmp_path: pathlib.Path) -> None:
373 _init_repo(tmp_path)
374 for i in range(3):
375 (tmp_path / f"tmp_{i}.txt").write_text(str(i), encoding="utf-8")
376 result = _invoke(["clean", "-n", "--json"], _env(tmp_path))
377 assert result.exit_code == 0
378 data = _parse_json(result)
379 assert data["count"] == 3
380 assert len(data["removed"]) == 3
381 assert data["dry_run"] is True
382
383
384 # ---------------------------------------------------------------------------
385 # --include-ignored: respects and overrides .museignore
386 # ---------------------------------------------------------------------------
387
388
389 def test_include_ignored_deletes_ignored_files(tmp_path: pathlib.Path) -> None:
390 _init_repo(tmp_path)
391 _commit_file(tmp_path, "tracked.txt", b"tracked")
392 (tmp_path / "debug.log").write_text("log", encoding="utf-8")
393
394 fake_patterns = ["*.log"]
395 with patch("muse.cli.commands.clean.resolve_patterns", return_value=fake_patterns):
396 # Without -x, the .log file is excluded from cleaning.
397 result_no_x = _invoke(["clean", "-n"], _env(tmp_path))
398 assert "debug.log" not in result_no_x.output
399
400 # With -x, the file is included.
401 result_x = _invoke(["clean", "-n", "-x"], _env(tmp_path))
402 assert "debug.log" in result_x.output
403
404
405 # ---------------------------------------------------------------------------
406 # --directories: empty-dir removal
407 # ---------------------------------------------------------------------------
408
409
410 def test_directories_removes_empty_dir_after_file_deletion(
411 tmp_path: pathlib.Path,
412 ) -> None:
413 _init_repo(tmp_path)
414 _commit_file(tmp_path, "kept.txt", b"kept")
415 subdir = tmp_path / "subdir"
416 subdir.mkdir()
417 (subdir / "junk.txt").write_text("junk", encoding="utf-8")
418
419 result = _invoke(["clean", "-f", "-d"], _env(tmp_path))
420 assert result.exit_code == 0
421 assert not subdir.exists()
422
423
424 def test_directories_leaves_non_empty_dir(tmp_path: pathlib.Path) -> None:
425 _init_repo(tmp_path)
426 subdir = tmp_path / "mixed"
427 subdir.mkdir()
428 (subdir / "untracked.txt").write_text("bye", encoding="utf-8")
429 (subdir / "kept.txt").write_bytes(b"keep me")
430 _commit_file(tmp_path, "mixed/kept.txt", b"keep me")
431
432 result = _invoke(["clean", "-f", "-d"], _env(tmp_path))
433 assert result.exit_code == 0
434 # Directory still exists (kept.txt is inside it and tracked).
435 assert subdir.is_dir()
436
437
438 def test_directories_dry_run_does_not_remove_dir(tmp_path: pathlib.Path) -> None:
439 _init_repo(tmp_path)
440 subdir = tmp_path / "dry_subdir"
441 subdir.mkdir()
442 (subdir / "junk.txt").write_text("junk", encoding="utf-8")
443
444 result = _invoke(["clean", "-n", "-d"], _env(tmp_path))
445 assert result.exit_code == 0
446 assert subdir.is_dir()
447
448
449 # ---------------------------------------------------------------------------
450 # Integration: full lifecycle
451 # ---------------------------------------------------------------------------
452
453
454 def test_integration_commit_then_clean(tmp_path: pathlib.Path) -> None:
455 _init_repo(tmp_path)
456 _commit_file(tmp_path, "tracked.txt", b"tracked")
457 (tmp_path / "untracked.txt").write_text("bye", encoding="utf-8")
458
459 result = _invoke(["clean", "-f"], _env(tmp_path))
460 assert result.exit_code == 0
461 assert not (tmp_path / "untracked.txt").exists()
462 assert (tmp_path / "tracked.txt").exists()
463
464
465 def test_integration_already_clean(tmp_path: pathlib.Path) -> None:
466 _init_repo(tmp_path)
467 _commit_file(tmp_path, "everything.txt", b"all tracked")
468
469 result = _invoke(["clean", "-f"], _env(tmp_path))
470 assert result.exit_code == 0
471 assert "nothing" in result.output.lower()
472
473
474 def test_integration_no_commits_cleans_all(tmp_path: pathlib.Path) -> None:
475 """With no HEAD commit every file is untracked."""
476 _init_repo(tmp_path)
477 (tmp_path / "orphan.txt").write_text("orphan", encoding="utf-8")
478
479 result = _invoke(["clean", "-f"], _env(tmp_path))
480 assert result.exit_code == 0
481 assert not (tmp_path / "orphan.txt").exists()
482
483
484 def test_integration_json_full_cycle(tmp_path: pathlib.Path) -> None:
485 _init_repo(tmp_path)
486 _commit_file(tmp_path, "a.txt", b"a")
487 (tmp_path / "b.txt").write_text("b", encoding="utf-8")
488 (tmp_path / "c.txt").write_text("c", encoding="utf-8")
489
490 result = _invoke(["clean", "-f", "--json"], _env(tmp_path))
491 assert result.exit_code == 0
492 data = _parse_json(result)
493 assert data["count"] == 2
494 assert set(data["removed"]) == {"b.txt", "c.txt"}
495 assert not (tmp_path / "b.txt").exists()
496 assert not (tmp_path / "c.txt").exists()
497 assert (tmp_path / "a.txt").exists()
498
499
500 # ---------------------------------------------------------------------------
501 # E2E: help output
502 # ---------------------------------------------------------------------------
503
504
505 def test_help_output() -> None:
506 result = _invoke(["clean", "--help"], {})
507 assert result.exit_code == 0
508 for flag in ("-f", "--force", "-n", "--dry-run", "--json"):
509 assert flag in result.output
510
511
512 def test_help_describes_json_flag() -> None:
513 result = _invoke(["clean", "--help"], {})
514 assert "json" in result.output.lower()
515
516
517 # ---------------------------------------------------------------------------
518 # Stress: 1 000 untracked files
519 # ---------------------------------------------------------------------------
520
521
522 def test_stress_1000_untracked(tmp_path: pathlib.Path) -> None:
523 _init_repo(tmp_path)
524 for i in range(1_000):
525 (tmp_path / f"stress_{i:04d}.dat").write_bytes(b"x" * 64)
526
527 result = _invoke(["clean", "-f", "--json"], _env(tmp_path))
528 assert result.exit_code == 0
529 data = _parse_json(result)
530 assert data["count"] == 1_000
531 remaining = list(tmp_path.glob("stress_*.dat"))
532 assert len(remaining) == 0
533
534
535 def test_stress_1000_dry_run(tmp_path: pathlib.Path) -> None:
536 _init_repo(tmp_path)
537 for i in range(1_000):
538 (tmp_path / f"dry_{i:04d}.dat").write_bytes(b"y" * 64)
539
540 result = _invoke(["clean", "-n", "--json"], _env(tmp_path))
541 assert result.exit_code == 0
542 data = _parse_json(result)
543 assert data["count"] == 1_000
544 assert data["dry_run"] is True
545 # Nothing deleted.
546 remaining = list(tmp_path.glob("dry_*.dat"))
547 assert len(remaining) == 1_000
548
549
550 def test_stress_50_ignore_patterns(tmp_path: pathlib.Path) -> None:
551 """_is_ignored with 50 patterns must not crash and must filter correctly."""
552 patterns = [f"*.ext{i}" for i in range(50)]
553 assert _is_ignored("file.ext25", patterns) is True
554 assert _is_ignored("file.py", patterns) is False
555
556
557 def test_stress_concurrent_json_reads(tmp_path: pathlib.Path) -> None:
558 """Concurrent dry-run invocations must all exit 0 without data races.
559
560 CliRunner serialises stdout capture per invocation, so we guard each call
561 with a lock and check only the exit code and JSON parse-ability rather
562 than racing on the shared capture buffer.
563 """
564 _init_repo(tmp_path)
565 _commit_file(tmp_path, "tracked.txt", b"tracked")
566 for i in range(20):
567 (tmp_path / f"concurrent_{i}.txt").write_text(str(i), encoding="utf-8")
568
569 invoke_lock = threading.Lock()
570 errors: list[str] = []
571
572 def _worker() -> None:
573 with invoke_lock:
574 r = _invoke(["clean", "-n", "--json"], _env(tmp_path))
575 try:
576 assert r.exit_code == 0
577 data = _parse_json(r)
578 assert data["count"] == 20
579 except Exception as exc:
580 errors.append(str(exc))
581
582 threads = [threading.Thread(target=_worker) for _ in range(8)]
583 for t in threads:
584 t.start()
585 for t in threads:
586 t.join()
587
588 assert errors == [], f"Concurrent read failures: {errors}"
589
590
591 # ---------------------------------------------------------------------------
592 # Edge cases
593 # ---------------------------------------------------------------------------
594
595
596 def test_force_and_dry_run_together_dry_wins(tmp_path: pathlib.Path) -> None:
597 """When both -f and -n are given, -n wins (no deletion)."""
598 _init_repo(tmp_path)
599 (tmp_path / "both_flags.txt").write_text("keep", encoding="utf-8")
600 result = _invoke(["clean", "-f", "-n"], _env(tmp_path))
601 assert result.exit_code == 0
602 assert (tmp_path / "both_flags.txt").exists()
603
604
605 def test_ansi_in_filename_sanitized(tmp_path: pathlib.Path) -> None:
606 """ANSI escape codes embedded in filenames must not leak to output."""
607 _init_repo(tmp_path)
608 # Use a filename that contains ANSI escape chars encoded in the name.
609 evil_name = "evil\x1b[31mred\x1b[0m.txt"
610 try:
611 (tmp_path / evil_name).write_text("evil", encoding="utf-8")
612 except (OSError, ValueError):
613 pytest.skip("filesystem does not support ANSI chars in filenames")
614
615 result = _invoke(["clean", "-n"], _env(tmp_path))
616 assert "\x1b[31m" not in result.output
617
618
619 def test_clean_respects_muse_dir_immune(tmp_path: pathlib.Path) -> None:
620 """Under no circumstances should clean delete anything inside .muse/."""
621 _init_repo(tmp_path)
622 head_before = (tmp_path / ".muse" / "HEAD").read_text()
623
624 with patch(
625 "muse.cli.commands.clean.walk_workdir",
626 return_value={
627 ".muse/HEAD": "abc",
628 ".muse/repo.json": "def",
629 },
630 ):
631 result = _invoke(["clean", "-f"], _env(tmp_path))
632
633 assert result.exit_code == 0
634 assert (tmp_path / ".muse" / "HEAD").read_text() == head_before
635
636
637 # ---------------------------------------------------------------------------
638 # Agent supercharge — duration_ms and exit_code in every JSON output
639 # ---------------------------------------------------------------------------
640
641
642 class TestElapsed:
643 """Every JSON output path must include ``duration_ms`` as a float."""
644
645 def test_nothing_to_clean_has_elapsed(self, tmp_path: pathlib.Path) -> None:
646 _init_repo(tmp_path)
647 _commit_file(tmp_path, "tracked.txt", b"tracked")
648 result = _invoke(["clean", "-n", "--json"], _env(tmp_path))
649 assert result.exit_code == 0
650 data = _parse_json(result)
651 assert "duration_ms" in data
652 assert isinstance(data["duration_ms"], float)
653
654 def test_dry_run_with_files_has_elapsed(self, tmp_path: pathlib.Path) -> None:
655 _init_repo(tmp_path)
656 _commit_file(tmp_path, "tracked.txt", b"tracked")
657 (tmp_path / "untracked.txt").write_text("x")
658 result = _invoke(["clean", "-n", "--json"], _env(tmp_path))
659 assert result.exit_code == 0
660 data = _parse_json(result)
661 assert "duration_ms" in data
662 assert isinstance(data["duration_ms"], float)
663
664 def test_force_clean_has_elapsed(self, tmp_path: pathlib.Path) -> None:
665 _init_repo(tmp_path)
666 _commit_file(tmp_path, "tracked.txt", b"tracked")
667 (tmp_path / "untracked.txt").write_text("x")
668 result = _invoke(["clean", "-f", "--json"], _env(tmp_path))
669 assert result.exit_code == 0
670 data = _parse_json(result)
671 assert "duration_ms" in data
672 assert isinstance(data["duration_ms"], float)
673
674
675 class TestExitCode:
676 """Every JSON output path must include ``exit_code`` mirroring process exit."""
677
678 def test_nothing_to_clean_exit_code_0(self, tmp_path: pathlib.Path) -> None:
679 _init_repo(tmp_path)
680 _commit_file(tmp_path, "tracked.txt", b"tracked")
681 result = _invoke(["clean", "-n", "--json"], _env(tmp_path))
682 data = _parse_json(result)
683 assert data["exit_code"] == 0
684
685 def test_dry_run_with_files_exit_code_0(self, tmp_path: pathlib.Path) -> None:
686 _init_repo(tmp_path)
687 _commit_file(tmp_path, "tracked.txt", b"tracked")
688 (tmp_path / "untracked.txt").write_text("x")
689 result = _invoke(["clean", "-n", "--json"], _env(tmp_path))
690 data = _parse_json(result)
691 assert data["exit_code"] == 0
692
693 def test_force_clean_exit_code_0(self, tmp_path: pathlib.Path) -> None:
694 _init_repo(tmp_path)
695 _commit_file(tmp_path, "tracked.txt", b"tracked")
696 (tmp_path / "untracked.txt").write_text("x")
697 result = _invoke(["clean", "-f", "--json"], _env(tmp_path))
698 data = _parse_json(result)
699 assert data["exit_code"] == 0
700
701
702 class TestDryRunStatus:
703 """Dry-run with files to remove must report status ``would_remove``, not ``clean``."""
704
705 def test_dry_run_with_files_status_is_would_remove(self, tmp_path: pathlib.Path) -> None:
706 _init_repo(tmp_path)
707 _commit_file(tmp_path, "tracked.txt", b"tracked")
708 (tmp_path / "untracked.txt").write_text("x")
709 result = _invoke(["clean", "-n", "--json"], _env(tmp_path))
710 data = _parse_json(result)
711 assert data["status"] == "would_remove", (
712 f"dry-run with files should be 'would_remove', got {data['status']!r}"
713 )
714
715 def test_dry_run_no_files_status_is_clean(self, tmp_path: pathlib.Path) -> None:
716 _init_repo(tmp_path)
717 _commit_file(tmp_path, "tracked.txt", b"tracked")
718 result = _invoke(["clean", "-n", "--json"], _env(tmp_path))
719 data = _parse_json(result)
720 assert data["status"] == "clean"
721
722 def test_force_with_files_status_is_removed(self, tmp_path: pathlib.Path) -> None:
723 _init_repo(tmp_path)
724 _commit_file(tmp_path, "tracked.txt", b"tracked")
725 (tmp_path / "untracked.txt").write_text("x")
726 result = _invoke(["clean", "-f", "--json"], _env(tmp_path))
727 data = _parse_json(result)
728 assert data["status"] == "removed"
729
730
731 class TestJsonSchemaComplete:
732 """Full schema must include all fields including duration_ms and exit_code."""
733
734 _FULL_KEYS = {"status", "removed", "dirs_removed", "count", "dry_run",
735 "duration_ms", "exit_code"}
736
737 def test_nothing_to_clean_schema_complete(self, tmp_path: pathlib.Path) -> None:
738 _init_repo(tmp_path)
739 _commit_file(tmp_path, "tracked.txt", b"tracked")
740 result = _invoke(["clean", "-n", "--json"], _env(tmp_path))
741 data = _parse_json(result)
742 missing = self._FULL_KEYS - data.keys()
743 assert not missing, f"Missing keys in clean JSON: {missing}"
744
745 def test_dry_run_with_files_schema_complete(self, tmp_path: pathlib.Path) -> None:
746 _init_repo(tmp_path)
747 _commit_file(tmp_path, "tracked.txt", b"tracked")
748 (tmp_path / "untracked.txt").write_text("x")
749 result = _invoke(["clean", "-n", "--json"], _env(tmp_path))
750 data = _parse_json(result)
751 missing = self._FULL_KEYS - data.keys()
752 assert not missing, f"Missing keys in dry-run JSON: {missing}"
753
754 def test_force_clean_schema_complete(self, tmp_path: pathlib.Path) -> None:
755 _init_repo(tmp_path)
756 _commit_file(tmp_path, "tracked.txt", b"tracked")
757 (tmp_path / "untracked.txt").write_text("x")
758 result = _invoke(["clean", "-f", "--json"], _env(tmp_path))
759 data = _parse_json(result)
760 missing = self._FULL_KEYS - data.keys()
761 assert not missing, f"Missing keys in force JSON: {missing}"
762
763
764 # ---------------------------------------------------------------------------
765 # Flag registration tests
766 # ---------------------------------------------------------------------------
767
768 import argparse as _argparse
769 from muse.cli.commands.clean import register as _register_clean
770
771
772 def _parse_clean(*args: str) -> _argparse.Namespace:
773 root_p = _argparse.ArgumentParser()
774 subs = root_p.add_subparsers(dest="cmd")
775 _register_clean(subs)
776 return root_p.parse_args(["clean", *args])
777
778
779 class TestRegisterFlags:
780 def test_default_json_out_is_false(self) -> None:
781 ns = _parse_clean()
782 assert ns.json_out is False
783
784 def test_json_flag_sets_json_out(self) -> None:
785 ns = _parse_clean("--json")
786 assert ns.json_out is True
787
788 def test_j_shorthand_sets_json_out(self) -> None:
789 ns = _parse_clean("-j")
790 assert ns.json_out is True
791
792 def test_force_flag(self) -> None:
793 ns = _parse_clean("--force")
794 assert ns.force is True
795
796 def test_dry_run_n_shorthand(self) -> None:
797 ns = _parse_clean("-n")
798 assert ns.dry_run is True
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 134 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 140 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 143 days ago