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