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