"""Tests for ``muse rm``. Covers: - --cached: stage deletion without touching disk - no --cached: stage deletion AND delete from disk - -r / --recursive: required for directories - -f / --force: bypass safety checks - -n / --dry-run: preview without side effects - --json: machine-readable output (always valid, including error paths) - File not tracked → exit 1 - Directory without -r → exit 1 - Modified file without --force → exit 1 - Staged-addition without --force → exit 1 - Multiple paths in one invocation - Stress: 200 files, remove half """ from __future__ import annotations import datetime import hashlib import json import pathlib import pytest from tests.cli_test_helper import CliRunner from muse.core.object_store import write_object from muse.core.snapshot import compute_commit_id, compute_snapshot_id from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot from muse.core._types import Manifest from muse.plugins.code.stage import StagedFileMap, make_entry, read_stage, write_stage type _EnvDict = dict[str, str] type _FileBytes = dict[str, bytes] cli = None # argparse migration — CliRunner ignores this arg runner = CliRunner() _REPO_ID = "rm-test" # --------------------------------------------------------------------------- # Test-repo bootstrap helpers # --------------------------------------------------------------------------- def _sha(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def _init_repo(path: pathlib.Path) -> pathlib.Path: muse = path / ".muse" for d in ("commits", "snapshots", "objects", "refs/heads"): (muse / d).mkdir(parents=True, exist_ok=True) (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") (muse / "repo.json").write_text( json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8" ) return path def _env(repo: pathlib.Path) -> _EnvDict: return {"MUSE_REPO_ROOT": str(repo)} _counter = 0 def _commit_files(root: pathlib.Path, files: _FileBytes) -> str: """Write *files* to disk and to the object store; create a commit.""" global _counter _counter += 1 manifest: Manifest = {} for rel_path, content in files.items(): obj_id = _sha(content) write_object(root, obj_id, content) manifest[rel_path] = obj_id # Write to disk so on-disk and committed content match. abs_path = root / rel_path abs_path.parent.mkdir(parents=True, exist_ok=True) abs_path.write_bytes(content) snap_id = compute_snapshot_id(manifest) write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest)) committed_at = datetime.datetime.now(datetime.timezone.utc) commit_id = compute_commit_id( [], snap_id, f"commit {_counter}", committed_at.isoformat() ) write_commit( root, CommitRecord( commit_id=commit_id, repo_id=_REPO_ID, branch="main", snapshot_id=snap_id, message=f"commit {_counter}", committed_at=committed_at, ), ) (root / ".muse" / "refs" / "heads" / "main").write_text(commit_id, encoding="utf-8") return commit_id # --------------------------------------------------------------------------- # help # --------------------------------------------------------------------------- def test_rm_help() -> None: result = runner.invoke(cli, ["rm", "--help"]) assert result.exit_code == 0 assert "--cached" in result.output # --------------------------------------------------------------------------- # --cached: stage deletion, keep file on disk # --------------------------------------------------------------------------- def test_rm_cached_stages_deletion(tmp_path: pathlib.Path) -> None: """``muse rm --cached`` writes mode D to stage, leaves file on disk.""" _init_repo(tmp_path) _commit_files(tmp_path, {"song.txt": b"verse\n"}) result = runner.invoke( cli, ["rm", "--cached", "song.txt"], env=_env(tmp_path) ) assert result.exit_code == 0 # File still on disk. assert (tmp_path / "song.txt").exists() # Stage has a "D" entry for song.txt. stage = read_stage(tmp_path) assert "song.txt" in stage assert stage["song.txt"]["mode"] == "D" def test_rm_cached_json_output(tmp_path: pathlib.Path) -> None: """``muse rm --cached --json`` emits valid JSON with expected fields.""" _init_repo(tmp_path) _commit_files(tmp_path, {"notes.txt": b"A\n"}) result = runner.invoke( cli, ["rm", "--cached", "--json", "notes.txt"], env=_env(tmp_path) ) assert result.exit_code == 0 data = json.loads(result.output) assert data["status"] == "removed" assert "notes.txt" in data["removed"] assert data["cached"] is True assert data["dry_run"] is False assert data["count"] == 1 # --------------------------------------------------------------------------- # Without --cached: stage deletion AND delete from disk # --------------------------------------------------------------------------- def test_rm_deletes_file_from_disk(tmp_path: pathlib.Path) -> None: """``muse rm`` without --cached removes the file from disk.""" _init_repo(tmp_path) _commit_files(tmp_path, {"beat.mid": b"\x00\x01\x02"}) result = runner.invoke(cli, ["rm", "beat.mid"], env=_env(tmp_path)) assert result.exit_code == 0 # File gone from disk. assert not (tmp_path / "beat.mid").exists() # Stage has a "D" entry. stage = read_stage(tmp_path) assert stage["beat.mid"]["mode"] == "D" def test_rm_json_no_cached(tmp_path: pathlib.Path) -> None: """``muse rm --json`` emits cached=false.""" _init_repo(tmp_path) _commit_files(tmp_path, {"f.txt": b"x\n"}) result = runner.invoke(cli, ["rm", "--json", "f.txt"], env=_env(tmp_path)) assert result.exit_code == 0 data = json.loads(result.output) assert data["cached"] is False assert data["count"] == 1 # --------------------------------------------------------------------------- # File not tracked → exit 1 # --------------------------------------------------------------------------- def test_rm_untracked_file_exits_1(tmp_path: pathlib.Path) -> None: """Removing an untracked file must exit non-zero.""" _init_repo(tmp_path) _commit_files(tmp_path, {"existing.txt": b"x\n"}) result = runner.invoke( cli, ["rm", "does_not_exist.txt"], env=_env(tmp_path) ) assert result.exit_code != 0 def test_rm_untracked_does_not_affect_stage(tmp_path: pathlib.Path) -> None: """Attempting to remove an untracked file must not mutate the stage.""" _init_repo(tmp_path) _commit_files(tmp_path, {"a.txt": b"a\n"}) runner.invoke(cli, ["rm", "ghost.txt"], env=_env(tmp_path)) stage = read_stage(tmp_path) # Stage should still be empty (no entry added for a.txt or ghost.txt). assert "a.txt" not in stage # --------------------------------------------------------------------------- # Directory without -r → exit 1 # --------------------------------------------------------------------------- def test_rm_directory_without_recursive_exits_1(tmp_path: pathlib.Path) -> None: """Removing a directory path without -r must exit non-zero.""" _init_repo(tmp_path) _commit_files(tmp_path, {"src/main.py": b"pass\n"}) result = runner.invoke(cli, ["rm", "--cached", "src"], env=_env(tmp_path)) assert result.exit_code != 0 def test_rm_directory_with_recursive_stages_all(tmp_path: pathlib.Path) -> None: """``muse rm -r --cached `` stages deletion for every file under dir.""" _init_repo(tmp_path) _commit_files( tmp_path, { "src/a.py": b"a\n", "src/b.py": b"b\n", "other.txt": b"c\n", }, ) result = runner.invoke( cli, ["rm", "-r", "--cached", "src"], env=_env(tmp_path) ) assert result.exit_code == 0 stage = read_stage(tmp_path) assert stage["src/a.py"]["mode"] == "D" assert stage["src/b.py"]["mode"] == "D" # File outside the directory must be untouched. assert "other.txt" not in stage # --------------------------------------------------------------------------- # Modified file without --force → exit 1 # --------------------------------------------------------------------------- def test_rm_modified_file_without_force_exits_1(tmp_path: pathlib.Path) -> None: """Removing a locally-modified file without --force must exit non-zero.""" _init_repo(tmp_path) _commit_files(tmp_path, {"track.txt": b"original\n"}) # Mutate the on-disk copy after committing. (tmp_path / "track.txt").write_bytes(b"modified\n") result = runner.invoke(cli, ["rm", "track.txt"], env=_env(tmp_path)) assert result.exit_code != 0 # File must not have been deleted. assert (tmp_path / "track.txt").exists() def test_rm_modified_file_with_force_succeeds(tmp_path: pathlib.Path) -> None: """``muse rm --force`` removes a locally-modified file.""" _init_repo(tmp_path) _commit_files(tmp_path, {"track.txt": b"original\n"}) (tmp_path / "track.txt").write_bytes(b"modified\n") result = runner.invoke(cli, ["rm", "--force", "track.txt"], env=_env(tmp_path)) assert result.exit_code == 0 assert not (tmp_path / "track.txt").exists() assert read_stage(tmp_path)["track.txt"]["mode"] == "D" def test_rm_cached_modified_no_force_ok(tmp_path: pathlib.Path) -> None: """``muse rm --cached`` on a modified file is always safe (no disk delete).""" _init_repo(tmp_path) _commit_files(tmp_path, {"track.txt": b"original\n"}) (tmp_path / "track.txt").write_bytes(b"modified\n") # --cached never deletes from disk so the safety check is skipped. result = runner.invoke( cli, ["rm", "--cached", "track.txt"], env=_env(tmp_path) ) assert result.exit_code == 0 # On-disk copy preserved (and modified). assert (tmp_path / "track.txt").read_bytes() == b"modified\n" assert read_stage(tmp_path)["track.txt"]["mode"] == "D" # --------------------------------------------------------------------------- # Staged-addition without --force → exit 1 # --------------------------------------------------------------------------- def test_rm_staged_addition_without_force_exits_1(tmp_path: pathlib.Path) -> None: """Removing a staged-but-never-committed file without --force exits non-zero.""" _init_repo(tmp_path) # No commit — the file only exists in the stage (mode A). (tmp_path / "new.py").write_bytes(b"print('hi')\n") stage: StagedFileMap = { "new.py": make_entry(object_id=_sha(b"print('hi')\n"), mode="A"), } write_stage(tmp_path, stage) result = runner.invoke(cli, ["rm", "--cached", "new.py"], env=_env(tmp_path)) assert result.exit_code != 0 def test_rm_staged_addition_with_force_removes_from_stage( tmp_path: pathlib.Path, ) -> None: """``muse rm --force --cached`` removes a staged-addition entry from stage.""" _init_repo(tmp_path) (tmp_path / "new.py").write_bytes(b"print('hi')\n") stage: StagedFileMap = { "new.py": make_entry(object_id=_sha(b"print('hi')\n"), mode="A"), } write_stage(tmp_path, stage) result = runner.invoke( cli, ["rm", "--force", "--cached", "new.py"], env=_env(tmp_path) ) assert result.exit_code == 0 # Entry must be gone from stage (not just flipped to D). assert "new.py" not in read_stage(tmp_path) # --------------------------------------------------------------------------- # --dry-run: no side effects # --------------------------------------------------------------------------- def test_rm_dry_run_does_not_delete(tmp_path: pathlib.Path) -> None: """``muse rm -n`` must not delete the file or write to the stage.""" _init_repo(tmp_path) _commit_files(tmp_path, {"chord.txt": b"Am\n"}) result = runner.invoke(cli, ["rm", "-n", "chord.txt"], env=_env(tmp_path)) assert result.exit_code == 0 # File still on disk. assert (tmp_path / "chord.txt").exists() # Stage must be empty. assert "chord.txt" not in read_stage(tmp_path) def test_rm_dry_run_json(tmp_path: pathlib.Path) -> None: """``muse rm -n --json`` emits valid JSON with status=dry_run.""" _init_repo(tmp_path) _commit_files(tmp_path, {"chord.txt": b"Am\n"}) result = runner.invoke( cli, ["rm", "-n", "--json", "chord.txt"], env=_env(tmp_path) ) assert result.exit_code == 0 data = json.loads(result.output) assert data["status"] == "dry_run" assert "chord.txt" in data["removed"] assert data["dry_run"] is True assert data["count"] == 1 def test_rm_dry_run_cached(tmp_path: pathlib.Path) -> None: """``muse rm -n --cached`` previews correctly, writes nothing.""" _init_repo(tmp_path) _commit_files(tmp_path, {"f.txt": b"x\n"}) result = runner.invoke( cli, ["rm", "-n", "--cached", "--json", "f.txt"], env=_env(tmp_path) ) assert result.exit_code == 0 data = json.loads(result.output) assert data["status"] == "dry_run" assert data["cached"] is True # --------------------------------------------------------------------------- # Multiple paths in one invocation # --------------------------------------------------------------------------- def test_rm_multiple_files(tmp_path: pathlib.Path) -> None: """Multiple paths in one ``muse rm`` invocation are all removed.""" _init_repo(tmp_path) _commit_files( tmp_path, {"a.txt": b"a\n", "b.txt": b"b\n", "c.txt": b"c\n"}, ) result = runner.invoke( cli, ["rm", "--cached", "--json", "a.txt", "b.txt"], env=_env(tmp_path) ) assert result.exit_code == 0 data = json.loads(result.output) assert data["count"] == 2 assert "a.txt" in data["removed"] assert "b.txt" in data["removed"] assert "c.txt" not in data["removed"] stage = read_stage(tmp_path) assert stage["a.txt"]["mode"] == "D" assert stage["b.txt"]["mode"] == "D" assert "c.txt" not in stage # --------------------------------------------------------------------------- # Idempotency: remove already-staged-for-deletion # --------------------------------------------------------------------------- def test_rm_already_staged_deletion_is_idempotent(tmp_path: pathlib.Path) -> None: """Calling ``muse rm --cached`` twice on the same file is idempotent.""" _init_repo(tmp_path) _commit_files(tmp_path, {"x.txt": b"x\n"}) runner.invoke(cli, ["rm", "--cached", "x.txt"], env=_env(tmp_path)) result = runner.invoke(cli, ["rm", "--cached", "x.txt"], env=_env(tmp_path)) # Second call should still exit 0 — the file is still "tracked" in HEAD. assert result.exit_code == 0 assert read_stage(tmp_path)["x.txt"]["mode"] == "D" # --------------------------------------------------------------------------- # Stress: 200 files, remove half # --------------------------------------------------------------------------- def test_rm_stress_200_files(tmp_path: pathlib.Path) -> None: """Remove 100 of 200 committed files; verify stage and disk state.""" _init_repo(tmp_path) files = { f"file_{i:04d}.txt": f"content {i}\n".encode() for i in range(200) } _commit_files(tmp_path, files) # Remove even-numbered files with --cached. to_remove = [f"file_{i:04d}.txt" for i in range(200) if i % 2 == 0] result = runner.invoke( cli, ["rm", "--cached", "--json"] + to_remove, env=_env(tmp_path), ) assert result.exit_code == 0 data = json.loads(result.output) assert data["count"] == 100 stage = read_stage(tmp_path) # All even-numbered files staged for deletion. for i in range(200): name = f"file_{i:04d}.txt" if i % 2 == 0: assert stage[name]["mode"] == "D" else: assert name not in stage # All files still on disk (--cached). for name in files: assert (tmp_path / name).exists() # --------------------------------------------------------------------------- # JSON schema completeness # --------------------------------------------------------------------------- def test_rm_json_schema_all_fields(tmp_path: pathlib.Path) -> None: """JSON output always contains status, removed, cached, dry_run, count.""" _init_repo(tmp_path) _commit_files(tmp_path, {"z.txt": b"z\n"}) result = runner.invoke( cli, ["rm", "--json", "z.txt"], env=_env(tmp_path) ) assert result.exit_code == 0 data = json.loads(result.output) for key in ("status", "removed", "cached", "dry_run", "count"): assert key in data, f"Missing key: {key}" # --------------------------------------------------------------------------- # Recursive removal with disk delete # --------------------------------------------------------------------------- def test_rm_recursive_deletes_from_disk(tmp_path: pathlib.Path) -> None: """``muse rm -r `` removes all tracked files under dir from disk.""" _init_repo(tmp_path) _commit_files( tmp_path, { "static/app.css": b"body{}\n", "static/app.js": b"console.log(1)\n", "src/main.py": b"pass\n", }, ) result = runner.invoke( cli, ["rm", "-r", "--json", "static"], env=_env(tmp_path) ) assert result.exit_code == 0 data = json.loads(result.output) assert data["count"] == 2 assert not (tmp_path / "static" / "app.css").exists() assert not (tmp_path / "static" / "app.js").exists() # File outside the directory must be untouched. assert (tmp_path / "src" / "main.py").exists() stage = read_stage(tmp_path) assert stage["static/app.css"]["mode"] == "D" assert stage["static/app.js"]["mode"] == "D" assert "src/main.py" not in stage