"""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